oficina / sheets Public
π€ Sheets β design
Status: design, revised 2026-08-30 after review. Approved in brainstorming by Paul on 2026-08-30; revised the same day against a review dispatched to Fable (13 confirmed findings), with three source claims independently re-verified by this session and three more by the Docs session. Every ruling is dated and attributed, with the rejected alternative beside it. Reviewing model, per Β§12.1: dispatched to Fable. (The agent's own header self-reported as Opus 5; models are unreliable about their own identity, so the honest record is what was dispatched.) Scope: the Sheets instance app. The Suite home is a separate spec in a separate repo (Β§9); this document depends on it and does not define it.
---
0. The one rule
> A sheet's contents are sealed at rest. The bucket never holds a > plaintext cell.
The instance holds the key while it runs, so the server can read a sheet it has open. That is a deliberate softening, not an accident, and Β§2 names every leak it produces. What the rule forbids absolutely is a plaintext value reaching the replicated SQLite file or any object storage we write.
AGENTS.md opens with this rule verbatim, and is written before any code.
---
1. Settled decisions (don't relitigate casually)
| # | Ruling | Rejected | Who, when | |---|---|---|---| | 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 | | 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 | | 3 | Cell-level last-write-wins, live over SSE | Save-and-reload; single-writer locking; CRDT/OT | Paul, 2026-08-30 | | 4 | Rows + append-only log + in-memory sheet handle | Sealed blob per tab; rastrillo's mergeable store | Paul, 2026-08-30 | | 5 | Slice one includes version history, sort/filter/conditional formatting, comments AND charts | A smaller slice | Paul, 2026-08-30 | | 6 | All four "jank" cuts | Keeping any of them | Paul, 2026-08-30 | | 7 | day theme | signal; plain | Paul, 2026-08-30 | | 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 | | 9 | Provisioning: the home queues, an operator drains | A standing console token in a web-facing app | Paul, 2026-08-30 | | 10 | The home is its own repo and its own spec | Keeping it here; Docs drafting it | Paul, 2026-08-30 | | 11 | ~~Passkey RP is an oncarlos.com host, provisionally~~ β superseded by 18 | A real suite domain now; no passkeys | Paul, 2026-08-30 | | 12 | Link sharing is viewer-or-commenter, never editor | Anonymous link editing | Paul, 2026-08-30 | | 13 | Passkey 2FA optional per person, owner-enforceable per team | Mandatory; none | Paul, 2026-08-30 | | 14 | Light/dark is a per-person preference, switchable in the editor | Per-document; per-team; follow-OS-only | Paul, 2026-08-30 | | 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 | | 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 | | 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 | | 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 |
---
2. Trust model, and the leak list
The DEK arrives as platform config (carlos secrets), never stored in the database it decrypts. HKDF derives purpose keys; values are sealed with AES-GCM. Golden vectors pin the envelope.
2.1 Sealed
- Every cell's raw input and every cached computed value.
- Sheet titles, tab names, comment text.
- Event log payloads. Version history and undo are unimplementable unless the log carries old and new cell values, and those values are user content. This was missing from the first draft and the grep gate would have caught it in week two β see Β§4.3 for what the log actually stores.
- Conditional-formatting rule constants.
> 1000,contains "overdue"β user data living inside a format-adjacent record. - Persisted sort and filter criteria. A filter value is a cell value.
- Chart titles, axis labels and series names.
- Every blob, via
blobs.Sealedβ see Β§2.3.
2.2 Plaintext, and published in the README rather than discovered
- Cell coordinates and stable ids β therefore the shape and size of every sheet.
- Format records: fills (as OKLCH intent), borders, alignment, number format codes. Not the rule constants that select them (Β§2.1).
- Timestamps, actor ids, and the shape of the edit log.
- Membership, grants, and roles β including readable email addresses where a grant carries one (see Β§9.6).
Publish our database and you learn who works with whom, how big their spreadsheets are, how often they edit and what colour they painted things. You learn no value in any cell.
2.3 Blobs, and the metadata that escapes sealing
Imported and exported XLSX files are whole spreadsheets. If they reach rastrillo/blobs on the S3 backend unsealed, that is a plaintext spreadsheet in a bucket β Β§0 defeated through a side door.
blobs.Sealed is mandatory for every blob this app writes. It exists for exactly this (blobs/blobs.go: E2EE apps wrap any backend in Sealed).
Three properties of it that shape our usage, all verified against source:
ContentTypepasses through as visible metadata. We writeapplication/octet-stream, always. Sealing a spreadsheet's bytes and then labelling it as a spreadsheet would be a strange place to end up.- The blob address is the ciphertext hash, so deduplication is per-key.
Ref.Sizeis the sealed size. Any quota or progress figure shown to a person must account for that or it will be visibly wrong.
2.4 Logs and error text are storage
carlos logs is platform-held. An import error reading "can't parse 'salary 50000' at B7" ships cell content off the instance to storage we do not control and cannot grep.
Rule: no cell content, comment text, sheet title or tab name in any log line, error string, job status message, or metric label. Errors carry coordinates and error codes; the content stays in the sheet. This is test-enforced (Β§12 gate 9).
2.5 The residual risk, stated and never contradicted
A compromised live host can read any sheet the instance has open, because the key is in memory. So can anyone who can read platform secrets. We do not claim "we can't read your data" anywhere in the product.
2.6 The gate, honestly scoped
The platform runs Litestream into a platform-owned bucket and the member contract is zero infrastructure access β CI cannot read that bucket. Scoping the gate to "bucket objects" would have been silently narrowed at implementation time, which is the drift Β§0 exists to prevent.
What the gate actually covers: the raw SQLite file and its WAL, byte for byte, plus every object this app writes to its own store. The DB file is byte-identical to what Litestream ships, so covering it covers the replication path. plaintextExempt starts empty; any entry is dated, justified and listed in the README.
---
3. System shape
One repo, oficina/sheets, one binary: cmd/sheets. One process, one SQLite file, one hostname per team, hibernating. rastrillo server shape throughout, with exactly one declared client-shape surface (the grid, Β§6).
---
4. Data model
4.1 Tables
sheetsβ id, title (sealed), createdby, timestamps, deletedat.tabsβ id, sheetid, name (sealed), position, frozenrows, frozen_cols.rowsβ id, tab_id, position, height. Stable identity (Β§4.2).colsβ id, tab_id, position, width. Stable identity (Β§4.2).cellsβ PK(tab_id, row_id, col_id), sparse. Raw input (sealed), cached computed value (sealed), cacheversion (Β§4.4), formatid, version, updatedby, updatedat.formatsβ interned. A column of 5,000 identically-styled cells is one row, not 5,000.eventsβ append-only. See Β§4.3.members,grants,link_grants,comments,cf_rules,filter_views,charts.
Migrations are additive-only. New code over an old DB is always safe.
4.2 Rows and columns have stable identity β and what that does NOT buy
A1-style coordinates are a view, not a key. Positions live on the rows and cols records; B7 is derived at render time.
This is load-bearing for three things: comments stay attached to the cell they were written about when a row is inserted above them; conditional formatting ranges move correctly; chart ranges move correctly.
It does not eliminate formula reference rewriting, and the first draft claimed it did. Relative references in A1 semantics are positional offsets, not identities β copying =A1 from B1 to B2 must produce =A2. Stable ids remove the rewrite for insert and delete only. Per operation:
| Operation | Reference behaviour | |---|---| | 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. | | 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. | | 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. | | Move a whole row | Position changes, id does not. References follow the row. Matches Excel. | | 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. | | 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. |
Ranges are stored as (start_id, end_id) with an explicit boundary rule: a row inserted strictly between the bounds joins the range; one inserted at either edge does not. Written down because the two behaviours are indistinguishable until someone relies on one.
4.3 What the event log stores
Two record kinds, because conflating them is what made the leak list wrong:
- Input events β a person's edit. Old and new raw input, sealed. This is the version history and the undo stack.
- Recalc markers β one row per recalc pass: the triggering event id and the count of derived cells touched. Not the derived values.
Derived values are not logged (ruling 16), because logging them makes the log grow with the fan-out of every edit rather than with the number of edits (Β§4.5) β in a SQLite file Litestream ships in full, continuously. The accepted cost: restoring an old version of a large sheet is a recalculation, so it needs a progress state rather than being instant. Restoring a version replays inputs and recomputes; that is slower than reading stored values and it is the only version that stays honest at scale.
Retention: the log is checkpointed. Beyond the retention horizon, per-cell input history is compacted to periodic full snapshots of the sealed input layer, and the spec's version-history UI is explicit that history is complete within the horizon and coarse before it. "Additive-only migrations" is a schema rule and says nothing about a log that grows forever; this is that rule.
4.4 The in-memory handle, and crash recovery
Opening a sheet decrypts it into a refcounted handle holding the dependency graph. Edits, sort, filter, search and export read it; writes go through to the tables and the log. Idle handles evict.
A crash mid-recalc must not leave stale caches serving as truth. The input write and a dirty marker land in the same transaction; the recalc pass clears the marker only when it completes. On open, any dirty marker triggers a dirty-forward recalc before the handle serves a read. cache_version on each cell records the input version its cached value was derived from, so a stale cache is detectable rather than assumed.
Without this, a deploy, a hibernation eviction or a panic between the input write and its dependents' rewrite produces a sheet whose displayed totals disagree with its inputs, silently, forever.
Budgets, written down because "hold it in RAM" is only true up to a number:
- 200,000 cells per tab, hard, with an honest error rather than a slow death. Google's limit is 10M and essentially nobody reaches it.
- A per-process handle budget, with eviction under pressure. A tab at cap is plausibly tens of megabytes in handle form; several open sheets across many instances on one small box needs a ceiling, not an assumption.
- A measured wake budget. Cold open at cap is decrypt + parse + graph-build + possible dirty recalc. If it exceeds the budget, the answer is a progressive open (viewport first), not a longer spinner.
4.5 Query scoping β scope.Owned is the wrong tool and dangerous here
Verified against source: scope.Owned is literally g.Where("user_id = ?", owner), and its sibling scope.OwnedBy is the same single-column equality with a different column name.
Our rows are team-shared, not user-owned. Applied to sheets or cells, Owned returns only the creator's rows β so a sheet list looks empty rather than broken. It fails as a plausible empty state, not as an error, which is the worst available failure shape and also the default thing to reach for.
Access goes through a grants join, hand-written, expressing "rows this team can see, plus rows granted to me individually, minus rows I have been removed from". The 404-not-403 contract is preserved by hand and is test-enforced by the two-user isolation suite (Β§12 gate 2).
rastrillo/idear is the roles/membership addon and is explicitly not core. We hand-roll members/grants rather than adopt it, because our guest model (Β§8.3) is per-sheet rather than per-namespace. Recorded as a divergence so it is a decision rather than an oversight; revisit if idear grows per-resource grants.
Never bind a request body onto a GORM model: explicit map[string]any plus a .Select allowlist. Read the viewer with auth.From(r) or sessions.Current(r) β never sessions.UserID, which returns (0, false) for an email Subject, and dropping that ok scopes every query to uid 0.
---
5. The calc engine
Lexer β parser β AST β evaluator, plus a precedent graph with dirty-set propagation. Cycle detection returns a circular-reference error rather than hanging. Excel error values throughout, because XLSX round-trips them. ~120 functions to start.
This is the single largest piece of work in the slice β larger than the grid. Written in Go, from scratch. excelize (BSD-3) is confined strictly to XLSX I/O: its calc engine works against a file model rather than an in-memory dependency graph.
Cell values are IEEE doubles end to end. The family's "a float never touches an amount" rule is about app-domain quantities, and a spreadsheet cell is not one β currency is a number format, and =A1*1.05 produces a double that XLSX itself stores as a double. Storing cents would round the evaluator's own output and diverge from Excel on re-read. The cents rule binds app-owned amounts only β billing, quotas, anything Sheets itself counts. The first draft applied it to cell storage, which was incoherent.
Golden vectors are generated from LibreOffice headless β expected values produced by a real spreadsheet, not by our reading of a spec. It is the only honest way to claim 120 functions.
Go formats; the client paints strings it is given. Ruling 2 put one engine behind cell values β but a value is not what a cell shows. 123.456 under a currency format displays β¬123.46, and if the browser renders that from a raw number, number formatting is a second implementation of the same truth, which is the exact bug class ruling 2 exists to prevent, one layer down. So the wire carries the display string alongside the raw value; Jspreadsheet paints what it is handed. Formats are still edited as structured records β the client sends "currency, 2dp", never a rendered string.
(Found by analogy: docs-e6's r1 declared "one engine, one truth" and then chose contentEditable, handing line-breaking to the browser β a second layout authority inside a spec whose founding rule forbade one. The same shape was sitting in this spec's formatting layer, unnamed. 2026-08-30.)
Recalc fan-out is priced, not assumed. One edit to a cell feeding a whole-column formula dirties thousands of dependents, each needing a seal, a cell update and an SSE payload entry. The dirty set is capped per pass; past the cap the client is told to refetch the viewport rather than being sent a huge delta. Derived cells are not individually logged (Β§4.3), which is what keeps the log linear in edits rather than in fan-out.
---
6. The grid, and where zero-JS stops
Jspreadsheet CE v5, vendored as a static asset β no CDN, no bundler, no build step.
Script-required, stated plainly: the grid editor, drag-fill, live sync.
Fully scriptless: sign-in, the sheet list, sharing and membership, settings, version history, comments, import and export, and a read-only table view of any sheet with per-cell edit forms β paginated, because a 200,000-cell tab rendered as one HTML table is a several-hundred-megabyte page.
No form in the app is script-required. A test fetches every page script-stripped and diffs for every affordance.
---
7. Collaboration
Cell-level last-write-wins over SSE, plus presence.
Derived cells carry the actor of the edit that caused them, not the owner of the formula, and a derived flag so version history can show "your edit changed 400 cells" rather than attributing authorship of a formula's output to whoever typed a number.
Every SSE message carries the cell's version, and clients discard out-of-order paints by version rather than by arrival. Without this, optimistic painting plus LWW produces visibly wrong grids under two concurrent editors β the exact bug the optimism is meant to hide.
The SSE transport check is a streamed 200, not a 101. SSE is text/event-stream over a normal response; 101 is the WebSocket upgrade. The test verifies an unbuffered stream through the real edge β a check written against 101 would never pass. (The source doc conflates these; the conflation stops here.)
Idle policy, written down rather than emergent. Connections drop after a defined idle period with no edits and no presence heartbeat. Heartbeats stop on a hidden tab and have a ceiling, so a tab left open all weekend cannot hold an instance awake and billable indefinitely. Held sockets and hibernation pull against each other; this is where that is resolved.
Undo is own-edits-only, cell-level. Ctrl-Z reverts your most recent input event, applied as a fresh LWW write β so if someone has since written over that cell, your undo takes it back and is itself undoable. Multi-user undo has no correct answer; this one is predictable and explainable, which is the next best thing.
---
8. Identity and sharing
8.1 One relying party β RULED, needs Paul's confirmation
The first draft contradicted itself and the review caught it. Β§8 put passkey.Handlers.Gate on the instance; ruling 11 put the RP on the home. Verified against source: passkey.New sets RPID from Config.Origin β the app's own origin. So the obvious wiring enrolls a credential per instance, and a person in three teams enrolls three passkeys. That is precisely the "three instances must not mean three credentials" problem decisions.md says to solve on day one.
Resolution: the home's credential origin is the only RP. Instances never enroll passkeys and never mount Gate.
- Sign-in happens at the home, for members and guests alike: magic link auto-upgrading to Keymail, with the passkey second factor β optional per person, owner-enforceable per team (ruling 13) β completed there, on the RP origin, before any assertion exists. rastrillo's
Gateis used by the home, on its own origin, which is exactly what it is built for. No cross-origin assertion hand-off is required, because the ceremony finishes before the instance is involved. - Instances receive identity as signed assertions β Tito's recorded pattern: headers stripped and re-minted, and signing, not stripping, is what stops a peer socket forging them. An instance verifies a signature; it does not run WebAuthn.
Β§9.1call 1 is therefore unambiguous: the home mints the session, the instance trusts a signed assertion. The first draft left both plausible.
Assertions carry identity, never authorization (docs-e6, 2026-08-30 β stated positively because a reviewer will otherwise propose the opposite as an improvement).
A guest's assertion is the same shape as an owner's. The assertion says who you are; the instance decides what you may do from its own grants table. Narrowing a comment-only guest's assertion is tempting and belongs on the wrong side of the boundary: encoding capability requires the home to know capability, which puts an authorization model inside the identity origin and cuts against Β§9's design, where the home knows you have access to a team in an app and nothing else.
The security argument does not survive either β a leaked assertion buys exactly the grants of the person it names, which is what a stolen session buys anyway. Narrowing reduces no blast radius while adding a second assertion type, its key handling, and a second audience-binding surface.
Assertion expiry is seconds, not minutes. It is exchanged for a session immediately; the long-lived thing is the session, which is sessions' problem and already solved.
Forward, and recorded so this is a staging post rather than a ceiling (Paul, 2026-08-30): CARLOS is expected to grow support for per-instance auth. One RP at the home is the right answer for Sheets now, and it is explicitly not a bet against per-instance auth later β it is a bet that we do not need it yet.
What would make us revisit: sign-in availability becoming a real complaint rather than a theoretical one; a team needing to authenticate against its own instance under its own domain for a compliance reason; or CARLOS shipping per-instance auth with an answer to the N-credentials problem. Any of those, and Β§8.1 is reopened with this paragraph as the reason it was always going to be.
Named consequence: the credential origin is an authority, and a single point of failure for new sign-ins. Existing sessions keep working, and Β§9.1.1's hint ruling covers the directory β but sign-in is deliberately not a hint, and the README says so. Rejected alternative: per-instance passkeys, which keeps sign-in working when the home is down at the cost of N credentials per person and an RP that breaks whenever a team attaches a custom domain.
8.2 Roles
Team: owner, member, guest. Per sheet: owner, editor, commenter, viewer. A guest holds no team-wide rights β they see exactly the sheets granted to them and learn nothing about the team beyond Β§9.5's disclosure.
Team standing comes from the roster the home holds (suite Β§5) in suite shape, and from $SHEETS_OWNERS and the local member rows in lone shape. Per-sheet roles stay here. The doctrine deliberately does not unify them: a shared role vocabulary designed from three specs at once would fit none of them, and it may move to the contract module when two apps have shipped the same one (suite Β§5.6). Sheets has commenter; calendar has no such role and Docs has none yet, so that moment has not arrived.
This is Pegamento's "sharing is membership" taken literally: the sheet never moves, and the outside person becomes a scoped member of the instance that owns it. There is no federation story and there will not be one.
8.3 Link sharing
"Sharing is membership" does not forbid link sharing. It forbids data crossing a team boundary (suite ruling 2), and a link does not move data β whoever holds it reads the sheet here. This sentence exists because the rule reads like a prohibition and someone will later think it was one. (docs-e6, 2026-08-30.)
A link is not a rung on the sharing ladder (suite ruling 15). A sheet is private, granted to named people, or shared with the team, and only the team rung is team-visible β so a sheet that is link-shared but not team-shared is one no sibling may hold a copy of. Making a link team-visible would turn "whoever I sent this URL to" into "everyone on the team, findable through memoria's search", which is a different grant than the one the sharer made.
Ruling 12 below is now the suite's rule, adopted verbatim as suite ruling 15's read-only half. The doctrine cites it; it stays here as the original, and the reason it gives is the reason the suite took it.
Off by default, high-entropy, revocable, viewer-or-commenter only, never editor (ruling 12) β every version row carries an actor, and an anonymous editor would write unattributed rows into version history.
Link tokens are stored hashed, per Pegamento's service contract. Stored plaintext, the replicated database becomes a bundle of live read capabilities to sealed sheets β Β§0 defeated through a side door for the second time (Β§2.3 is the first).
A view link mints nothing β read-only against a capability token; no member, no directory entry. The moment someone wants to write β comment β they sign in, which mints a guest member and produces a directory entry then. A link viewer gets no presence cursor and no name in the presence stack, because presence is a write to shared awareness state (docs-e6's corollary, 2026-08-30).
The line, and it is the same in Docs: anything that writes is attributed, and attribution is what mints membership.
---
9. The home, as a dependency
The home is a separate repo and spec (ruling 10), drafted by this session with Docs as a declared consumer. This section records only what Sheets depends on.
9.1 The contract, cited rather than restated
Sheets consumes the home contract as the home spec states it, and the suite contract as oficina/suite states it. Neither is copied here.
This section used to enumerate five calls. It had already caught itself drifting once β "the first draft called this the four-call contract and then admitted a fifth in a subsection; an implementer reading the heading builds four" β and the same thing happened again from the other side: the home now has six calls, with a seventh and eighth designed, and nothing made this copy notice. The note was right about the mechanism and could not fix it, because the fix is not to count more carefully. It is to stop keeping a second copy (suite Β§10).
What is Sheets' own, and stays:
- The home hashes, and returns the person ref, which we store against our member rows. An earlier revision of this section said the instance sends
HMAC(pepper, β¦), contradicting Β§9.6 of this same document; an implementer reading it would have built instance-side hashing, which needs the pepper on every instance. - A directory row never grants team standing (suite ruling 12), whatever its
kindsays. Membership is the home's own record, written only by an owner.
Instanceβhome auth follows Pegamento's service contract: per-consumer bearer token stored hashed, consumer identity is its origin, origins are an allowlist.
Bootstrap, which the first draft left unstated: a freshly provisioned instance receives its home bearer token as platform config and is added to the home's origin allowlist by the operator drain (Β§9.4), in the same step that creates it. An instance that has neither cannot call the home and, per Β§9.1.1, works fine without it.
9.1.1 The directory is a hint, never an authority β RULED 2026-08-30
The blueprint's position on glue is that losing it costs a convenience, never content. (Caught by docs-e6 reviewing as a consumer.)
- A failed directory upsert never fails the share. The person is granted access; the instance is the authority; the directory catches up.
- Call 2 goes through an outbox in the instance, never a call in the request path.
- Every instance re-checks authorization on every request, whatever the directory says. The directory answers "where might I have things", never "may I have this".
The outbox drains on wake and on each share mutation β not on a ticker. A hibernating instance runs no timers, so a time.Ticker retry loop is a retry loop that dies at hibernation. For a quiet team a failed upsert may wait weeks for an organic wake, which is acceptable precisely because the directory is a hint.
Note this ruling does not extend to sign-in (Β§8.1), which is an authority by design and says so.
9.2 Split origins β RULED 2026-08-30
A team name is account-authored, so a home rendering team names on its passkey RP origin is what decisions.md warns against. The answer is Tito's: credentials only ever on the root origin, account-authored content only on the account's own host.
- Credential origin β sign-in and the WebAuthn ceremony, nothing else. No team names, no titles, no account-authored string, ever. This is the RP.
- Directory origin β a different hostname. Renders team names, holds no credentials.
The directory exchanges a signed assertion from the credential origin for its own session. An XSS on the directory cannot reach the RP.
Rejected for now: names in a sealed blob decrypted in the browser. It needs a client-held key; our key source is passkey PRF; passkeys are optional (ruling 13). It would force passkeys on everyone or show unnamed rows to the least technical users. It becomes correct when Pegamento's vault facet lands, and split origins make it an upgrade rather than a redesign.
9.3 Theme is per-person suite state β RULED 2026-08-30 by Paul
Light/dark is a per-person preference, switchable from the spreadsheet editor (ruling 14). Not per document, not per team.
It therefore lives with the home as suite-level account state, or a person sets it separately in every team they belong to β the same shape as the credentials problem in Β§8.1.
It must resolve before first paint. For a grid this is sharper than for plain chrome: cell fills resolve against the theme (Β§10.2), so a late-arriving preference does not merely flip the chrome, it repaints every coloured cell in the sheet. The preference must therefore be readable synchronously at render β a cookie on the instance's own origin, written from the assertion at session mint and refreshed when the person changes it β never a fetch, and never a value the first paint has to wait on.
The in-editor toggle writes through to the home and updates the local cookie optimistically, so the switch is instant and the preference follows the person to their next team.
9.4 Provisioning, and a recorded divergence from Pegamento
Signup writes a provisioning request; an operator command drains it under a real console credential, creates instances, delivers the home bearer token and allowlist entry (Β§9.1), and sends the welcome mail. Team ready in minutes, and the copy says so rather than faking a spinner.
Recorded divergence. Pegamento doctrine Β§9 is explicit that provisioning and instance lifecycle are not Pegamento, and flags Eleven's home β a blind vault and a graph-aware provisioning broker in one binary β as "the one place blindness breaks", adding "Pegamento does not repeat that conflation." Our home carries sign-in, directory and provisioning enqueue.
That is a divergence and the first draft claimed doctrinal compliance it did not have. The divergence is deliberate and bounded: call 4 is an opaque enqueue with no directory read and no graph access; the queue lives in its own store; and the drain runs outside the home process entirely, under a human's credential. The conflation the doctrine warns about is a blind service that also knows personβinstance β ours knows personβinstance in the directory already (Β§9.5 discloses it), so the enqueue adds no knowledge the service lacks.
Revisit trigger: the home spec is the right place to decide whether the queue moves to a separate binary. Recorded here so the home spec inherits an argued position rather than an accident.
9.5 A guest grant discloses the granting team's name
Sharing one sheet with an outside guest puts a row in their directory carrying the granting team's name.
Ruled: keep the behaviour, disclose it in the share UI. The alternative β guest grants producing no directory row β removes the reason the directory exists. What is wrong is an emergent property nobody decided. The share dialog says, in words, who will be able to see the team's name. (docs-e6, 2026-08-30.)
9.6 Re-keying, and the pepper's blast radius
The directory is keyed on HMAC(pepper, lowercased email).
Email change. The person's HMAC changes and their rows orphan. Call 5 is a cursor-based rekey journal that instances pull, carrying old_hmac β new_hmac. The home learns both addresses transiently and stores neither.
Resolved by the home spec, 2026-08-30: instances hold readable addresses; the home never does. The sharer typed the address, so the team already knows it β storing it in that team's instance leaks nothing the team lacks, whereas the home holding every address is the aggregation risk. It also keeps share notification possible, since nobody can email a hash.
The journal keys on person refs, not hashes. An instance holds no hashes and can compute none, so a hash-keyed journal would have matched nothing it had. The ref arrives in call 2's response and is also the assertion's sub, so one identifier links assertion to grant to journal. Rows carry the sealed new address alongside it, scoped per consumer, with a per-consumer retention TTL rather than a slowest-cursor rule.
The pepper is a suite-wide secret with per-team blast radius, and that is the wrong shape. Call 2 has the instance compute the HMAC, so every instance holds the pepper β and one compromised instance enables offline dictionary attacks against the entire suite directory. That inverts factor VIII, where the point of instance-per-team is that a compromise is bounded to one team.
Ruled: instances never hold the pepper. Call 2 sends the address over the authenticated channel and the home hashes it, learning it transiently exactly as it already does at sign-in (Β§9.1). This costs the home a transient plaintext address it already handles, and removes a suite-wide secret from every instance on the estate.
Pepper rotation then becomes the home's business alone. Under Β§9.1.1 a rotation is a convenience outage rather than data loss: access is unaffected, and the directory refills as instances re-upsert. Rotation requires a re-announce sweep, not merely outbox retry β retry redelivers failed upserts and does not re-enumerate every existing grant. That sweep is a named operator verb, not an emergent behaviour.
9.7 The RP rename drill β scheduled, then made unnecessary
Ruling 11 put the RP on a platform-default host provisionally, which scheduled this rename rather than avoiding it. Ruling 18 then ran it early, for free.
The hostnames, settled 2026-09-03. The suite's primary domain is oficina.works, registered and ours. The credential origin is id.oficina.works β that is the RP β and a Sheets instance is <team>.sheets.oficina.works, one team to one hostname. Hosting stays on Carloku, with oficina.works as a custom domain in front of it.
This replaces the provisional <app>.<sqid>.<apps-domain> on the hosted apps-domain oncarlos.com under account sqid bds (console.carloku.com/a/bds/). bds was a hosting artifact and does not survive the move. Earlier drafts naming carloku.com β the console brand β were wrong about the old domain too; none of it matters now except as the LegacyRPID value, if it is ever needed.
The drill did not have to run. At the time of the move no passkey.Config existed in any app in the suite β sign-in was magic-link throughout β so no credential had ever been scoped to an oncarlos.com host. The window below is sized for the next move, not this one, and its cost is the argument for having moved before passkeys shipped rather than after.
Never widen the RP ID to a registrable suffix. WebAuthn permits an RP ID that is a suffix of the origin's hostname, so oficina.works would be accepted for id.oficina.works. The objection used to be other Carloku tenants on a shared platform domain; on a zone we own it is that such an RP ID is valid at every host in the zone β this instance included β so the credential the home holds would be presentable straight to a Sheets instance, which is the separation ruling 15 exists to keep. The RP ID is the full host, always.
But the mechanism already exists. passkey.Config.LegacyRPID β verified at source β is "a relying party this instance used to be, accepted for assertions (never registration) so a hostname move doesn't strand enrolled keys." That is the accepted-for-assertion-never-registration crossover this spec described as something to invent. (Found by docs-e6, 2026-08-30, verified by this session.)
So the drill is: set LegacyRPID to the old host, run the window, drop it. Not a multi-phase invention.
The window is still sized by the guest tail, not by active members. The population least likely to survive is the third-party guest who signed in once, months ago, to comment on one sheet β no ongoing relationship, no reason to be paying attention, invisible to any plan that assumes people re-authenticate when nudged. The window is measured in months. A drill that quietly drops the guests is a drill that failed while reporting success. LegacyRPID removes the machinery cost; it does not shorten the window.
Trigger: a real suite domain exists.
---
9a. Consumed suite rulings
The suite sharing doctrine (oficina/suite) binds this spec. Read against revision 4, commit 210312f.
| # | What it settles for Sheets | |---|---| | 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. | | 2 | Sharing is membership across teams. Nothing crosses a team label. | | 3 | Custody and authorization are two rules, and authorization is never cached at any scope. | | 5 | Two shapes, one switch. The sibling-token switch is independent of whether a home is present. | | 6 | The egress sentence, suite-wide. | | 7 | One contract, one module, fixtures as the interface. | | 8 | Two credentials. A consumer token proves a same-team sibling; an assertion proves a person. | | 9 | A write names its real actor β the rule Β§8.3's ruling 12 already served. | | 12 | Membership is the home's own record. A directory row never grants team standing. | | 14 | Custody is bounded at acquisition and retired by the feed. | | 15 | Three rungs and a read-only link. Sheets ruling 12 is the original this adopts. |
Sheets serves S1, S2, S3 and S6 wherever sibling consumer tokens are in its platform config. S6 is Sheets' alone: an enumerated aggregate over cell refs, capped, never expression text, so memoria can total a column without holding the sheet.
Not yet consumed: S8 write-as-self (suite Β§7.2) needs a designated-area grant in the share UI, and the hand-off (suite Β§7.1) needs a receipt route with pending state. Neither is specified here.
One thing the doctrine took from Sheets rather than the other way round. Ruling 12 β link sharing is viewer-or-commenter, never editor β is now suite ruling 15's read-only half, and the reason Sheets gave for it is the reason the suite took it: every version row carries an actor, and an anonymous editor writes unattributed rows into history. A draft of the doctrine proposed an anonymous link editor and had to be corrected against this spec.
---
10. Design system
day theme (ruling 7). rastrillo tokens.
10.1 Markup: classes now, attributes after the dual-grammar release
The first draft got this backwards and it would have shipped an unstyled app. Verified against origin/main post-design-system-v2 (2026-08-30): ui/tokens.css contains *zero bare `rst- attribute selectors** against ~528 .rst- class selector occurrences, and the rake line ::after is still live. (It *does* carry attribute selectors for [data-tone=], [aria-] and [type=]` β an earlier wording said "zero attribute selectors", which was imprecise and would not survive being checked. The conclusion is unchanged.) The attribute grammar is "RULED, not yet started", and its staged path is explicitly ship a tokens.css matching both spellings β codemod β drop the class selectors.*
So: *write `class="rst-" today.** When the dual-grammar release ships, run the codemod. Writing rst-btn="primary compact" now renders unstyled buttons against a frozen static/tokens.css`, which is the worst of both spellings.
This session over-applied advice from the design-system session. Their advice β don't invest in the losing spelling β was about investment, not about writing markup no shipped stylesheet matches.
tokens.css is frozen into static/ at scaffold time while partials upgrade with the module. Re-copy deliberately on every rastrillo upgrade. rastrillo doctor was proposed to make this mechanical, with this app as its named consumer; adopt it the day it lands and delete the manual step.
Design system v2 is a dated dependency, not an assumption. Everything this spec takes from it is either shipped (verified in ui at origin/main) or staged (ruled in rastrillo's spec, not in released code). Shipped: ui.TokensCSS(), the class vocabulary, the three themes. Staged, and therefore unavailable to us today: the bare-attribute grammar, --rst-header-rule, Pair, Allocate, rastrillo doctor. Neither of the two consuming sessions could tell a ruling from a released API by reading the design-system spec, which is why this paragraph exists rather than a reference to it.
Upstream recording is a risk, not a fact. rastrillo doctor, the (hue, chroma, background) β {fill, on-fill} pair function and the allocation entry point exist in this spec and in cross-session messages, and in zero files in the rastrillo repo. Cross-session agreements that live only downstream are agreements the upstream can silently never ship. Task: get each recorded upstream as an issue or spec section; until then treat all three as unavailable and keep the Β§10.3 shim.
10.2 The four jank cuts, made concrete
- Top bar is title, Share, avatar. No upgrade button, no app waffle, no AI sparkle, no right-hand rail.
- Toolbar is contextual and grouped β around ten controls that change with selection. The long tail lives in a command palette.
- Menus go shallow: File, Edit, Insert, Format, Data, Help.
- Home is your sheets by recency and a New button. No template carousel.
The app shell is app-local. Shared primitives are not.
Destination: oficina/estilo β a CARLOS suite design-system variant, not rastrillo's ui (Paul, 2026-08-30; repo created same day). A variant depends on ui, keeps its bones β tokens, the partial vocabulary, the accessibility floors β and adds components, themes and its own gallery. Not a fork, and more than a theme, since a theme cannot add a component. cmd/dsgen went public 2026-08-30 and generates a gallery for any module's own vocabulary; read it before anyone writes a second one.
The distinction that produced this, worth keeping (ui-guidelines-52, 2026-08-30): two callers at design time is the bar for extraction, and it says nothing about destination. Both consuming sessions were told "propose it to the library" and applied the right bar to the wrong question, because the first answer looked like it settled the second. It will recur for every piece either app hands upward.
Primitives split with Docs: we own toolbar groups; Docs owns inspector panel, presence stack and comment thread (their anchoring case is harder, so it owns the design and we consume it β a cross-repo dependency inside slice one, noted in Β§13's risk). Same standards either way: twelve locales, the script-required line stated per primitive, 24Γ24 targets, 320px reflow, colour never carrying meaning alone.
The colour engine is unaffected and stays in ui. Pair and allocation are contrast arithmetic against floors the framework already gates β a function, not a component β so our cell fills and Docs' highlights are callers of the same thing regardless of where their components live.
10.3 Cell fill colour
Stored as OKLCH intent (hue + chroma), resolved to a {fill, on-fill} pair β the pair must clear 4.5:1, because a user picks a fill and then types in it. Imported hex is retained alongside for lossless XLSX round-trips, dropped when a user re-picks (ruling 8).
Signature takes a literal background, not a scheme: (hue, chroma, background) β {fill, on-fill}. A scheme parameter could only mean "resolve against the theme's surface token", which is wrong whenever the cell sits on something else β conditional formatting paints under a user fill, selection tints, frozen headers. Passing the real background is what makes our contrast gate assert the pair the cell actually shows, rather than certifying the wrong thing while passing.
Allocation is a second entry point, a pure function of a canonically ordered key set (member id), never of arrival order β otherwise two clients viewing the same sheet allocate differently and the same person is green for one viewer and blue for another. The avoid set is a parameter, so collisions occur only past capacity. Sheets is a caller for presence cursors (Β§7). Stability of a person's colour is best-effort; separation is the hard guarantee.
Until ui ships these: a deliberately crippled shim. A hardcoded table of six to eight intents with hand-computed pairs, behind the corrected signature, gated by a contrast test that copies rastrillo's arithmetic verbatim β not merely models it, or the gates diverge by a rounding decision and swapping the table changes the test's meaning silently. Header comment names it a placeholder and links Β§10.1's upstream-recording task as its deletion trigger.
Colour never carries meaning alone. A fill is decoration; whatever it means must survive greyscale.
10.4 Accessibility
The grid claims 1.4.10's two-dimensional-content exemption. A data table is the WG's canonical example and a spreadsheet is that case at its most literal. So the grid scrolls in both axes at 320px, and every other surface must reflow or it is a bug, not an exemption β falsifiable, which is why it holds.
Target size 24Γ24 throughout, including a 24px hit area around the fill handle's small visual. Every drag-only affordance gets a menu equivalent, which buys the scriptless path at the same time. We do not lean on 2.5.8's spacing exception.
---
11. XLSX and CSV
excelize both directions. Import maps incoming hex fills to the nearest offered intent while retaining the original hex (Β§10.3); export resolves intent to concrete hex.
A golden round-trip corpus is a gate: import β export β import must be stable, and our output must open cleanly in Excel and LibreOffice.
Import runs as a rastrillo background job, 303 to /jobs/{id}, with StatusPage/Fragment/Events mounted behind sessions.Require. The status page works scriptless. Jobs are idempotent β a deploy ends them mid-flight β and location is a server-built path, never user input. Job error text carries coordinates and codes, never content (Β§2.4).
Uploaded and generated files go to blobs.Sealed with ContentType: application/octet-stream (Β§2.3).
Import's HTTP surface built 2026-09-02. The converter and its round-trip corpus predated this by two days; what did not exist was any way to reach them β no route, no upload, no job, and a PRODUCT.md that listed import among the things "built and working". POST /sheets/import now takes a workbook, makes the sheet, and runs xlsx.Import as a jobs job that 303s to /jobs/{id} and lands on the finished sheet. Three things the design had not had to decide, decided here:
- The sheet is created before the job, and removed if the job fails.
jobs.Startneeds the finished location up front and that location must be server-built, so the sheet id has to exist first. The cost is a sheet that briefly exists without contents; a failed import soft-deletes it, because a half-imported sheet looks complete, sits in a list the whole team can see, and its only contradiction is a status page nobody else opened. - The upload is not retained at all. The rule above governs keeping one; not keeping it satisfies the same concern more simply, and no plaintext workbook reaches storage in any form.
xlsx.StoreSealedis still there for the task that wants the original back. - Job error text is replaced, never wrapped. rastrillo's runner slogs the error a job returns, and
carlos logsis platform-held. SoimportFailurebuilds fresh text from a coordinate and a code, andxlsx.CellErrorcarries that coordinate as data β itsError()omits its own cause by construction, so printing one cannot leak a cell even by accident.
Drag and drop is the form. The list's drop zone fills the same file input and submits the same form, so there is one route and one code path, and Β§6's scriptless floor holds without a second implementation to keep in step with the first.
11.1 A retained fill can be unreadable, and that is correct
Retaining an imported fill's original hex (Β§10.3) means we also carry the author's font colour, and the pair may fail WCAG AA. This is not hypothetical: Excel's own light-yellow conditional-formatting preset ships #9C6500 on #FFEB9C β 4.12:1, below the 4.5:1 floor β confirmed against an independent source, so it is what the incumbent offers by default, not merely what a careless user can construct.
We render it as authored. Faithfully displaying a document somebody else wrote is a different act from generating a colour, and silently correcting imported formatting would lie about the file β the same class of wrong as inventing a font colour on export (Β§10.3).
So the guarantee is narrower than it reads. Our offered set cannot produce an unreadable pairing; a cell in this app can nonetheless be one, when the file said so. Anyone reading the first claim as the second will eventually find a failing cell and file a bug against a guarantee that is working exactly as specified.
The obligation that follows is surfacing, not overriding β telling a person their imported file contains a pairing that fails, and leaving the decision to them. That was grid work and belonged to plan 2 (Β§14).
Built 2026-09-01. It needed one thing this spec had assumed: the authored ink. readStyle retained fills and never font colours, so the pair Β§11.1 is about could not exist in our data at all β a fill alone cannot fail, since the other half was always our own theme's. Format gained InkHex, XLSX import and export carry it, and the grid and table views name the failing cells in a notice. No cell is altered.
(Found 2026-08-31 by ui-guidelines-52 while building rastrillo's colour engine, from Excel's documented preset fill/ink pairs. Related: #0000FF carries black ink at 2.44:1 and cannot be a wash at all β the case where a user's chosen hue and their ink genuinely conflict.)
---
12. Testing gates
From the first commit:
- Plaintext grep over the raw SQLite file and WAL, plus every object this app writes to its own store (Β§2.6).
- Two-user isolation suite, including the grants-join 404-not-403 contract (Β§4.5).
- Golden XLSX round-trip corpus.
- Formula golden vectors from LibreOffice headless.
- Contrast test copying
ui/contrast_test.go's arithmetic verbatim. - Scriptless-affordance diff.
- Browser drives in CI. A JS syntax error takes the whole client down and only a real engine sees it.
- Module-size cap on JS, ratchet-down only. The vendored Jspreadsheet blob is exempt and named; nothing we write is.
- No-content-in-logs β scan log lines, error strings and job messages for cell content (Β§2.4).
- Cache coherence β assert no cell serves a cached value whose
cache_versionpredates its input's version (Β§4.4). - Computed value to painted output. A browser drive reads the text actually painted in cells and compares it against the golden corpus's expected display strings β crossing from the engine to the screen.
Why gate 11 exists, and why gates 4 and 7 do not cover it (docs-e6, 2026-08-30). Gate 4 proves the Go engine matches LibreOffice. Gate 7 proves the client runs. Neither proves that what a person sees is what the engine computed β and a gate that compares two of our own layers against each other can stay green forever while the product is wrong. Docs shipped exactly that bug: a conformance test comparing two runtimes of the same module, proving determinism, which WASM guarantees anyway, and never proving the thing the rule was about.
The rule to keep: at least one gate must cross from computed value to painted output. A suite of gates that all live inside one layer is a suite that agrees with itself.
Review on a deployed canary, never localhost. Verify a deploy with X-Carlos-Version on the canonical host after the watch β never a 200.
12.1 Review β RULED 2026-08-30 by Paul
Every spec and every branch is reviewed by the highest-capability model available before it is approved or merged. Cheaper models draft; the most capable available reviews. A review by a weaker model than the author is not a review.
Specs get a pass before human approval and again after any material change to a settled decision. Branches get one before the PR opens and again after any force-push bigger than a rebase. The reviewing model is recorded β in this spec's status line, and in the branch discussion for a branch, which survives a squash where a PR body does not. A review nobody can attribute is a review nobody can check.
The highest-capability model is discovered, not hardcoded. A session that cannot reach a model at least as capable as the author's says so and waits.
---
13. Sequencing
- Skeleton,
AGENTS.md, auth - Storage and sealing (gates 1, 9, 10 live from here)
- Formula engine
- Grid
- XLSX / CSV
- Sharing, and the home contract
- Live sync
- Version history
- Sort, filter, conditional formatting
- Comments
- Charts
Scope honesty, from the review β narrowing confirmed by Paul, 2026-08-30 (ruling 17). Ruling 5 stands; what it costs: three of the eleven items are subsystems, not features β charts, version history (restore semantics, replay, retention), and conditional formatting (a second expression evaluator plus a rules UI). Naming charts as the sole slip candidate understated the risk.
So the slip order is written down rather than discovered: charts first, conditional formatting second.
The criterion is "its absence leaves a coherent product", not "it has no dependents" (docs-e6, 2026-08-30). The two usually agree, and where they disagree the first is the one that matters. Charts passes on the stronger reading: a spreadsheet without charts is still a spreadsheet. Conditional formatting is second because a spreadsheet without it is also still a spreadsheet, merely a duller one β whereas a spreadsheet without version history is one nobody trusts with real data, which is why history is not on this list at all. Version history is not a slip candidate because Β§4.3's log design is load-bearing for live sync β but its minimal form is named now: view history and restore a cell or a range. Restore-a-whole-sheet is the extension, not the baseline.
Progress is measured by gates green, not by items ticked.
One cross-repo dependency inside the slice: comments consume Docs' comment-thread primitive (Β§10.2). If Docs slips, we ship comments with a cell-anchored local implementation and adopt theirs later β decided now so it is a fallback rather than a scramble.
---
14. Deliberately not built yet
| Thing | Trigger | |---|---| | Pegamento's real vault facet (sealed team names, Β§9.2) | The vault facet ships with a recovery story | | Moving the provisioning queue out of the home (Β§9.4) | The home spec rules on it | | 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. | | 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. | | Pivot tables | Asked for twice | | Rich presence beyond who's-here | Teams above ~5 concurrent editors | | Offline editing | β | | Mobile-native apps | β | | client shape for anything but the grid | A surface genuinely needs client-held keys | | 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 | | Live sheet range embedded in a doc | A known future crossing of two collaboration models | | ~~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. | | 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. | | ~~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. |
---
15. Open
- Assertion verification is the remaining shared gap, and it is smaller than the one it replaced. Ruling 15 dissolves the cross-origin WebAuthn problem β the ceremony finishes at the home before any assertion exists, so nothing needs handing across origins. What is left is that an instance must verify a signed identity assertion, and rastrillo has no helper for it. Docs needs the identical thing. To be settled jointly and filed once.
- Upstream recording of
rastrillo doctor, the pair function and allocation (Β§10.1). Until done, all three are unavailable. - The platform provisioning credential β filed as
carlos/platform#4. - Repo move verbs β filed as
amadan/amadan#28. - A real suite domain β triggers Β§9.7.
- The home spec itself β drafted next, in its own repo.
- Language neutrality upstream. A discussion proposing CARLOS picks the language per app rather than assuming Go. Nothing here depends on the outcome; Go stays right for this app either way.
---
Appendix A β assertion verification, filing draft (awaiting Paul's nod)
Destination: github.com/rastrilloorg/rastrillo. Rastrillo has no amadan remote, so this is a GitHub issue rather than an amadan discuss, and it needs Paul's go-ahead. Drafted jointly with docs-e6; both consumer paragraphs are below.
Title: An instance needs to verify a signed identity assertion
Instance-per-account makes single sign-in a day-one problem β decisions.md says it directly: "three instances must not mean three credentials." Two apps have now independently arrived at the same answer, which is why this is a framework ask rather than an app one.
The shape both reached: one identity origin holds the credentials and runs the ceremony; instances receive identity as a signed assertion and verify a signature. Instances never run WebAuthn, never hold a passkey RP, never see a password.
This is Tito's recorded pattern: identity lives beside the router, outside the per-account blast radius, and reaches instances only as signed assertions β headers are stripped-and-re-minted, and signing (not stripping alone) is what stops a peer socket from forging them.
What rastrillo has: sessions, auth, password, passkey β every one authenticating at the app. What is missing: anything letting an app say "I trust assertions minted by this identity origin; verify this one and mint a session from it."
Why the framework rather than two apps. The parenthetical in Tito's rule is the point β signing, not stripping alone β and the failure modes do not announce themselves:
- Audience binding. An assertion must name the instance it is for, or one minted for team A replays against team B. Both apps are multi-tenant by construction, so every deployment has thousands of sibling instances to replay against. This is the one most likely to differ subtly between two independent implementations.
- Replay. Single-use identifier plus short expiry β seconds, not minutes; the long-lived thing is the session minted afterwards.
- Key distribution and rotation, without a flag day.
- Algorithm confusion.
That is squarely "supplies what is hard to get right twice", which is how rastrillo describes its own scope β and it is literally twice: two apps, same week, same pattern, neither having seen the other's code.
Rough shape, not a design: verify against pinned or fetched public keys, check audience against this instance's own identity, enforce single-use and expiry, return a Subject plus claims, hand off to sessions.SignIn β which is the whole contract the existing identity plugins have with the core. A fourth plugin, not a new seam.
One thing to state positively, because a reviewer will otherwise propose it as an improvement: assertions carry identity, never authorization. A guest's assertion is the same shape as an owner's; the instance decides capability from its own grants. Encoding capability would require the identity origin to hold an authorization model, and a leaked assertion buys exactly what a stolen session buys anyway β so narrowing reduces no blast radius while adding a second assertion type and a second audience-binding surface.
Named consumers:
- Sheets (
oficina/sheets) β the home's credential origin is the sole RP, confirmed by Paul 2026-08-30; instances verify assertions and never enroll passkeys. - Docs (
oficina/docs) β same shape, same week, independently reached. Docs adds one operational requirement Sheets does not surface: its guest population is dominated by single-purpose visitors who signed in once to comment and never returned, so the single-use replay store churns against a population far larger than the membership list and unbounded in time. The store must be self-pruning, sized by assertion lifetime rather than by user count β a naive implementation is correct on day one and a growing table for the life of the deployment. That belongs in the helper, because "single-use" is the helper's promise to keep.