oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Sheets β€” design
2
3**Status:** design, revised 2026-08-30 after review. Approved in
4brainstorming by Paul on 2026-08-30; revised the same day against a
5review dispatched to **Fable** (13 confirmed findings), with three source
6claims independently re-verified by this session and three more by the Docs
7session. Every ruling is dated and attributed, with the rejected
8alternative beside it.
9**Reviewing model, per Β§12.1:** dispatched to Fable. *(The agent's own
10header self-reported as Opus 5; models are unreliable about their own
11identity, so the honest record is what was dispatched.)*
12**Scope:** the Sheets instance app. The Suite home is a **separate spec in
13a separate repo** (Β§9); this document depends on it and does not define it.
14
15---
16
17## 0. The one rule
18
19> **A sheet's contents are sealed at rest. The bucket never holds a
20> plaintext cell.**
21
22The instance holds the key while it runs, so the server *can* read a sheet
23it has open. That is a deliberate softening, not an accident, and Β§2 names
24every leak it produces. What the rule forbids absolutely is a plaintext
25value reaching the replicated SQLite file or any object storage we write.
26
27`AGENTS.md` opens with this rule verbatim, and is written before any code.
28
29---
30
31## 1. Settled decisions (don't relitigate casually)
32
33| # | Ruling | Rejected | Who, when |
34|---|---|---|---|
35| 1 | **Sealed at rest, DEK in instance memory** (Seapointish's position) | Plaintext server-trust (Tito); full E2EE (Keymail/Kass); split-by-class (Woodstar) | Paul, 2026-08-30 |
36| 2 | **Vendor Jspreadsheet CE v5 (MIT, single UMD file, no bundler); its formula engine is DISABLED β€” Go owns calc** | Client-side calc; hand-built VanJS grid; Univer (needs a bundler) | Paul, 2026-08-30 |
37| 3 | **Cell-level last-write-wins, live over SSE** | Save-and-reload; single-writer locking; CRDT/OT | Paul, 2026-08-30 |
38| 4 | **Rows + append-only log + in-memory sheet handle** | Sealed blob per tab; rastrillo's `mergeable` store | Paul, 2026-08-30 |
39| 5 | **Slice one includes version history, sort/filter/conditional formatting, comments AND charts** | A smaller slice | Paul, 2026-08-30 |
40| 6 | **All four "jank" cuts** | Keeping any of them | Paul, 2026-08-30 |
41| 7 | **`day` theme** | `signal`; `plain` | Paul, 2026-08-30 |
42| 8 | **Cell fill stored as OKLCH intent; imported hex retained** | Intent only (lossy round-trip); absolute hex (breaks in dark mode) | Paul, 2026-08-30 |
43| 9 | **Provisioning: the home queues, an operator drains** | A standing console token in a web-facing app | Paul, 2026-08-30 |
44| 10 | **The home is its own repo and its own spec** | Keeping it here; Docs drafting it | Paul, 2026-08-30 |
45| 11 | ~~Passkey RP is an `oncarlos.com` host, provisionally~~ β€” superseded by 18 | A real suite domain now; no passkeys | Paul, 2026-08-30 |
46| 12 | **Link sharing is viewer-or-commenter, never editor** | Anonymous link editing | Paul, 2026-08-30 |
47| 13 | **Passkey 2FA optional per person, owner-enforceable per team** | Mandatory; none | Paul, 2026-08-30 |
48| 14 | **Light/dark is a per-person preference, switchable in the editor** | Per-document; per-team; follow-OS-only | Paul, 2026-08-30 |
49| 15 | **One RP: the home's credential origin. Instances never enroll passkeys** β€” Β§8.1 | Per-instance passkeys (three teams = three credentials) | Proposed by this session, **confirmed by Paul, 2026-08-30** |
50| 16 | **Version history replays inputs and recomputes; derived values are never logged** β€” Β§4.3 | Logging derived values (instant restore, log grows with fan-out) | Paul, 2026-08-30 |
51| 17 | **Slip order written down: charts first, conditional formatting second. Version history's baseline is restore-a-cell-or-range** β€” Β§13 | Holding all eleven at full scope; cutting charts outright today | Paul, 2026-08-30 |
52| 18 | **`oficina.works` is the primary domain; the RP is `id.oficina.works`, instances are `<team>.sheets.oficina.works`** β€” Β§9.7 | Waiting until passkeys shipped (the move only stays free until then) | Paul, 2026-09-03 |
53
54---
55
56## 2. Trust model, and the leak list
57
58The DEK arrives as platform config (`carlos secrets`), never stored in the
59database it decrypts. HKDF derives purpose keys; values are sealed with
60AES-GCM. Golden vectors pin the envelope.
61
62### 2.1 Sealed
63
64- Every cell's raw input and every cached computed value.
65- Sheet titles, tab names, comment text.
66- **Event log payloads.** Version history and undo are unimplementable
67 unless the log carries old and new cell values, and those values are user
68 content. This was missing from the first draft and the grep gate would
69 have caught it in week two β€” see Β§4.3 for what the log actually stores.
70- **Conditional-formatting rule constants.** `> 1000`, `contains
71 "overdue"` β€” user data living inside a format-adjacent record.
72- **Persisted sort and filter criteria.** A filter value *is* a cell value.
73- **Chart titles, axis labels and series names.**
74- **Every blob**, via `blobs.Sealed` β€” see Β§2.3.
75
76### 2.2 Plaintext, and published in the README rather than discovered
77
78- Cell coordinates and stable ids β€” therefore the **shape and size** of
79 every sheet.
80- Format records: fills (as OKLCH intent), borders, alignment, number
81 format codes. Not the rule constants that select them (Β§2.1).
82- Timestamps, actor ids, and the shape of the edit log.
83- Membership, grants, and roles β€” **including readable email addresses**
84 where a grant carries one (see Β§9.6).
85
86Publish our database and you learn who works with whom, how big their
87spreadsheets are, how often they edit and what colour they painted things.
88You learn **no value in any cell**.
89
90### 2.3 Blobs, and the metadata that escapes sealing
91
92Imported and exported XLSX files are whole spreadsheets. If they reach
93`rastrillo/blobs` on the S3 backend unsealed, that is a plaintext
94spreadsheet in a bucket β€” Β§0 defeated through a side door.
95
96**`blobs.Sealed` is mandatory for every blob this app writes.** It exists
97for exactly this (`blobs/blobs.go`: E2EE apps wrap any backend in Sealed).
98
99Three properties of it that shape our usage, all verified against source:
100
101- **`ContentType` passes through as visible metadata.** We write
102 `application/octet-stream`, always. Sealing a spreadsheet's bytes and
103 then labelling it as a spreadsheet would be a strange place to end up.
104- The blob address is the **ciphertext hash**, so deduplication is per-key.
105- `Ref.Size` is the **sealed** size. Any quota or progress figure shown to
106 a person must account for that or it will be visibly wrong.
107
108### 2.4 Logs and error text are storage
109
110`carlos logs` is platform-held. An import error reading *"can't parse
111'salary 50000' at B7"* ships cell content off the instance to storage we do
112not control and cannot grep.
113
114**Rule: no cell content, comment text, sheet title or tab name in any log
115line, error string, job status message, or metric label.** Errors carry
116coordinates and error codes; the content stays in the sheet. This is
117test-enforced (Β§12 gate 9).
118
119### 2.5 The residual risk, stated and never contradicted
120
121A compromised *live* host can read any sheet the instance has open, because
122the key is in memory. So can anyone who can read platform secrets. **We do
123not claim "we can't read your data" anywhere in the product.**
124
125### 2.6 The gate, honestly scoped
126
127The platform runs Litestream into a platform-owned bucket and the member
128contract is zero infrastructure access β€” **CI cannot read that bucket.**
129Scoping the gate to "bucket objects" would have been silently narrowed at
130implementation time, which is the drift Β§0 exists to prevent.
131
132What the gate actually covers: **the raw SQLite file and its WAL, byte for
133byte, plus every object this app writes to its own store.** The DB file is
134byte-identical to what Litestream ships, so covering it covers the
135replication path. `plaintextExempt` starts empty; any entry is dated,
136justified and listed in the README.
137
138---
139
140## 3. System shape
141
142One repo, `oficina/sheets`, one binary: `cmd/sheets`. One process, one
143SQLite file, one hostname per team, hibernating. rastrillo `server` shape
144throughout, with exactly one declared client-shape surface (the grid, Β§6).
145
146---
147
148## 4. Data model
149
150### 4.1 Tables
151
152- `sheets` β€” id, title (sealed), created_by, timestamps, deleted_at.
153- `tabs` β€” id, sheet_id, name (sealed), position, frozen_rows, frozen_cols.
154- **`rows`** β€” id, tab_id, position, height. Stable identity (Β§4.2).
155- **`cols`** β€” id, tab_id, position, width. Stable identity (Β§4.2).
156- `cells` β€” PK `(tab_id, row_id, col_id)`, sparse. Raw input (sealed),
157 cached computed value (sealed), cache_version (Β§4.4), format_id, version,
158 updated_by, updated_at.
159- `formats` β€” **interned**. A column of 5,000 identically-styled cells is
160 one row, not 5,000.
161- `events` β€” append-only. See Β§4.3.
162- `members`, `grants`, `link_grants`, `comments`, `cf_rules`,
163 `filter_views`, `charts`.
164
165Migrations are additive-only. New code over an old DB is always safe.
166
167### 4.2 Rows and columns have stable identity β€” and what that does NOT buy
168
169**A1-style coordinates are a view, not a key.** Positions live on the `rows`
170and `cols` records; `B7` is derived at render time.
171
172This is load-bearing for **three** things: comments stay attached to the
173cell they were written about when a row is inserted above them; conditional
174formatting ranges move correctly; chart ranges move correctly.
175
176**It does not eliminate formula reference rewriting, and the first draft
177claimed it did.** Relative references in A1 semantics are *positional
178offsets*, not identities β€” copying `=A1` from B1 to B2 must produce `=A2`.
179Stable ids remove the rewrite for insert and delete only. Per operation:
180
181| Operation | Reference behaviour |
182|---|---|
183| **Insert / delete row or col** | No rewrite. Ids are stable; ranges bounded by `(start_id, end_id)` widen or narrow. **This is the case stable ids buy.** |
184| **Copy / drag-fill** | **Translation pass at paste time.** Relative refs are re-resolved by offset from the paste anchor; absolute (`$A$1`) refs resolve to the same id. Unavoidable β€” it is what relative means. |
185| **Cut-paste a cell** | The cell keeps its `(row_id, col_id)` key and its *content moves*. Inbound references therefore follow the destination, matching Excel. |
186| **Move a whole row** | Position changes, id does not. References follow the row. Matches Excel. |
187| **Sort** | **Values move, ids stay with positions.** Sorting rewrites cell *contents* across existing row ids rather than reordering ids β€” so formulas referencing row 7 still mean "row 7", which is Excel's behaviour and the one users expect. |
188| **Delete a referenced row** | The id no longer resolves. The reference renders `#REF!` and the stored id is retained, so an undo restores the reference rather than a broken string. |
189
190**Ranges are stored as `(start_id, end_id)` with an explicit boundary rule:**
191a row inserted *strictly between* the bounds joins the range; one inserted
192at either edge does not. Written down because the two behaviours are
193indistinguishable until someone relies on one.
194
195### 4.3 What the event log stores
196
197Two record kinds, because conflating them is what made the leak list wrong:
198
199- **Input events** β€” a person's edit. Old and new raw input, sealed. This
200 is the version history and the undo stack.
201- **Recalc markers** β€” one row per recalc pass: the triggering event id and
202 the count of derived cells touched. **Not** the derived values.
203
204Derived values are not logged (ruling 16), because logging them makes the
205log grow with the *fan-out* of every edit rather than with the number of
206edits (Β§4.5) β€” in a SQLite file Litestream ships in full, continuously.
207**The accepted cost: restoring an old version of a large sheet is a
208recalculation, so it needs a progress state rather than being instant.**
209Restoring a version replays inputs and **recomputes**; that is slower than
210reading stored values and it is the only version that stays honest at scale.
211
212**Retention:** the log is checkpointed. Beyond the retention horizon,
213per-cell input history is compacted to periodic full snapshots of the
214sealed input layer, and the spec's version-history UI is explicit that
215history is complete within the horizon and coarse before it. "Additive-only
216migrations" is a schema rule and says nothing about a log that grows
217forever; this is that rule.
218
219### 4.4 The in-memory handle, and crash recovery
220
221Opening a sheet decrypts it into a refcounted handle holding the dependency
222graph. Edits, sort, filter, search and export read it; writes go through to
223the tables and the log. Idle handles evict.
224
225**A crash mid-recalc must not leave stale caches serving as truth.** The
226input write and a **dirty marker** land in the same transaction; the recalc
227pass clears the marker only when it completes. On open, any dirty marker
228triggers a dirty-forward recalc before the handle serves a read.
229`cache_version` on each cell records the input version its cached value was
230derived from, so a stale cache is detectable rather than assumed.
231
232Without this, a deploy, a hibernation eviction or a panic between the input
233write and its dependents' rewrite produces a sheet whose displayed totals
234disagree with its inputs, silently, forever.
235
236**Budgets, written down because "hold it in RAM" is only true up to a
237number:**
238
239- **200,000 cells per tab**, hard, with an honest error rather than a slow
240 death. Google's limit is 10M and essentially nobody reaches it.
241- **A per-process handle budget**, with eviction under pressure. A tab at
242 cap is plausibly tens of megabytes in handle form; several open sheets
243 across many instances on one small box needs a ceiling, not an assumption.
244- **A measured wake budget.** Cold open at cap is decrypt + parse +
245 graph-build + possible dirty recalc. If it exceeds the budget, the answer
246 is a progressive open (viewport first), not a longer spinner.
247
248### 4.5 Query scoping β€” `scope.Owned` is the wrong tool and dangerous here
249
250Verified against source: `scope.Owned` is literally `g.Where("user_id = ?",
251owner)`, and its sibling `scope.OwnedBy` is the same single-column equality
252with a different column name.
253
254**Our rows are team-shared, not user-owned.** Applied to `sheets` or
255`cells`, `Owned` returns only the creator's rows β€” so a sheet list looks
256**empty rather than broken**. It fails as a plausible empty state, not as an
257error, which is the worst available failure shape and also the default thing
258to reach for.
259
260**Access goes through a grants join, hand-written**, expressing "rows this
261team can see, plus rows granted to me individually, minus rows I have been
262removed from". The **404-not-403 contract is preserved by hand** and is
263test-enforced by the two-user isolation suite (Β§12 gate 2).
264
265`rastrillo/idear` is the roles/membership addon and is explicitly *not*
266core. We hand-roll `members`/`grants` rather than adopt it, because our
267guest model (Β§8.3) is per-sheet rather than per-namespace. **Recorded as a
268divergence** so it is a decision rather than an oversight; revisit if idear
269grows per-resource grants.
270
271Never bind a request body onto a GORM model: explicit `map[string]any` plus
272a `.Select` allowlist. Read the viewer with `auth.From(r)` or
273`sessions.Current(r)` β€” **never `sessions.UserID`**, which returns
274`(0, false)` for an email Subject, and dropping that `ok` scopes every query
275to uid 0.
276
277---
278
279## 5. The calc engine
280
281Lexer β†’ parser β†’ AST β†’ evaluator, plus a precedent graph with dirty-set
282propagation. Cycle detection returns a circular-reference error rather than
283hanging. Excel error values throughout, because XLSX round-trips them.
284~120 functions to start.
285
286This is the single largest piece of work in the slice β€” larger than the
287grid. Written in Go, from scratch. `excelize` (BSD-3) is confined **strictly
288to XLSX I/O**: its calc engine works against a file model rather than an
289in-memory dependency graph.
290
291**Cell values are IEEE doubles end to end.** The family's "a float never
292touches an amount" rule is about app-domain quantities, and a spreadsheet
293cell is not one β€” currency is a *number format*, and `=A1*1.05` produces a
294double that XLSX itself stores as a double. Storing cents would round the
295evaluator's own output and diverge from Excel on re-read. **The cents rule
296binds app-owned amounts only** β€” billing, quotas, anything Sheets itself
297counts. The first draft applied it to cell storage, which was incoherent.
298
299**Golden vectors are generated from LibreOffice headless** β€” expected values
300produced by a real spreadsheet, not by our reading of a spec. It is the only
301honest way to claim 120 functions.
302
303**Go formats; the client paints strings it is given.** Ruling 2 put one
304engine behind cell *values* β€” but a value is not what a cell shows. `123.456`
305under a currency format displays `€123.46`, and if the browser renders that
306from a raw number, **number formatting is a second implementation of the
307same truth**, which is the exact bug class ruling 2 exists to prevent, one
308layer down. So the wire carries the display string alongside the raw value;
309Jspreadsheet paints what it is handed. Formats are still edited as
310structured records β€” the client sends "currency, 2dp", never a rendered
311string.
312
313*(Found by analogy: docs-e6's r1 declared "one engine, one truth" and then
314chose contentEditable, handing line-breaking to the browser β€” a second
315layout authority inside a spec whose founding rule forbade one. The same
316shape was sitting in this spec's formatting layer, unnamed. 2026-08-30.)*
317
318**Recalc fan-out is priced, not assumed.** One edit to a cell feeding a
319whole-column formula dirties thousands of dependents, each needing a seal, a
320cell update and an SSE payload entry. The dirty set is capped per pass; past
321the cap the client is told to refetch the viewport rather than being sent a
322huge delta. Derived cells are **not** individually logged (Β§4.3), which is
323what keeps the log linear in edits rather than in fan-out.
324
325---
326
327## 6. The grid, and where zero-JS stops
328
329Jspreadsheet CE v5, vendored as a static asset β€” no CDN, no bundler, no
330build step.
331
332**Script-required, stated plainly:** the grid editor, drag-fill, live sync.
333
334**Fully scriptless:** sign-in, the sheet list, sharing and membership,
335settings, version history, comments, import and export, and a **read-only
336table view of any sheet with per-cell edit forms** β€” **paginated**, because
337a 200,000-cell tab rendered as one HTML table is a several-hundred-megabyte
338page.
339
340**No form in the app is script-required.** A test fetches every page
341script-stripped and diffs for every affordance.
342
343---
344
345## 7. Collaboration
346
347Cell-level last-write-wins over SSE, plus presence.
348
349**Derived cells carry the actor of the edit that caused them**, not the
350owner of the formula, and a `derived` flag so version history can show "your
351edit changed 400 cells" rather than attributing authorship of a formula's
352output to whoever typed a number.
353
354**Every SSE message carries the cell's version**, and clients discard
355out-of-order paints by version rather than by arrival. Without this,
356optimistic painting plus LWW produces visibly wrong grids under two
357concurrent editors β€” the exact bug the optimism is meant to hide.
358
359**The SSE transport check is a streamed 200**, not a 101. SSE is
360`text/event-stream` over a normal response; 101 is the WebSocket upgrade.
361The test verifies an unbuffered stream through the real edge β€” a check
362written against 101 would never pass. *(The source doc conflates these;
363the conflation stops here.)*
364
365**Idle policy, written down rather than emergent.** Connections drop after a
366defined idle period with no edits and no presence heartbeat. **Heartbeats
367stop on a hidden tab and have a ceiling**, so a tab left open all weekend
368cannot hold an instance awake and billable indefinitely. Held sockets and
369hibernation pull against each other; this is where that is resolved.
370
371**Undo is own-edits-only, cell-level.** Ctrl-Z reverts your most recent
372input event, applied as a fresh LWW write β€” so if someone has since written
373over that cell, your undo takes it back and is itself undoable. Multi-user
374undo has no correct answer; this one is predictable and explainable, which
375is the next best thing.
376
377---
378
379## 8. Identity and sharing
380
381### 8.1 One relying party β€” RULED, needs Paul's confirmation
382
383**The first draft contradicted itself and the review caught it.** Β§8 put
384`passkey.Handlers.Gate` on the instance; ruling 11 put the RP on the home.
385Verified against source: `passkey.New` sets `RPID` from `Config.Origin` β€”
386*the app's own origin*. So the obvious wiring enrolls a credential per
387instance, and a person in three teams enrolls three passkeys. That is
388precisely the "three instances must not mean three credentials" problem
389`decisions.md` says to solve on day one.
390
391**Resolution: the home's credential origin is the only RP.** Instances never
392enroll passkeys and never mount `Gate`.
393
394- **Sign-in happens at the home**, for members and guests alike: magic link
395 auto-upgrading to Keymail, with the passkey second factor β€” optional per
396 person, owner-enforceable per team (ruling 13) β€” completed there, on the
397 RP origin, before any assertion exists. rastrillo's `Gate` is used by the
398 home, on its own origin, which is exactly what it is built for. **No
399 cross-origin assertion hand-off is required**, because the ceremony
400 finishes before the instance is involved.
401- **Instances receive identity as signed assertions** β€” Tito's recorded
402 pattern: headers stripped and re-minted, and *signing*, not stripping,
403 is what stops a peer socket forging them. An instance verifies a
404 signature; it does not run WebAuthn.
405- **`Β§9.1` call 1 is therefore unambiguous:** the home mints the session,
406 the instance trusts a signed assertion. The first draft left both plausible.
407
408**Assertions carry identity, never authorization** (docs-e6, 2026-08-30 β€”
409stated positively because a reviewer will otherwise propose the opposite as
410an improvement).
411
412A guest's assertion is the same shape as an owner's. The assertion says *who
413you are*; the instance decides *what you may do* from its own `grants`
414table. Narrowing a comment-only guest's assertion is tempting and belongs on
415the wrong side of the boundary: encoding capability requires the home to
416know capability, which puts an authorization model inside the identity
417origin and cuts against Β§9's design, where the home knows you have access to
418a team in an app and nothing else.
419
420The security argument does not survive either β€” a leaked assertion buys
421exactly the grants of the person it names, which is what a stolen session
422buys anyway. Narrowing reduces no blast radius while adding a second
423assertion type, its key handling, and a second audience-binding surface.
424
425**Assertion expiry is seconds, not minutes.** It is exchanged for a session
426immediately; the long-lived thing is the session, which is `sessions`'
427problem and already solved.
428
429**Forward, and recorded so this is a staging post rather than a ceiling
430(Paul, 2026-08-30):** CARLOS is expected to grow support for per-instance
431auth. One RP at the home is the right answer for Sheets now, and it is
432explicitly *not* a bet against per-instance auth later β€” it is a bet that we
433do not need it yet.
434
435What would make us revisit: sign-in availability becoming a real complaint
436rather than a theoretical one; a team needing to authenticate against its
437own instance under its own domain for a compliance reason; or CARLOS
438shipping per-instance auth with an answer to the N-credentials problem. Any
439of those, and Β§8.1 is reopened with this paragraph as the reason it was
440always going to be.
441
442**Named consequence:** the credential origin is an authority, and a single
443point of failure for *new* sign-ins. Existing sessions keep working, and
444Β§9.1.1's hint ruling covers the directory β€” but sign-in is deliberately not
445a hint, and the README says so. Rejected alternative: per-instance passkeys,
446which keeps sign-in working when the home is down at the cost of N
447credentials per person and an RP that breaks whenever a team attaches a
448custom domain.
449
450### 8.2 Roles
451
452Team: owner, member, guest. Per sheet: owner, editor, commenter, viewer. A
453guest holds no team-wide rights β€” they see exactly the sheets granted to
454them and learn nothing about the team beyond Β§9.5's disclosure.
455
456**Team standing comes from the roster the home holds** (suite Β§5) in suite
457shape, and from `$SHEETS_OWNERS` and the local member rows in lone shape.
458**Per-sheet roles stay here.** The doctrine deliberately does not unify
459them: a shared role vocabulary designed from three specs at once would fit
460none of them, and it may move to the contract module when two apps have
461shipped the same one (suite Β§5.6). Sheets has `commenter`; calendar has no
462such role and Docs has none yet, so that moment has not arrived.
463
464This is Pegamento's *"sharing is membership"* taken literally: the sheet
465never moves, and the outside person becomes a scoped member of the instance
466that owns it. There is no federation story and there will not be one.
467
468### 8.3 Link sharing
469
470**"Sharing is membership" does not forbid link sharing.** It forbids data
471crossing a *team* boundary (suite ruling 2), and a link does not move data β€”
472whoever holds it reads the sheet here. This sentence exists because the rule
473reads like a prohibition and someone will later think it was one. (docs-e6,
4742026-08-30.)
475
476**A link is not a rung on the sharing ladder** (suite ruling 15). A sheet is
477private, granted to named people, or shared with the team, and only the team
478rung is team-visible β€” so a sheet that is link-shared but not team-shared is
479one no sibling may hold a copy of. Making a link team-visible would turn
480"whoever I sent this URL to" into "everyone on the team, findable through
481memoria's search", which is a different grant than the one the sharer made.
482
483**Ruling 12 below is now the suite's rule**, adopted verbatim as suite
484ruling 15's read-only half. The doctrine cites it; it stays here as the
485original, and the reason it gives is the reason the suite took it.
486
487Off by default, high-entropy, revocable, **viewer-or-commenter only, never
488editor** (ruling 12) β€” every version row carries an actor, and an anonymous
489editor would write unattributed rows into version history.
490
491**Link tokens are stored hashed**, per Pegamento's service contract. Stored
492plaintext, the replicated database becomes a bundle of live read
493capabilities to sealed sheets β€” Β§0 defeated through a side door for the
494second time (Β§2.3 is the first).
495
496**A view link mints nothing** β€” read-only against a capability token; no
497member, no directory entry. The moment someone wants to *write* β€” comment β€”
498they sign in, which mints a guest member and produces a directory entry
499then. **A link viewer gets no presence cursor and no name in the presence
500stack**, because presence is a write to shared awareness state (docs-e6's
501corollary, 2026-08-30).
502
503The line, and it is the same in Docs: **anything that writes is attributed,
504and attribution is what mints membership.**
505
506---
507
508## 9. The home, as a dependency
509
510The home is a **separate repo and spec** (ruling 10), drafted by this
511session with Docs as a declared consumer. This section records only what
512Sheets depends on.
513
514### 9.1 The contract, cited rather than restated
515
516**Sheets consumes the home contract as the home spec states it, and the
517suite contract as `oficina/suite` states it.** Neither is copied here.
518
519This section used to enumerate five calls. It had already caught itself
520drifting once β€” *"the first draft called this the four-call contract and
521then admitted a fifth in a subsection; an implementer reading the heading
522builds four"* β€” and the same thing happened again from the other side: the
523home now has six calls, with a seventh and eighth designed, and nothing made
524this copy notice. The note was right about the mechanism and could not fix
525it, because the fix is not to count more carefully. It is to stop keeping a
526second copy (suite Β§10).
527
528What is Sheets' own, and stays:
529
530- **The home hashes, and returns the person ref**, which we store against
531 our member rows. An earlier revision of this section said the instance
532 sends `HMAC(pepper, …)`, contradicting Β§9.6 of this same document; an
533 implementer reading it would have built instance-side hashing, which needs
534 the pepper on every instance.
535- **A directory row never grants team standing** (suite ruling 12), whatever
536 its `kind` says. Membership is the home's own record, written only by an
537 owner.
538
539Instance→home auth follows Pegamento's service contract: per-consumer
540bearer token stored hashed, consumer identity is its origin, origins are an
541allowlist.
542
543**Bootstrap, which the first draft left unstated:** a freshly provisioned
544instance receives its home bearer token as platform config and is added to
545the home's origin allowlist by the operator drain (Β§9.4), in the same step
546that creates it. An instance that has neither cannot call the home and, per
547Β§9.1.1, works fine without it.
548
549### 9.1.1 The directory is a hint, never an authority β€” RULED 2026-08-30
550
551The blueprint's position on glue is that losing it **costs a convenience,
552never content**. (Caught by docs-e6 reviewing as a consumer.)
553
554- **A failed directory upsert never fails the share.** The person is
555 granted access; the instance is the authority; the directory catches up.
556- Call 2 goes through an **outbox in the instance**, never a call in the
557 request path.
558- **Every instance re-checks authorization on every request**, whatever the
559 directory says. The directory answers "where might I have things", never
560 "may I have this".
561
562**The outbox drains on wake and on each share mutation β€” not on a ticker.**
563A hibernating instance runs no timers, so a `time.Ticker` retry loop is a
564retry loop that dies at hibernation. For a quiet team a failed upsert may
565wait weeks for an organic wake, which is acceptable precisely because the
566directory is a hint.
567
568Note this ruling does **not** extend to sign-in (Β§8.1), which is an
569authority by design and says so.
570
571### 9.2 Split origins β€” RULED 2026-08-30
572
573A team name is account-authored, so a home rendering team names on its
574passkey RP origin is what `decisions.md` warns against. The answer is
575Tito's: *credentials only ever on the root origin, account-authored content
576only on the account's own host.*
577
578- **Credential origin** β€” sign-in and the WebAuthn ceremony, nothing else.
579 No team names, no titles, no account-authored string, ever. This is the RP.
580- **Directory origin** β€” a different hostname. Renders team names, holds no
581 credentials.
582
583The directory exchanges a signed assertion from the credential origin for
584its own session. An XSS on the directory cannot reach the RP.
585
586**Rejected for now: names in a sealed blob decrypted in the browser.** It
587needs a client-held key; our key source is passkey PRF; passkeys are
588optional (ruling 13). It would force passkeys on everyone or show unnamed
589rows to the least technical users. It becomes correct when Pegamento's vault
590facet lands, and split origins make it an upgrade rather than a redesign.
591
592### 9.3 Theme is per-person suite state β€” RULED 2026-08-30 by Paul
593
594**Light/dark is a per-person preference, switchable from the spreadsheet
595editor** (ruling 14). Not per document, not per team.
596
597It therefore lives with the home as **suite-level account state**, or a
598person sets it separately in every team they belong to β€” the same shape as
599the credentials problem in Β§8.1.
600
601**It must resolve before first paint.** For a grid this is sharper than for
602plain chrome: cell fills resolve against the theme (Β§10.2), so a
603late-arriving preference does not merely flip the chrome, it **repaints
604every coloured cell in the sheet**. The preference must therefore be
605readable synchronously at render β€” a cookie on the instance's own origin,
606written from the assertion at session mint and refreshed when the person
607changes it β€” never a fetch, and never a value the first paint has to wait on.
608
609The in-editor toggle writes through to the home and updates the local cookie
610optimistically, so the switch is instant and the preference follows the
611person to their next team.
612
613### 9.4 Provisioning, and a recorded divergence from Pegamento
614
615Signup writes a provisioning request; an operator command drains it under a
616real console credential, creates instances, delivers the home bearer token
617and allowlist entry (Β§9.1), and sends the welcome mail. Team ready in
618minutes, and the copy says so rather than faking a spinner.
619
620**Recorded divergence.** Pegamento doctrine Β§9 is explicit that provisioning
621and instance lifecycle are *not* Pegamento, and flags Eleven's home β€” a
622blind vault *and* a graph-aware provisioning broker in one binary β€” as "the
623one place blindness breaks", adding "Pegamento does not repeat that
624conflation." Our home carries sign-in, directory *and* provisioning enqueue.
625
626That is a divergence and the first draft claimed doctrinal compliance it did
627not have. **The divergence is deliberate and bounded:** call 4 is an opaque
628enqueue with no directory read and no graph access; the queue lives in its
629own store; and the drain runs outside the home process entirely, under a
630human's credential. The conflation the doctrine warns about is a *blind*
631service that also knows person↔instance β€” ours knows person↔instance in the
632directory already (Β§9.5 discloses it), so the enqueue adds no knowledge the
633service lacks.
634
635**Revisit trigger:** the home spec is the right place to decide whether the
636queue moves to a separate binary. Recorded here so the home spec inherits an
637argued position rather than an accident.
638
639### 9.5 A guest grant discloses the granting team's name
640
641Sharing one sheet with an outside guest puts a row in their directory
642carrying **the granting team's name**.
643
644**Ruled: keep the behaviour, disclose it in the share UI.** The alternative
645β€” guest grants producing no directory row β€” removes the reason the
646directory exists. What is wrong is an emergent property nobody decided. The
647share dialog says, in words, who will be able to see the team's name.
648(docs-e6, 2026-08-30.)
649
650### 9.6 Re-keying, and the pepper's blast radius
651
652The directory is keyed on `HMAC(pepper, lowercased email)`.
653
654**Email change.** The person's HMAC changes and their rows orphan. Call 5 is
655a cursor-based rekey journal that instances pull, carrying `old_hmac β†’
656new_hmac`. The home learns both addresses transiently and stores neither.
657
658**Resolved by the home spec, 2026-08-30: instances hold readable addresses;
659the home never does.** The sharer typed the address, so the team already
660knows it β€” storing it in that team's instance leaks nothing the team lacks,
661whereas the home holding every address is the aggregation risk. It also
662keeps share notification possible, since nobody can email a hash.
663
664**The journal keys on person refs, not hashes.** An instance holds no hashes
665and can compute none, so a hash-keyed journal would have matched nothing it
666had. The ref arrives in call 2's response and is also the assertion's `sub`,
667so one identifier links assertion to grant to journal. Rows carry the sealed
668new address alongside it, scoped per consumer, with a per-consumer retention
669TTL rather than a slowest-cursor rule.
670
671**The pepper is a suite-wide secret with per-team blast radius, and that is
672the wrong shape.** Call 2 has the *instance* compute the HMAC, so every
673instance holds the pepper β€” and one compromised instance enables offline
674dictionary attacks against the entire suite directory. That inverts factor
675VIII, where the point of instance-per-team is that a compromise is bounded
676to one team.
677
678**Ruled: instances never hold the pepper.** Call 2 sends the address over
679the authenticated channel and **the home hashes it**, learning it
680transiently exactly as it already does at sign-in (Β§9.1). This costs the
681home a transient plaintext address it already handles, and removes a
682suite-wide secret from every instance on the estate.
683
684**Pepper rotation** then becomes the home's business alone. Under Β§9.1.1 a
685rotation is a convenience outage rather than data loss: access is
686unaffected, and the directory refills as instances re-upsert. Rotation
687requires a **re-announce sweep**, not merely outbox retry β€” retry
688redelivers *failed* upserts and does not re-enumerate every existing grant.
689That sweep is a named operator verb, not an emergent behaviour.
690
691### 9.7 The RP rename drill β€” scheduled, then made unnecessary
692
693Ruling 11 put the RP on a platform-default host provisionally, which
694**scheduled** this rename rather than avoiding it. Ruling 18 then ran it
695early, for free.
696
697**The hostnames, settled 2026-09-03.** The suite's primary domain is
698**`oficina.works`**, registered and ours. The credential origin is
699**`id.oficina.works`** β€” that is the RP β€” and a Sheets instance is
700**`<team>.sheets.oficina.works`**, one team to one hostname. Hosting stays
701on Carloku, with `oficina.works` as a custom domain in front of it.
702
703This replaces the provisional `<app>.<sqid>.<apps-domain>` on the hosted
704apps-domain `oncarlos.com` under account sqid **`bds`**
705(`console.carloku.com/a/bds/`). `bds` was a hosting artifact and does not
706survive the move. Earlier drafts naming `carloku.com` β€” the console brand β€”
707were wrong about the old domain too; none of it matters now except as the
708`LegacyRPID` value, if it is ever needed.
709
710**The drill did not have to run.** At the time of the move no `passkey.Config`
711existed in any app in the suite β€” sign-in was magic-link throughout β€” so no
712credential had ever been scoped to an `oncarlos.com` host. The window below
713is sized for the *next* move, not this one, and its cost is the argument for
714having moved before passkeys shipped rather than after.
715
716**Never widen the RP ID to a registrable suffix.** WebAuthn permits an RP ID
717that is a suffix of the origin's hostname, so `oficina.works` would be
718*accepted* for `id.oficina.works`. The objection used to be other Carloku
719tenants on a shared platform domain; on a zone we own it is that such an RP
720ID is valid at every host in the zone β€” this instance included β€” so the
721credential the home holds would be presentable straight to a Sheets
722instance, which is the separation ruling 15 exists to keep. The RP ID is the
723full host, always.
724
725**But the mechanism already exists.** `passkey.Config.LegacyRPID` β€”
726verified at source β€” is *"a relying party this instance used to be, accepted
727for assertions (never registration) so a hostname move doesn't strand
728enrolled keys."* That is the accepted-for-assertion-never-registration
729crossover this spec described as something to invent. (Found by docs-e6,
7302026-08-30, verified by this session.)
731
732So the drill is: set `LegacyRPID` to the old host, run the window, drop it.
733Not a multi-phase invention.
734
735**The window is still sized by the guest tail, not by active members.** The
736population least likely to survive is the third-party guest who signed in
737once, months ago, to comment on one sheet β€” no ongoing relationship, no
738reason to be paying attention, invisible to any plan that assumes people
739re-authenticate when nudged. The window is measured in months. **A drill
740that quietly drops the guests is a drill that failed while reporting
741success.** `LegacyRPID` removes the machinery cost; it does not shorten the
742window.
743
744**Trigger:** a real suite domain exists.
745
746---
747
748## 9a. Consumed suite rulings
749
750The suite sharing doctrine (`oficina/suite`) binds this spec. Read against
751**revision 4, commit `210312f`**.
752
753| # | What it settles for Sheets |
754|---|---|
755| 1 | The team is the boundary. A team's instances across every app are one trust domain, so a same-team memoria may hold team-visible sheets. |
756| 2 | Sharing is membership across teams. Nothing crosses a team label. |
757| 3 | Custody and authorization are two rules, and authorization is never cached at any scope. |
758| 5 | Two shapes, one switch. The sibling-token switch is independent of whether a home is present. |
759| 6 | The egress sentence, suite-wide. |
760| 7 | One contract, one module, fixtures as the interface. |
761| 8 | Two credentials. A consumer token proves a same-team sibling; an assertion proves a person. |
762| 9 | A write names its real actor β€” the rule Β§8.3's ruling 12 already served. |
763| 12 | Membership is the home's own record. A directory row never grants team standing. |
764| 14 | Custody is bounded at acquisition and retired by the feed. |
765| 15 | Three rungs and a read-only link. Sheets ruling 12 is the original this adopts. |
766
767Sheets serves **S1, S2, S3 and S6** wherever sibling consumer tokens are in
768its platform config. S6 is Sheets' alone: an enumerated aggregate over cell
769refs, capped, never expression text, so memoria can total a column without
770holding the sheet.
771
772**Not yet consumed:** S8 write-as-self (suite Β§7.2) needs a designated-area
773grant in the share UI, and the hand-off (suite Β§7.1) needs a receipt route
774with pending state. Neither is specified here.
775
776**One thing the doctrine took from Sheets rather than the other way round.**
777Ruling 12 β€” link sharing is viewer-or-commenter, never editor β€” is now suite
778ruling 15's read-only half, and the reason Sheets gave for it is the reason
779the suite took it: every version row carries an actor, and an anonymous
780editor writes unattributed rows into history. A draft of the doctrine
781proposed an anonymous link editor and had to be corrected against this
782spec.
783
784---
785
786## 10. Design system
787
788`day` theme (ruling 7). rastrillo tokens.
789
790### 10.1 Markup: classes now, attributes after the dual-grammar release
791
792**The first draft got this backwards and it would have shipped an unstyled
793app.** Verified against `origin/main` post-design-system-v2 (2026-08-30):
794`ui/tokens.css` contains **zero bare `rst-*` attribute selectors** against
795~528 `.rst-` class selector occurrences, and the rake line `::after` is
796still live. (It *does* carry attribute selectors for `[data-tone=]`,
797`[aria-*]` and `[type=]` β€” an earlier wording said "zero attribute
798selectors", which was imprecise and would not survive being checked. The
799conclusion is unchanged.) The attribute grammar is "RULED, **not yet
800started**", and its staged path is explicitly *ship a `tokens.css` matching
801both spellings β†’ codemod β†’ drop the class selectors.*
802
803So: **write `class="rst-*"` today.** When the dual-grammar release ships,
804run the codemod. Writing `rst-btn="primary compact"` now renders unstyled
805buttons against a frozen `static/tokens.css`, which is the worst of both
806spellings.
807
808This session over-applied advice from the design-system session. Their
809advice β€” don't invest in the losing spelling β€” was about *investment*, not
810about writing markup no shipped stylesheet matches.
811
812**`tokens.css` is frozen into `static/` at scaffold time while partials
813upgrade with the module.** Re-copy deliberately on every rastrillo upgrade.
814`rastrillo doctor` was proposed to make this mechanical, with this app as
815its named consumer; adopt it the day it lands and delete the manual step.
816
817**Design system v2 is a dated dependency, not an assumption.** Everything
818this spec takes from it is either *shipped* (verified in `ui` at
819`origin/main`) or *staged* (ruled in rastrillo's spec, not in released
820code). Shipped: `ui.TokensCSS()`, the class vocabulary, the three themes.
821Staged, and therefore unavailable to us today: the bare-attribute grammar,
822`--rst-header-rule`, `Pair`, `Allocate`, `rastrillo doctor`. Neither of the
823two consuming sessions could tell a ruling from a released API by reading
824the design-system spec, which is why this paragraph exists rather than a
825reference to it.
826
827**Upstream recording is a risk, not a fact.** `rastrillo doctor`, the
828`(hue, chroma, background) β†’ {fill, on-fill}` pair function and the
829allocation entry point exist in this spec and in cross-session messages, and
830in **zero files in the rastrillo repo**. Cross-session agreements that live
831only downstream are agreements the upstream can silently never ship. **Task:
832get each recorded upstream as an issue or spec section; until then treat all
833three as unavailable and keep the Β§10.3 shim.**
834
835### 10.2 The four jank cuts, made concrete
836
837- **Top bar is title, Share, avatar.** No upgrade button, no app waffle, no
838 AI sparkle, no right-hand rail.
839- **Toolbar is contextual and grouped** β€” around ten controls that change
840 with selection. The long tail lives in a command palette.
841- **Menus go shallow:** File, Edit, Insert, Format, Data, Help.
842- **Home is your sheets by recency and a New button.** No template carousel.
843
844The app shell is **app-local**. Shared primitives are not.
845
846**Destination: `oficina/estilo`** β€” a CARLOS suite design-system variant,
847**not** rastrillo's `ui` (Paul, 2026-08-30; repo created same day). A
848variant depends on `ui`, keeps its bones β€” tokens, the partial vocabulary,
849the accessibility floors β€” and adds components, themes and its own gallery.
850Not a fork, and more than a theme, since a theme cannot add a component.
851`cmd/dsgen` went public 2026-08-30 and generates a gallery for any module's
852own vocabulary; **read it before anyone writes a second one.**
853
854**The distinction that produced this, worth keeping** (ui-guidelines-52,
8552026-08-30): *two callers at design time is the bar for **extraction**, and
856it says nothing about **destination**.* Both consuming sessions were told
857"propose it to the library" and applied the right bar to the wrong question,
858because the first answer looked like it settled the second. It will recur
859for every piece either app hands upward.
860
861**Primitives split with Docs:** we own **toolbar groups**; Docs owns
862inspector panel, presence stack and **comment thread** (their anchoring case
863is harder, so it owns the design and we consume it β€” a cross-repo dependency
864inside slice one, noted in Β§13's risk). Same standards either way: twelve
865locales, the script-required line stated per primitive, 24Γ—24 targets, 320px
866reflow, colour never carrying meaning alone.
867
868**The colour engine is unaffected and stays in `ui`.** `Pair` and allocation
869are contrast arithmetic against floors the framework already gates β€” a
870function, not a component β€” so our cell fills and Docs' highlights are
871callers of the same thing regardless of where their components live.
872
873### 10.3 Cell fill colour
874
875Stored as **OKLCH intent** (hue + chroma), resolved to a `{fill, on-fill}`
876**pair** β€” the pair must clear 4.5:1, because a user picks a fill and then
877types in it. Imported hex is retained alongside for lossless XLSX
878round-trips, dropped when a user re-picks (ruling 8).
879
880**Signature takes a literal background, not a scheme:**
881`(hue, chroma, background) β†’ {fill, on-fill}`. A `scheme` parameter could
882only mean "resolve against the theme's surface token", which is wrong
883whenever the cell sits on something else β€” conditional formatting paints
884under a user fill, selection tints, frozen headers. Passing the real
885background is what makes our contrast gate assert the pair the cell actually
886shows, rather than certifying the wrong thing while passing.
887
888**Allocation is a second entry point**, a pure function of a **canonically
889ordered** key set (member id), never of arrival order β€” otherwise two
890clients viewing the same sheet allocate differently and the same person is
891green for one viewer and blue for another. The `avoid` set is a parameter,
892so collisions occur only past capacity. Sheets is a caller for **presence
893cursors** (Β§7). Stability of a person's colour is best-effort; separation is
894the hard guarantee.
895
896**Until `ui` ships these: a deliberately crippled shim.** A hardcoded table
897of six to eight intents with hand-computed pairs, behind the corrected
898signature, gated by a contrast test that **copies rastrillo's arithmetic
899verbatim** β€” not merely models it, or the gates diverge by a rounding
900decision and swapping the table changes the test's meaning silently. Header
901comment names it a placeholder and links Β§10.1's upstream-recording task as
902its deletion trigger.
903
904**Colour never carries meaning alone.** A fill is decoration; whatever it
905*means* must survive greyscale.
906
907### 10.4 Accessibility
908
909**The grid claims 1.4.10's two-dimensional-content exemption.** A data table
910is the WG's canonical example and a spreadsheet is that case at its most
911literal. So the grid scrolls in both axes at 320px, and **every other
912surface must reflow or it is a bug, not an exemption** β€” falsifiable, which
913is why it holds.
914
915Target size 24Γ—24 throughout, including a 24px hit area around the fill
916handle's small visual. **Every drag-only affordance gets a menu
917equivalent**, which buys the scriptless path at the same time. We do not
918lean on 2.5.8's spacing exception.
919
920---
921
922## 11. XLSX and CSV
923
924`excelize` both directions. Import maps incoming hex fills to the nearest
925offered intent while retaining the original hex (Β§10.3); export resolves
926intent to concrete hex.
927
928A **golden round-trip corpus** is a gate: import β†’ export β†’ import must be
929stable, and our output must open cleanly in Excel and LibreOffice.
930
931Import runs as a rastrillo background job, 303 to `/jobs/{id}`, with
932`StatusPage`/`Fragment`/`Events` mounted behind `sessions.Require`. The
933status page works scriptless. Jobs are idempotent β€” a deploy ends them
934mid-flight β€” and `location` is a server-built path, never user input. **Job
935error text carries coordinates and codes, never content (Β§2.4).**
936
937Uploaded and generated files go to `blobs.Sealed` with
938`ContentType: application/octet-stream` (Β§2.3).
939
940**Import's HTTP surface built 2026-09-02.** The converter and its
941round-trip corpus predated this by two days; what did not exist was any
942way to reach them β€” no route, no upload, no job, and a `PRODUCT.md` that
943listed import among the things "built and working". `POST /sheets/import`
944now takes a workbook, makes the sheet, and runs `xlsx.Import` as a
945`jobs` job that 303s to `/jobs/{id}` and lands on the finished sheet.
946Three things the design had not had to decide, decided here:
947
948- **The sheet is created before the job, and removed if the job fails.**
949 `jobs.Start` needs the finished location up front and that location
950 must be server-built, so the sheet id has to exist first. The cost is
951 a sheet that briefly exists without contents; a failed import
952 soft-deletes it, because a half-imported sheet looks complete, sits in
953 a list the whole team can see, and its only contradiction is a status
954 page nobody else opened.
955- **The upload is not retained at all.** The rule above governs keeping
956 one; not keeping it satisfies the same concern more simply, and no
957 plaintext workbook reaches storage in any form. `xlsx.StoreSealed` is
958 still there for the task that wants the original back.
959- **Job error text is replaced, never wrapped.** rastrillo's runner
960 slogs the error a job returns, and `carlos logs` is platform-held. So
961 `importFailure` builds fresh text from a coordinate and a code, and
962 `xlsx.CellError` carries that coordinate as data β€” its `Error()` omits
963 its own cause by construction, so printing one cannot leak a cell even
964 by accident.
965
966**Drag and drop is the form.** The list's drop zone fills the same file
967input and submits the same form, so there is one route and one code
968path, and Β§6's scriptless floor holds without a second implementation to
969keep in step with the first.
970
971### 11.1 A retained fill can be unreadable, and that is correct
972
973Retaining an imported fill's original hex (Β§10.3) means we also carry the
974author's font colour, and the pair may fail WCAG AA. This is not
975hypothetical: Excel's own light-yellow conditional-formatting preset ships
976`#9C6500` on `#FFEB9C` β€” **4.12:1, below the 4.5:1 floor** β€” confirmed
977against an independent source, so it is what the incumbent *offers by
978default*, not merely what a careless user can construct.
979
980**We render it as authored.** Faithfully displaying a document somebody else
981wrote is a different act from generating a colour, and silently correcting
982imported formatting would lie about the file β€” the same class of wrong as
983inventing a font colour on export (Β§10.3).
984
985**So the guarantee is narrower than it reads.** Our offered set cannot
986*produce* an unreadable pairing; a cell in this app can nonetheless *be*
987one, when the file said so. Anyone reading the first claim as the second
988will eventually find a failing cell and file a bug against a guarantee that
989is working exactly as specified.
990
991**The obligation that follows is surfacing, not overriding** β€” telling a
992person their imported file contains a pairing that fails, and leaving the
993decision to them. That was grid work and belonged to plan 2 (Β§14).
994
995**Built 2026-09-01.** It needed one thing this spec had assumed: the
996authored *ink*. `readStyle` retained fills and never font colours, so the
997pair Β§11.1 is about could not exist in our data at all β€” a fill alone
998cannot fail, since the other half was always our own theme's. `Format`
999gained `InkHex`, XLSX import and export carry it, and the grid and table
1000views name the failing cells in a notice. No cell is altered.
1001
1002*(Found 2026-08-31 by ui-guidelines-52 while building rastrillo's colour
1003engine, from Excel's documented preset fill/ink pairs. Related: `#0000FF`
1004carries black ink at 2.44:1 and cannot be a wash at all β€” the case where a
1005user's chosen hue and their ink genuinely conflict.)*
1006
1007---
1008
1009## 12. Testing gates
1010
1011From the first commit:
1012
10131. **Plaintext grep** over the raw SQLite file and WAL, plus every object
1014 this app writes to its own store (Β§2.6).
10152. **Two-user isolation suite**, including the grants-join 404-not-403
1016 contract (Β§4.5).
10173. **Golden XLSX round-trip corpus.**
10184. **Formula golden vectors** from LibreOffice headless.
10195. **Contrast test** copying `ui/contrast_test.go`'s arithmetic verbatim.
10206. **Scriptless-affordance diff.**
10217. **Browser drives in CI.** A JS syntax error takes the whole client down
1022 and only a real engine sees it.
10238. **Module-size cap** on JS, ratchet-down only. The vendored Jspreadsheet
1024 blob is exempt and named; nothing we write is.
10259. **No-content-in-logs** β€” scan log lines, error strings and job messages
1026 for cell content (Β§2.4).
102710. **Cache coherence** β€” assert no cell serves a cached value whose
1028 `cache_version` predates its input's version (Β§4.4).
102911. **Computed value to painted output.** A browser drive reads the *text
1030 actually painted* in cells and compares it against the golden corpus's
1031 expected display strings β€” crossing from the engine to the screen.
1032
1033**Why gate 11 exists, and why gates 4 and 7 do not cover it** (docs-e6,
10342026-08-30). Gate 4 proves the Go engine matches LibreOffice. Gate 7 proves
1035the client runs. Neither proves that what a person *sees* is what the engine
1036computed β€” and a gate that compares two of our own layers against each other
1037can stay green forever while the product is wrong. Docs shipped exactly that
1038bug: a conformance test comparing two runtimes of the same module, proving
1039determinism, which WASM guarantees anyway, and never proving the thing the
1040rule was about.
1041
1042The rule to keep: **at least one gate must cross from computed value to
1043painted output.** A suite of gates that all live inside one layer is a suite
1044that agrees with itself.
1045
1046Review on a deployed canary, never localhost. Verify a deploy with
1047`X-Carlos-Version` on the canonical host after the watch β€” never a 200.
1048
1049### 12.1 Review β€” RULED 2026-08-30 by Paul
1050
1051**Every spec and every branch is reviewed by the highest-capability model
1052available before it is approved or merged.** Cheaper models draft; the most
1053capable available reviews. A review by a weaker model than the author is not
1054a review.
1055
1056Specs get a pass before human approval and again after any material change
1057to a settled decision. Branches get one before the PR opens and again after
1058any force-push bigger than a rebase. **The reviewing model is recorded** β€”
1059in this spec's status line, and in the branch discussion for a branch, which
1060survives a squash where a PR body does not. A review nobody can attribute is
1061a review nobody can check.
1062
1063The highest-capability model is **discovered, not hardcoded**. A session
1064that cannot reach a model at least as capable as the author's says so and
1065waits.
1066
1067---
1068
1069## 13. Sequencing
1070
10711. Skeleton, `AGENTS.md`, auth
10722. Storage and sealing (gates 1, 9, 10 live from here)
10733. Formula engine
10744. Grid
10755. XLSX / CSV
10766. Sharing, and the home contract
10777. Live sync
10788. Version history
10799. Sort, filter, conditional formatting
108010. Comments
108111. **Charts**
1082
1083**Scope honesty, from the review β€” narrowing confirmed by Paul,
10842026-08-30 (ruling 17).** Ruling 5 stands; what it costs: **three of the eleven items are subsystems, not features** β€” charts,
1085version history (restore semantics, replay, retention), and conditional
1086formatting (a second expression evaluator plus a rules UI). Naming charts as
1087the sole slip candidate understated the risk.
1088
1089So the slip order is written down rather than discovered: **charts first,
1090conditional formatting second.**
1091
1092**The criterion is "its absence leaves a coherent product", not "it has no
1093dependents"** (docs-e6, 2026-08-30). The two usually agree, and where they
1094disagree the first is the one that matters. Charts passes on the stronger
1095reading: a spreadsheet without charts is still a spreadsheet. Conditional
1096formatting is second because a spreadsheet without it is also still a
1097spreadsheet, merely a duller one β€” whereas a spreadsheet without version
1098history is one nobody trusts with real data, which is why history is not on
1099this list at all. Version history is not a slip candidate
1100because Β§4.3's log design is load-bearing for live sync β€” but **its minimal
1101form is named now**: view history and restore a *cell* or a *range*.
1102Restore-a-whole-sheet is the extension, not the baseline.
1103
1104**Progress is measured by gates green, not by items ticked.**
1105
1106**One cross-repo dependency inside the slice:** comments consume Docs'
1107comment-thread primitive (Β§10.2). If Docs slips, we ship comments with a
1108cell-anchored local implementation and adopt theirs later β€” decided now so
1109it is a fallback rather than a scramble.
1110
1111---
1112
1113## 14. Deliberately not built yet
1114
1115| Thing | Trigger |
1116|---|---|
1117| Pegamento's real vault facet (sealed team names, Β§9.2) | The vault facet ships with a recovery story |
1118| Moving the provisioning queue out of the home (Β§9.4) | The home spec rules on it |
1119| **Destructive sort β€” physically reordering rows** | calc's dependency graph is keyed on POSITION (`store/handle.go`'s `cellKey` builds `CellKey{rowPos+1, colPos+1}`), so moving a row changes what every existing formula points at, silently. Plan 1's review flagged this as "valid only until rows move". Trigger: **calc's key space rebuilt on stable ids**, which is a subsystem and not a feature. Sort and filter ship non-destructively meanwhile β€” they reorder what is displayed and never what is stored, which is correct rather than a stopgap. |
1120| **Importing a workbook's second and later worksheets** | `xlsx.Import` reads `GetSheetList()[0]` and stops, so a three-worksheet file becomes a one-tab sheet. Stated in the import form's own hint rather than discovered afterwards (Β§11), because a workbook that arrives looking whole is the quiet kind of wrong PRODUCT.md's first principle forbids. Trigger: **a second tab is reachable in the UI**. Sheets already has a `Tab` table and `SheetSummary.TabID` already means "the first tab", but nothing creates, names, lists or switches tabs β€” so importing into tabs today would write rows no screen can open, which is worse than importing one worksheet honestly. |
1121| Pivot tables | Asked for twice |
1122| Rich presence beyond who's-here | Teams above ~5 concurrent editors |
1123| Offline editing | β€” |
1124| Mobile-native apps | β€” |
1125| `client` shape for anything but the grid | A surface genuinely needs client-held keys |
1126| Print / PDF export | Asked for. docs-e6 keeps its layout engine's page model generic over content boxes so this stays open: paginated grid, repeating header rows, scale-to-width, explicit page breaks, charts as vector |
1127| Live sheet range embedded in a doc | A known future crossing of two collaboration models |
1128| ~~Formatting from the scriptless table view~~ | **Closed 2026-09-04.** PRODUCT.md's second principle says the scriptless path is the product, not the fallback, and this row was the debt while it went unmet. Bold, italic, alignment, number format, fill and text colour reached the table view on 2026-09-02; **column width was the last one left** and had no form behind it at all β€” `handleSizePost` decoded JSON only. It now takes a form the same way `handleFormatPost` does: `sizeFromForm` converges on the same `sizeWrite`, and the exactly-one-of-col-and-row rule, the bounds and the store call still run once, so the two decoders cannot drift into two rule sets. The control is one column picker beside the sort and filter form rather than an input in each of 26 headers. Undo for a format or a resize is still absent and still has its own row below β€” that is a different gap with a different trigger. |
1129| **Undo for formatting and for a resize** | Undo replays input events (Β§7), and neither a format write nor a resize appends one β€” so Ctrl-Z after bolding undoes the last VALUE edit instead, which is surprising in the way this app tries not to be. The UI therefore does not offer undo for either. Trigger: **a format event kind**, which changes the undo model and is not a thing to do in passing. |
1130| ~~Surfacing an imported fill/ink pairing that fails WCAG AA~~ | **Trigger fired and built, 2026-09-01.** Plan 2's grid work landed, and with it `Format.InkHex` (the authored font colour, which nothing retained before), a WCAG ratio, and a notice naming the failing cells on both the grid and the table view. Surfaced, never overridden β€” Β§11.1's ruling stands. |
1131
1132---
1133
1134## 15. Open
1135
1136- **Assertion verification is the remaining shared gap, and it is smaller
1137 than the one it replaced.** Ruling 15 dissolves the cross-origin WebAuthn
1138 problem β€” the ceremony finishes at the home before any assertion exists,
1139 so nothing needs handing across origins. What is left is that an instance
1140 must verify a signed identity assertion, and rastrillo has no helper for
1141 it. Docs needs the identical thing. To be settled jointly and filed once.
1142- **Upstream recording** of `rastrillo doctor`, the pair function and
1143 allocation (Β§10.1). Until done, all three are unavailable.
1144- **The platform provisioning credential** β€” filed as `carlos/platform` #4.
1145- **Repo move verbs** β€” filed as `amadan/amadan` #28.
1146- **A real suite domain** β€” triggers Β§9.7.
1147- **The home spec itself** β€” drafted next, in its own repo.
1148- **Language neutrality upstream.** A discussion proposing CARLOS picks the
1149 language per app rather than assuming Go. Nothing here depends on the
1150 outcome; Go stays right for this app either way.
1151
1152---
1153
1154## Appendix A β€” assertion verification, filing draft (awaiting Paul's nod)
1155
1156**Destination: `github.com/rastrilloorg/rastrillo`.** Rastrillo has no
1157amadan remote, so this is a GitHub issue rather than an `amadan discuss`,
1158and it needs Paul's go-ahead. Drafted jointly with docs-e6; both consumer
1159paragraphs are below.
1160
1161**Title: An instance needs to verify a signed identity assertion**
1162
1163Instance-per-account makes single sign-in a day-one problem β€” `decisions.md`
1164says it directly: *"three instances must not mean three credentials."* Two
1165apps have now independently arrived at the same answer, which is why this is
1166a framework ask rather than an app one.
1167
1168The shape both reached: **one identity origin holds the credentials and runs
1169the ceremony; instances receive identity as a signed assertion and verify a
1170signature.** Instances never run WebAuthn, never hold a passkey RP, never
1171see a password.
1172
1173This is Tito's recorded pattern: *identity lives beside the router, outside
1174the per-account blast radius, and reaches instances only as **signed**
1175assertions β€” headers are stripped-and-re-minted, and signing (not stripping
1176alone) is what stops a peer socket from forging them.*
1177
1178**What rastrillo has:** `sessions`, `auth`, `password`, `passkey` β€” every
1179one authenticating *at* the app. **What is missing:** anything letting an
1180app say "I trust assertions minted by this identity origin; verify this one
1181and mint a session from it."
1182
1183**Why the framework rather than two apps.** The parenthetical in Tito's rule
1184is the point β€” *signing, not stripping alone* β€” and the failure modes do not
1185announce themselves:
1186
1187- **Audience binding.** An assertion must name the instance it is for, or
1188 one minted for team A replays against team B. Both apps are multi-tenant
1189 by construction, so every deployment has thousands of sibling instances to
1190 replay against. This is the one most likely to differ subtly between two
1191 independent implementations.
1192- **Replay.** Single-use identifier plus short expiry β€” seconds, not
1193 minutes; the long-lived thing is the session minted afterwards.
1194- **Key distribution and rotation**, without a flag day.
1195- **Algorithm confusion.**
1196
1197That is squarely "supplies what is hard to get right twice", which is how
1198rastrillo describes its own scope β€” and it is literally twice: two apps,
1199same week, same pattern, neither having seen the other's code.
1200
1201**Rough shape**, not a design: verify against pinned or fetched public keys,
1202check audience against this instance's own identity, enforce single-use and
1203expiry, return a Subject plus claims, hand off to `sessions.SignIn` β€” which
1204is the whole contract the existing identity plugins have with the core. A
1205fourth plugin, not a new seam.
1206
1207**One thing to state positively, because a reviewer will otherwise propose
1208it as an improvement: assertions carry identity, never authorization.** A
1209guest's assertion is the same shape as an owner's; the instance decides
1210capability from its own grants. Encoding capability would require the
1211identity origin to hold an authorization model, and a leaked assertion buys
1212exactly what a stolen session buys anyway β€” so narrowing reduces no blast
1213radius while adding a second assertion type and a second audience-binding
1214surface.
1215
1216**Named consumers:**
1217
1218- **Sheets (`oficina/sheets`)** β€” the home's credential origin is the sole
1219 RP, confirmed by Paul 2026-08-30; instances verify assertions and never
1220 enroll passkeys.
1221- **Docs (`oficina/docs`)** β€” same shape, same week, independently reached.
1222 Docs adds one operational requirement Sheets does not surface: its guest
1223 population is dominated by single-purpose visitors who signed in once to
1224 comment and never returned, so the single-use replay store churns against
1225 a population far larger than the membership list and unbounded in time.
1226 **The store must be self-pruning, sized by assertion lifetime rather than
1227 by user count** β€” a naive implementation is correct on day one and a
1228 growing table for the life of the deployment. That belongs in the helper,
1229 because "single-use" is the helper's promise to keep.
1230