| 1 | # π€ Sheets β design |
| 2 | |
| 3 | **Status:** design, revised 2026-08-30 after review. Approved in |
| 4 | brainstorming by Paul on 2026-08-30; revised the same day against a |
| 5 | review dispatched to **Fable** (13 confirmed findings), with three source |
| 6 | claims independently re-verified by this session and three more by the Docs |
| 7 | session. Every ruling is dated and attributed, with the rejected |
| 8 | alternative beside it. |
| 9 | **Reviewing model, per Β§12.1:** dispatched to Fable. *(The agent's own |
| 10 | header self-reported as Opus 5; models are unreliable about their own |
| 11 | identity, so the honest record is what was dispatched.)* |
| 12 | **Scope:** the Sheets instance app. The Suite home is a **separate spec in |
| 13 | a 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 | |
| 22 | The instance holds the key while it runs, so the server *can* read a sheet |
| 23 | it has open. That is a deliberate softening, not an accident, and Β§2 names |
| 24 | every leak it produces. What the rule forbids absolutely is a plaintext |
| 25 | value 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 | |
| 58 | The DEK arrives as platform config (`carlos secrets`), never stored in the |
| 59 | database it decrypts. HKDF derives purpose keys; values are sealed with |
| 60 | AES-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 | |
| 86 | Publish our database and you learn who works with whom, how big their |
| 87 | spreadsheets are, how often they edit and what colour they painted things. |
| 88 | You learn **no value in any cell**. |
| 89 | |
| 90 | ### 2.3 Blobs, and the metadata that escapes sealing |
| 91 | |
| 92 | Imported and exported XLSX files are whole spreadsheets. If they reach |
| 93 | `rastrillo/blobs` on the S3 backend unsealed, that is a plaintext |
| 94 | spreadsheet in a bucket β Β§0 defeated through a side door. |
| 95 | |
| 96 | **`blobs.Sealed` is mandatory for every blob this app writes.** It exists |
| 97 | for exactly this (`blobs/blobs.go`: E2EE apps wrap any backend in Sealed). |
| 98 | |
| 99 | Three 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 |
| 112 | not control and cannot grep. |
| 113 | |
| 114 | **Rule: no cell content, comment text, sheet title or tab name in any log |
| 115 | line, error string, job status message, or metric label.** Errors carry |
| 116 | coordinates and error codes; the content stays in the sheet. This is |
| 117 | test-enforced (Β§12 gate 9). |
| 118 | |
| 119 | ### 2.5 The residual risk, stated and never contradicted |
| 120 | |
| 121 | A compromised *live* host can read any sheet the instance has open, because |
| 122 | the key is in memory. So can anyone who can read platform secrets. **We do |
| 123 | not claim "we can't read your data" anywhere in the product.** |
| 124 | |
| 125 | ### 2.6 The gate, honestly scoped |
| 126 | |
| 127 | The platform runs Litestream into a platform-owned bucket and the member |
| 128 | contract is zero infrastructure access β **CI cannot read that bucket.** |
| 129 | Scoping the gate to "bucket objects" would have been silently narrowed at |
| 130 | implementation time, which is the drift Β§0 exists to prevent. |
| 131 | |
| 132 | What the gate actually covers: **the raw SQLite file and its WAL, byte for |
| 133 | byte, plus every object this app writes to its own store.** The DB file is |
| 134 | byte-identical to what Litestream ships, so covering it covers the |
| 135 | replication path. `plaintextExempt` starts empty; any entry is dated, |
| 136 | justified and listed in the README. |
| 137 | |
| 138 | --- |
| 139 | |
| 140 | ## 3. System shape |
| 141 | |
| 142 | One repo, `oficina/sheets`, one binary: `cmd/sheets`. One process, one |
| 143 | SQLite file, one hostname per team, hibernating. rastrillo `server` shape |
| 144 | throughout, 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 | |
| 165 | Migrations 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` |
| 170 | and `cols` records; `B7` is derived at render time. |
| 171 | |
| 172 | This is load-bearing for **three** things: comments stay attached to the |
| 173 | cell they were written about when a row is inserted above them; conditional |
| 174 | formatting ranges move correctly; chart ranges move correctly. |
| 175 | |
| 176 | **It does not eliminate formula reference rewriting, and the first draft |
| 177 | claimed it did.** Relative references in A1 semantics are *positional |
| 178 | offsets*, not identities β copying `=A1` from B1 to B2 must produce `=A2`. |
| 179 | Stable 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:** |
| 191 | a row inserted *strictly between* the bounds joins the range; one inserted |
| 192 | at either edge does not. Written down because the two behaviours are |
| 193 | indistinguishable until someone relies on one. |
| 194 | |
| 195 | ### 4.3 What the event log stores |
| 196 | |
| 197 | Two 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 | |
| 204 | Derived values are not logged (ruling 16), because logging them makes the |
| 205 | log grow with the *fan-out* of every edit rather than with the number of |
| 206 | edits (Β§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 |
| 208 | recalculation, so it needs a progress state rather than being instant.** |
| 209 | Restoring a version replays inputs and **recomputes**; that is slower than |
| 210 | reading stored values and it is the only version that stays honest at scale. |
| 211 | |
| 212 | **Retention:** the log is checkpointed. Beyond the retention horizon, |
| 213 | per-cell input history is compacted to periodic full snapshots of the |
| 214 | sealed input layer, and the spec's version-history UI is explicit that |
| 215 | history is complete within the horizon and coarse before it. "Additive-only |
| 216 | migrations" is a schema rule and says nothing about a log that grows |
| 217 | forever; this is that rule. |
| 218 | |
| 219 | ### 4.4 The in-memory handle, and crash recovery |
| 220 | |
| 221 | Opening a sheet decrypts it into a refcounted handle holding the dependency |
| 222 | graph. Edits, sort, filter, search and export read it; writes go through to |
| 223 | the tables and the log. Idle handles evict. |
| 224 | |
| 225 | **A crash mid-recalc must not leave stale caches serving as truth.** The |
| 226 | input write and a **dirty marker** land in the same transaction; the recalc |
| 227 | pass clears the marker only when it completes. On open, any dirty marker |
| 228 | triggers a dirty-forward recalc before the handle serves a read. |
| 229 | `cache_version` on each cell records the input version its cached value was |
| 230 | derived from, so a stale cache is detectable rather than assumed. |
| 231 | |
| 232 | Without this, a deploy, a hibernation eviction or a panic between the input |
| 233 | write and its dependents' rewrite produces a sheet whose displayed totals |
| 234 | disagree with its inputs, silently, forever. |
| 235 | |
| 236 | **Budgets, written down because "hold it in RAM" is only true up to a |
| 237 | number:** |
| 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 | |
| 250 | Verified against source: `scope.Owned` is literally `g.Where("user_id = ?", |
| 251 | owner)`, and its sibling `scope.OwnedBy` is the same single-column equality |
| 252 | with 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 |
| 257 | error, which is the worst available failure shape and also the default thing |
| 258 | to reach for. |
| 259 | |
| 260 | **Access goes through a grants join, hand-written**, expressing "rows this |
| 261 | team can see, plus rows granted to me individually, minus rows I have been |
| 262 | removed from". The **404-not-403 contract is preserved by hand** and is |
| 263 | test-enforced by the two-user isolation suite (Β§12 gate 2). |
| 264 | |
| 265 | `rastrillo/idear` is the roles/membership addon and is explicitly *not* |
| 266 | core. We hand-roll `members`/`grants` rather than adopt it, because our |
| 267 | guest model (Β§8.3) is per-sheet rather than per-namespace. **Recorded as a |
| 268 | divergence** so it is a decision rather than an oversight; revisit if idear |
| 269 | grows per-resource grants. |
| 270 | |
| 271 | Never bind a request body onto a GORM model: explicit `map[string]any` plus |
| 272 | a `.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 |
| 275 | to uid 0. |
| 276 | |
| 277 | --- |
| 278 | |
| 279 | ## 5. The calc engine |
| 280 | |
| 281 | Lexer β parser β AST β evaluator, plus a precedent graph with dirty-set |
| 282 | propagation. Cycle detection returns a circular-reference error rather than |
| 283 | hanging. Excel error values throughout, because XLSX round-trips them. |
| 284 | ~120 functions to start. |
| 285 | |
| 286 | This is the single largest piece of work in the slice β larger than the |
| 287 | grid. Written in Go, from scratch. `excelize` (BSD-3) is confined **strictly |
| 288 | to XLSX I/O**: its calc engine works against a file model rather than an |
| 289 | in-memory dependency graph. |
| 290 | |
| 291 | **Cell values are IEEE doubles end to end.** The family's "a float never |
| 292 | touches an amount" rule is about app-domain quantities, and a spreadsheet |
| 293 | cell is not one β currency is a *number format*, and `=A1*1.05` produces a |
| 294 | double that XLSX itself stores as a double. Storing cents would round the |
| 295 | evaluator's own output and diverge from Excel on re-read. **The cents rule |
| 296 | binds app-owned amounts only** β billing, quotas, anything Sheets itself |
| 297 | counts. The first draft applied it to cell storage, which was incoherent. |
| 298 | |
| 299 | **Golden vectors are generated from LibreOffice headless** β expected values |
| 300 | produced by a real spreadsheet, not by our reading of a spec. It is the only |
| 301 | honest way to claim 120 functions. |
| 302 | |
| 303 | **Go formats; the client paints strings it is given.** Ruling 2 put one |
| 304 | engine behind cell *values* β but a value is not what a cell shows. `123.456` |
| 305 | under a currency format displays `β¬123.46`, and if the browser renders that |
| 306 | from a raw number, **number formatting is a second implementation of the |
| 307 | same truth**, which is the exact bug class ruling 2 exists to prevent, one |
| 308 | layer down. So the wire carries the display string alongside the raw value; |
| 309 | Jspreadsheet paints what it is handed. Formats are still edited as |
| 310 | structured records β the client sends "currency, 2dp", never a rendered |
| 311 | string. |
| 312 | |
| 313 | *(Found by analogy: docs-e6's r1 declared "one engine, one truth" and then |
| 314 | chose contentEditable, handing line-breaking to the browser β a second |
| 315 | layout authority inside a spec whose founding rule forbade one. The same |
| 316 | shape 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 |
| 319 | whole-column formula dirties thousands of dependents, each needing a seal, a |
| 320 | cell update and an SSE payload entry. The dirty set is capped per pass; past |
| 321 | the cap the client is told to refetch the viewport rather than being sent a |
| 322 | huge delta. Derived cells are **not** individually logged (Β§4.3), which is |
| 323 | what keeps the log linear in edits rather than in fan-out. |
| 324 | |
| 325 | --- |
| 326 | |
| 327 | ## 6. The grid, and where zero-JS stops |
| 328 | |
| 329 | Jspreadsheet CE v5, vendored as a static asset β no CDN, no bundler, no |
| 330 | build 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, |
| 335 | settings, version history, comments, import and export, and a **read-only |
| 336 | table view of any sheet with per-cell edit forms** β **paginated**, because |
| 337 | a 200,000-cell tab rendered as one HTML table is a several-hundred-megabyte |
| 338 | page. |
| 339 | |
| 340 | **No form in the app is script-required.** A test fetches every page |
| 341 | script-stripped and diffs for every affordance. |
| 342 | |
| 343 | --- |
| 344 | |
| 345 | ## 7. Collaboration |
| 346 | |
| 347 | Cell-level last-write-wins over SSE, plus presence. |
| 348 | |
| 349 | **Derived cells carry the actor of the edit that caused them**, not the |
| 350 | owner of the formula, and a `derived` flag so version history can show "your |
| 351 | edit changed 400 cells" rather than attributing authorship of a formula's |
| 352 | output to whoever typed a number. |
| 353 | |
| 354 | **Every SSE message carries the cell's version**, and clients discard |
| 355 | out-of-order paints by version rather than by arrival. Without this, |
| 356 | optimistic painting plus LWW produces visibly wrong grids under two |
| 357 | concurrent 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. |
| 361 | The test verifies an unbuffered stream through the real edge β a check |
| 362 | written against 101 would never pass. *(The source doc conflates these; |
| 363 | the conflation stops here.)* |
| 364 | |
| 365 | **Idle policy, written down rather than emergent.** Connections drop after a |
| 366 | defined idle period with no edits and no presence heartbeat. **Heartbeats |
| 367 | stop on a hidden tab and have a ceiling**, so a tab left open all weekend |
| 368 | cannot hold an instance awake and billable indefinitely. Held sockets and |
| 369 | hibernation 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 |
| 372 | input event, applied as a fresh LWW write β so if someone has since written |
| 373 | over that cell, your undo takes it back and is itself undoable. Multi-user |
| 374 | undo has no correct answer; this one is predictable and explainable, which |
| 375 | is 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. |
| 385 | Verified against source: `passkey.New` sets `RPID` from `Config.Origin` β |
| 386 | *the app's own origin*. So the obvious wiring enrolls a credential per |
| 387 | instance, and a person in three teams enrolls three passkeys. That is |
| 388 | precisely 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 |
| 392 | enroll 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 β |
| 409 | stated positively because a reviewer will otherwise propose the opposite as |
| 410 | an improvement). |
| 411 | |
| 412 | A guest's assertion is the same shape as an owner's. The assertion says *who |
| 413 | you are*; the instance decides *what you may do* from its own `grants` |
| 414 | table. Narrowing a comment-only guest's assertion is tempting and belongs on |
| 415 | the wrong side of the boundary: encoding capability requires the home to |
| 416 | know capability, which puts an authorization model inside the identity |
| 417 | origin and cuts against Β§9's design, where the home knows you have access to |
| 418 | a team in an app and nothing else. |
| 419 | |
| 420 | The security argument does not survive either β a leaked assertion buys |
| 421 | exactly the grants of the person it names, which is what a stolen session |
| 422 | buys anyway. Narrowing reduces no blast radius while adding a second |
| 423 | assertion type, its key handling, and a second audience-binding surface. |
| 424 | |
| 425 | **Assertion expiry is seconds, not minutes.** It is exchanged for a session |
| 426 | immediately; the long-lived thing is the session, which is `sessions`' |
| 427 | problem 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 |
| 431 | auth. One RP at the home is the right answer for Sheets now, and it is |
| 432 | explicitly *not* a bet against per-instance auth later β it is a bet that we |
| 433 | do not need it yet. |
| 434 | |
| 435 | What would make us revisit: sign-in availability becoming a real complaint |
| 436 | rather than a theoretical one; a team needing to authenticate against its |
| 437 | own instance under its own domain for a compliance reason; or CARLOS |
| 438 | shipping per-instance auth with an answer to the N-credentials problem. Any |
| 439 | of those, and Β§8.1 is reopened with this paragraph as the reason it was |
| 440 | always going to be. |
| 441 | |
| 442 | **Named consequence:** the credential origin is an authority, and a single |
| 443 | point 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 |
| 445 | a hint, and the README says so. Rejected alternative: per-instance passkeys, |
| 446 | which keeps sign-in working when the home is down at the cost of N |
| 447 | credentials per person and an RP that breaks whenever a team attaches a |
| 448 | custom domain. |
| 449 | |
| 450 | ### 8.2 Roles |
| 451 | |
| 452 | Team: owner, member, guest. Per sheet: owner, editor, commenter, viewer. A |
| 453 | guest holds no team-wide rights β they see exactly the sheets granted to |
| 454 | them 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 |
| 457 | shape, and from `$SHEETS_OWNERS` and the local member rows in lone shape. |
| 458 | **Per-sheet roles stay here.** The doctrine deliberately does not unify |
| 459 | them: a shared role vocabulary designed from three specs at once would fit |
| 460 | none of them, and it may move to the contract module when two apps have |
| 461 | shipped the same one (suite Β§5.6). Sheets has `commenter`; calendar has no |
| 462 | such role and Docs has none yet, so that moment has not arrived. |
| 463 | |
| 464 | This is Pegamento's *"sharing is membership"* taken literally: the sheet |
| 465 | never moves, and the outside person becomes a scoped member of the instance |
| 466 | that 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 |
| 471 | crossing a *team* boundary (suite ruling 2), and a link does not move data β |
| 472 | whoever holds it reads the sheet here. This sentence exists because the rule |
| 473 | reads like a prohibition and someone will later think it was one. (docs-e6, |
| 474 | 2026-08-30.) |
| 475 | |
| 476 | **A link is not a rung on the sharing ladder** (suite ruling 15). A sheet is |
| 477 | private, granted to named people, or shared with the team, and only the team |
| 478 | rung is team-visible β so a sheet that is link-shared but not team-shared is |
| 479 | one 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 |
| 481 | memoria'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 |
| 484 | ruling 15's read-only half. The doctrine cites it; it stays here as the |
| 485 | original, and the reason it gives is the reason the suite took it. |
| 486 | |
| 487 | Off by default, high-entropy, revocable, **viewer-or-commenter only, never |
| 488 | editor** (ruling 12) β every version row carries an actor, and an anonymous |
| 489 | editor would write unattributed rows into version history. |
| 490 | |
| 491 | **Link tokens are stored hashed**, per Pegamento's service contract. Stored |
| 492 | plaintext, the replicated database becomes a bundle of live read |
| 493 | capabilities to sealed sheets β Β§0 defeated through a side door for the |
| 494 | second time (Β§2.3 is the first). |
| 495 | |
| 496 | **A view link mints nothing** β read-only against a capability token; no |
| 497 | member, no directory entry. The moment someone wants to *write* β comment β |
| 498 | they sign in, which mints a guest member and produces a directory entry |
| 499 | then. **A link viewer gets no presence cursor and no name in the presence |
| 500 | stack**, because presence is a write to shared awareness state (docs-e6's |
| 501 | corollary, 2026-08-30). |
| 502 | |
| 503 | The line, and it is the same in Docs: **anything that writes is attributed, |
| 504 | and attribution is what mints membership.** |
| 505 | |
| 506 | --- |
| 507 | |
| 508 | ## 9. The home, as a dependency |
| 509 | |
| 510 | The home is a **separate repo and spec** (ruling 10), drafted by this |
| 511 | session with Docs as a declared consumer. This section records only what |
| 512 | Sheets 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 |
| 517 | suite contract as `oficina/suite` states it.** Neither is copied here. |
| 518 | |
| 519 | This section used to enumerate five calls. It had already caught itself |
| 520 | drifting once β *"the first draft called this the four-call contract and |
| 521 | then admitted a fifth in a subsection; an implementer reading the heading |
| 522 | builds four"* β and the same thing happened again from the other side: the |
| 523 | home now has six calls, with a seventh and eighth designed, and nothing made |
| 524 | this copy notice. The note was right about the mechanism and could not fix |
| 525 | it, because the fix is not to count more carefully. It is to stop keeping a |
| 526 | second copy (suite Β§10). |
| 527 | |
| 528 | What 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 | |
| 539 | Instanceβhome auth follows Pegamento's service contract: per-consumer |
| 540 | bearer token stored hashed, consumer identity is its origin, origins are an |
| 541 | allowlist. |
| 542 | |
| 543 | **Bootstrap, which the first draft left unstated:** a freshly provisioned |
| 544 | instance receives its home bearer token as platform config and is added to |
| 545 | the home's origin allowlist by the operator drain (Β§9.4), in the same step |
| 546 | that 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 | |
| 551 | The blueprint's position on glue is that losing it **costs a convenience, |
| 552 | never 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.** |
| 563 | A hibernating instance runs no timers, so a `time.Ticker` retry loop is a |
| 564 | retry loop that dies at hibernation. For a quiet team a failed upsert may |
| 565 | wait weeks for an organic wake, which is acceptable precisely because the |
| 566 | directory is a hint. |
| 567 | |
| 568 | Note this ruling does **not** extend to sign-in (Β§8.1), which is an |
| 569 | authority by design and says so. |
| 570 | |
| 571 | ### 9.2 Split origins β RULED 2026-08-30 |
| 572 | |
| 573 | A team name is account-authored, so a home rendering team names on its |
| 574 | passkey RP origin is what `decisions.md` warns against. The answer is |
| 575 | Tito's: *credentials only ever on the root origin, account-authored content |
| 576 | only 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 | |
| 583 | The directory exchanges a signed assertion from the credential origin for |
| 584 | its 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 |
| 587 | needs a client-held key; our key source is passkey PRF; passkeys are |
| 588 | optional (ruling 13). It would force passkeys on everyone or show unnamed |
| 589 | rows to the least technical users. It becomes correct when Pegamento's vault |
| 590 | facet 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 |
| 595 | editor** (ruling 14). Not per document, not per team. |
| 596 | |
| 597 | It therefore lives with the home as **suite-level account state**, or a |
| 598 | person sets it separately in every team they belong to β the same shape as |
| 599 | the credentials problem in Β§8.1. |
| 600 | |
| 601 | **It must resolve before first paint.** For a grid this is sharper than for |
| 602 | plain chrome: cell fills resolve against the theme (Β§10.2), so a |
| 603 | late-arriving preference does not merely flip the chrome, it **repaints |
| 604 | every coloured cell in the sheet**. The preference must therefore be |
| 605 | readable synchronously at render β a cookie on the instance's own origin, |
| 606 | written from the assertion at session mint and refreshed when the person |
| 607 | changes it β never a fetch, and never a value the first paint has to wait on. |
| 608 | |
| 609 | The in-editor toggle writes through to the home and updates the local cookie |
| 610 | optimistically, so the switch is instant and the preference follows the |
| 611 | person to their next team. |
| 612 | |
| 613 | ### 9.4 Provisioning, and a recorded divergence from Pegamento |
| 614 | |
| 615 | Signup writes a provisioning request; an operator command drains it under a |
| 616 | real console credential, creates instances, delivers the home bearer token |
| 617 | and allowlist entry (Β§9.1), and sends the welcome mail. Team ready in |
| 618 | minutes, and the copy says so rather than faking a spinner. |
| 619 | |
| 620 | **Recorded divergence.** Pegamento doctrine Β§9 is explicit that provisioning |
| 621 | and instance lifecycle are *not* Pegamento, and flags Eleven's home β a |
| 622 | blind vault *and* a graph-aware provisioning broker in one binary β as "the |
| 623 | one place blindness breaks", adding "Pegamento does not repeat that |
| 624 | conflation." Our home carries sign-in, directory *and* provisioning enqueue. |
| 625 | |
| 626 | That is a divergence and the first draft claimed doctrinal compliance it did |
| 627 | not have. **The divergence is deliberate and bounded:** call 4 is an opaque |
| 628 | enqueue with no directory read and no graph access; the queue lives in its |
| 629 | own store; and the drain runs outside the home process entirely, under a |
| 630 | human's credential. The conflation the doctrine warns about is a *blind* |
| 631 | service that also knows personβinstance β ours knows personβinstance in the |
| 632 | directory already (Β§9.5 discloses it), so the enqueue adds no knowledge the |
| 633 | service lacks. |
| 634 | |
| 635 | **Revisit trigger:** the home spec is the right place to decide whether the |
| 636 | queue moves to a separate binary. Recorded here so the home spec inherits an |
| 637 | argued position rather than an accident. |
| 638 | |
| 639 | ### 9.5 A guest grant discloses the granting team's name |
| 640 | |
| 641 | Sharing one sheet with an outside guest puts a row in their directory |
| 642 | carrying **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 |
| 646 | directory exists. What is wrong is an emergent property nobody decided. The |
| 647 | share 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 | |
| 652 | The directory is keyed on `HMAC(pepper, lowercased email)`. |
| 653 | |
| 654 | **Email change.** The person's HMAC changes and their rows orphan. Call 5 is |
| 655 | a cursor-based rekey journal that instances pull, carrying `old_hmac β |
| 656 | new_hmac`. The home learns both addresses transiently and stores neither. |
| 657 | |
| 658 | **Resolved by the home spec, 2026-08-30: instances hold readable addresses; |
| 659 | the home never does.** The sharer typed the address, so the team already |
| 660 | knows it β storing it in that team's instance leaks nothing the team lacks, |
| 661 | whereas the home holding every address is the aggregation risk. It also |
| 662 | keeps share notification possible, since nobody can email a hash. |
| 663 | |
| 664 | **The journal keys on person refs, not hashes.** An instance holds no hashes |
| 665 | and can compute none, so a hash-keyed journal would have matched nothing it |
| 666 | had. The ref arrives in call 2's response and is also the assertion's `sub`, |
| 667 | so one identifier links assertion to grant to journal. Rows carry the sealed |
| 668 | new address alongside it, scoped per consumer, with a per-consumer retention |
| 669 | TTL rather than a slowest-cursor rule. |
| 670 | |
| 671 | **The pepper is a suite-wide secret with per-team blast radius, and that is |
| 672 | the wrong shape.** Call 2 has the *instance* compute the HMAC, so every |
| 673 | instance holds the pepper β and one compromised instance enables offline |
| 674 | dictionary attacks against the entire suite directory. That inverts factor |
| 675 | VIII, where the point of instance-per-team is that a compromise is bounded |
| 676 | to one team. |
| 677 | |
| 678 | **Ruled: instances never hold the pepper.** Call 2 sends the address over |
| 679 | the authenticated channel and **the home hashes it**, learning it |
| 680 | transiently exactly as it already does at sign-in (Β§9.1). This costs the |
| 681 | home a transient plaintext address it already handles, and removes a |
| 682 | suite-wide secret from every instance on the estate. |
| 683 | |
| 684 | **Pepper rotation** then becomes the home's business alone. Under Β§9.1.1 a |
| 685 | rotation is a convenience outage rather than data loss: access is |
| 686 | unaffected, and the directory refills as instances re-upsert. Rotation |
| 687 | requires a **re-announce sweep**, not merely outbox retry β retry |
| 688 | redelivers *failed* upserts and does not re-enumerate every existing grant. |
| 689 | That sweep is a named operator verb, not an emergent behaviour. |
| 690 | |
| 691 | ### 9.7 The RP rename drill β scheduled, then made unnecessary |
| 692 | |
| 693 | Ruling 11 put the RP on a platform-default host provisionally, which |
| 694 | **scheduled** this rename rather than avoiding it. Ruling 18 then ran it |
| 695 | early, 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 |
| 701 | on Carloku, with `oficina.works` as a custom domain in front of it. |
| 702 | |
| 703 | This replaces the provisional `<app>.<sqid>.<apps-domain>` on the hosted |
| 704 | apps-domain `oncarlos.com` under account sqid **`bds`** |
| 705 | (`console.carloku.com/a/bds/`). `bds` was a hosting artifact and does not |
| 706 | survive the move. Earlier drafts naming `carloku.com` β the console brand β |
| 707 | were 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` |
| 711 | existed in any app in the suite β sign-in was magic-link throughout β so no |
| 712 | credential had ever been scoped to an `oncarlos.com` host. The window below |
| 713 | is sized for the *next* move, not this one, and its cost is the argument for |
| 714 | having moved before passkeys shipped rather than after. |
| 715 | |
| 716 | **Never widen the RP ID to a registrable suffix.** WebAuthn permits an RP ID |
| 717 | that 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 |
| 719 | tenants on a shared platform domain; on a zone we own it is that such an RP |
| 720 | ID is valid at every host in the zone β this instance included β so the |
| 721 | credential the home holds would be presentable straight to a Sheets |
| 722 | instance, which is the separation ruling 15 exists to keep. The RP ID is the |
| 723 | full host, always. |
| 724 | |
| 725 | **But the mechanism already exists.** `passkey.Config.LegacyRPID` β |
| 726 | verified at source β is *"a relying party this instance used to be, accepted |
| 727 | for assertions (never registration) so a hostname move doesn't strand |
| 728 | enrolled keys."* That is the accepted-for-assertion-never-registration |
| 729 | crossover this spec described as something to invent. (Found by docs-e6, |
| 730 | 2026-08-30, verified by this session.) |
| 731 | |
| 732 | So the drill is: set `LegacyRPID` to the old host, run the window, drop it. |
| 733 | Not a multi-phase invention. |
| 734 | |
| 735 | **The window is still sized by the guest tail, not by active members.** The |
| 736 | population least likely to survive is the third-party guest who signed in |
| 737 | once, months ago, to comment on one sheet β no ongoing relationship, no |
| 738 | reason to be paying attention, invisible to any plan that assumes people |
| 739 | re-authenticate when nudged. The window is measured in months. **A drill |
| 740 | that quietly drops the guests is a drill that failed while reporting |
| 741 | success.** `LegacyRPID` removes the machinery cost; it does not shorten the |
| 742 | window. |
| 743 | |
| 744 | **Trigger:** a real suite domain exists. |
| 745 | |
| 746 | --- |
| 747 | |
| 748 | ## 9a. Consumed suite rulings |
| 749 | |
| 750 | The 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 | |
| 767 | Sheets serves **S1, S2, S3 and S6** wherever sibling consumer tokens are in |
| 768 | its platform config. S6 is Sheets' alone: an enumerated aggregate over cell |
| 769 | refs, capped, never expression text, so memoria can total a column without |
| 770 | holding the sheet. |
| 771 | |
| 772 | **Not yet consumed:** S8 write-as-self (suite Β§7.2) needs a designated-area |
| 773 | grant in the share UI, and the hand-off (suite Β§7.1) needs a receipt route |
| 774 | with pending state. Neither is specified here. |
| 775 | |
| 776 | **One thing the doctrine took from Sheets rather than the other way round.** |
| 777 | Ruling 12 β link sharing is viewer-or-commenter, never editor β is now suite |
| 778 | ruling 15's read-only half, and the reason Sheets gave for it is the reason |
| 779 | the suite took it: every version row carries an actor, and an anonymous |
| 780 | editor writes unattributed rows into history. A draft of the doctrine |
| 781 | proposed an anonymous link editor and had to be corrected against this |
| 782 | spec. |
| 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 |
| 793 | app.** 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 |
| 796 | still live. (It *does* carry attribute selectors for `[data-tone=]`, |
| 797 | `[aria-*]` and `[type=]` β an earlier wording said "zero attribute |
| 798 | selectors", which was imprecise and would not survive being checked. The |
| 799 | conclusion is unchanged.) The attribute grammar is "RULED, **not yet |
| 800 | started**", and its staged path is explicitly *ship a `tokens.css` matching |
| 801 | both spellings β codemod β drop the class selectors.* |
| 802 | |
| 803 | So: **write `class="rst-*"` today.** When the dual-grammar release ships, |
| 804 | run the codemod. Writing `rst-btn="primary compact"` now renders unstyled |
| 805 | buttons against a frozen `static/tokens.css`, which is the worst of both |
| 806 | spellings. |
| 807 | |
| 808 | This session over-applied advice from the design-system session. Their |
| 809 | advice β don't invest in the losing spelling β was about *investment*, not |
| 810 | about writing markup no shipped stylesheet matches. |
| 811 | |
| 812 | **`tokens.css` is frozen into `static/` at scaffold time while partials |
| 813 | upgrade with the module.** Re-copy deliberately on every rastrillo upgrade. |
| 814 | `rastrillo doctor` was proposed to make this mechanical, with this app as |
| 815 | its 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 |
| 818 | this spec takes from it is either *shipped* (verified in `ui` at |
| 819 | `origin/main`) or *staged* (ruled in rastrillo's spec, not in released |
| 820 | code). Shipped: `ui.TokensCSS()`, the class vocabulary, the three themes. |
| 821 | Staged, and therefore unavailable to us today: the bare-attribute grammar, |
| 822 | `--rst-header-rule`, `Pair`, `Allocate`, `rastrillo doctor`. Neither of the |
| 823 | two consuming sessions could tell a ruling from a released API by reading |
| 824 | the design-system spec, which is why this paragraph exists rather than a |
| 825 | reference 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 |
| 829 | allocation entry point exist in this spec and in cross-session messages, and |
| 830 | in **zero files in the rastrillo repo**. Cross-session agreements that live |
| 831 | only downstream are agreements the upstream can silently never ship. **Task: |
| 832 | get each recorded upstream as an issue or spec section; until then treat all |
| 833 | three 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 | |
| 844 | The 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 |
| 848 | variant depends on `ui`, keeps its bones β tokens, the partial vocabulary, |
| 849 | the accessibility floors β and adds components, themes and its own gallery. |
| 850 | Not 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 |
| 852 | own vocabulary; **read it before anyone writes a second one.** |
| 853 | |
| 854 | **The distinction that produced this, worth keeping** (ui-guidelines-52, |
| 855 | 2026-08-30): *two callers at design time is the bar for **extraction**, and |
| 856 | it says nothing about **destination**.* Both consuming sessions were told |
| 857 | "propose it to the library" and applied the right bar to the wrong question, |
| 858 | because the first answer looked like it settled the second. It will recur |
| 859 | for every piece either app hands upward. |
| 860 | |
| 861 | **Primitives split with Docs:** we own **toolbar groups**; Docs owns |
| 862 | inspector panel, presence stack and **comment thread** (their anchoring case |
| 863 | is harder, so it owns the design and we consume it β a cross-repo dependency |
| 864 | inside slice one, noted in Β§13's risk). Same standards either way: twelve |
| 865 | locales, the script-required line stated per primitive, 24Γ24 targets, 320px |
| 866 | reflow, colour never carrying meaning alone. |
| 867 | |
| 868 | **The colour engine is unaffected and stays in `ui`.** `Pair` and allocation |
| 869 | are contrast arithmetic against floors the framework already gates β a |
| 870 | function, not a component β so our cell fills and Docs' highlights are |
| 871 | callers of the same thing regardless of where their components live. |
| 872 | |
| 873 | ### 10.3 Cell fill colour |
| 874 | |
| 875 | Stored 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 |
| 877 | types in it. Imported hex is retained alongside for lossless XLSX |
| 878 | round-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 |
| 882 | only mean "resolve against the theme's surface token", which is wrong |
| 883 | whenever the cell sits on something else β conditional formatting paints |
| 884 | under a user fill, selection tints, frozen headers. Passing the real |
| 885 | background is what makes our contrast gate assert the pair the cell actually |
| 886 | shows, rather than certifying the wrong thing while passing. |
| 887 | |
| 888 | **Allocation is a second entry point**, a pure function of a **canonically |
| 889 | ordered** key set (member id), never of arrival order β otherwise two |
| 890 | clients viewing the same sheet allocate differently and the same person is |
| 891 | green for one viewer and blue for another. The `avoid` set is a parameter, |
| 892 | so collisions occur only past capacity. Sheets is a caller for **presence |
| 893 | cursors** (Β§7). Stability of a person's colour is best-effort; separation is |
| 894 | the hard guarantee. |
| 895 | |
| 896 | **Until `ui` ships these: a deliberately crippled shim.** A hardcoded table |
| 897 | of six to eight intents with hand-computed pairs, behind the corrected |
| 898 | signature, gated by a contrast test that **copies rastrillo's arithmetic |
| 899 | verbatim** β not merely models it, or the gates diverge by a rounding |
| 900 | decision and swapping the table changes the test's meaning silently. Header |
| 901 | comment names it a placeholder and links Β§10.1's upstream-recording task as |
| 902 | its 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 |
| 910 | is the WG's canonical example and a spreadsheet is that case at its most |
| 911 | literal. So the grid scrolls in both axes at 320px, and **every other |
| 912 | surface must reflow or it is a bug, not an exemption** β falsifiable, which |
| 913 | is why it holds. |
| 914 | |
| 915 | Target size 24Γ24 throughout, including a 24px hit area around the fill |
| 916 | handle's small visual. **Every drag-only affordance gets a menu |
| 917 | equivalent**, which buys the scriptless path at the same time. We do not |
| 918 | lean 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 |
| 925 | offered intent while retaining the original hex (Β§10.3); export resolves |
| 926 | intent to concrete hex. |
| 927 | |
| 928 | A **golden round-trip corpus** is a gate: import β export β import must be |
| 929 | stable, and our output must open cleanly in Excel and LibreOffice. |
| 930 | |
| 931 | Import runs as a rastrillo background job, 303 to `/jobs/{id}`, with |
| 932 | `StatusPage`/`Fragment`/`Events` mounted behind `sessions.Require`. The |
| 933 | status page works scriptless. Jobs are idempotent β a deploy ends them |
| 934 | mid-flight β and `location` is a server-built path, never user input. **Job |
| 935 | error text carries coordinates and codes, never content (Β§2.4).** |
| 936 | |
| 937 | Uploaded 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 |
| 941 | round-trip corpus predated this by two days; what did not exist was any |
| 942 | way to reach them β no route, no upload, no job, and a `PRODUCT.md` that |
| 943 | listed import among the things "built and working". `POST /sheets/import` |
| 944 | now 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. |
| 946 | Three 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 |
| 967 | input and submits the same form, so there is one route and one code |
| 968 | path, and Β§6's scriptless floor holds without a second implementation to |
| 969 | keep in step with the first. |
| 970 | |
| 971 | ### 11.1 A retained fill can be unreadable, and that is correct |
| 972 | |
| 973 | Retaining an imported fill's original hex (Β§10.3) means we also carry the |
| 974 | author's font colour, and the pair may fail WCAG AA. This is not |
| 975 | hypothetical: Excel's own light-yellow conditional-formatting preset ships |
| 976 | `#9C6500` on `#FFEB9C` β **4.12:1, below the 4.5:1 floor** β confirmed |
| 977 | against an independent source, so it is what the incumbent *offers by |
| 978 | default*, not merely what a careless user can construct. |
| 979 | |
| 980 | **We render it as authored.** Faithfully displaying a document somebody else |
| 981 | wrote is a different act from generating a colour, and silently correcting |
| 982 | imported formatting would lie about the file β the same class of wrong as |
| 983 | inventing 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* |
| 987 | one, when the file said so. Anyone reading the first claim as the second |
| 988 | will eventually find a failing cell and file a bug against a guarantee that |
| 989 | is working exactly as specified. |
| 990 | |
| 991 | **The obligation that follows is surfacing, not overriding** β telling a |
| 992 | person their imported file contains a pairing that fails, and leaving the |
| 993 | decision 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 |
| 996 | authored *ink*. `readStyle` retained fills and never font colours, so the |
| 997 | pair Β§11.1 is about could not exist in our data at all β a fill alone |
| 998 | cannot fail, since the other half was always our own theme's. `Format` |
| 999 | gained `InkHex`, XLSX import and export carry it, and the grid and table |
| 1000 | views 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 |
| 1003 | engine, from Excel's documented preset fill/ink pairs. Related: `#0000FF` |
| 1004 | carries black ink at 2.44:1 and cannot be a wash at all β the case where a |
| 1005 | user's chosen hue and their ink genuinely conflict.)* |
| 1006 | |
| 1007 | --- |
| 1008 | |
| 1009 | ## 12. Testing gates |
| 1010 | |
| 1011 | From the first commit: |
| 1012 | |
| 1013 | 1. **Plaintext grep** over the raw SQLite file and WAL, plus every object |
| 1014 | this app writes to its own store (Β§2.6). |
| 1015 | 2. **Two-user isolation suite**, including the grants-join 404-not-403 |
| 1016 | contract (Β§4.5). |
| 1017 | 3. **Golden XLSX round-trip corpus.** |
| 1018 | 4. **Formula golden vectors** from LibreOffice headless. |
| 1019 | 5. **Contrast test** copying `ui/contrast_test.go`'s arithmetic verbatim. |
| 1020 | 6. **Scriptless-affordance diff.** |
| 1021 | 7. **Browser drives in CI.** A JS syntax error takes the whole client down |
| 1022 | and only a real engine sees it. |
| 1023 | 8. **Module-size cap** on JS, ratchet-down only. The vendored Jspreadsheet |
| 1024 | blob is exempt and named; nothing we write is. |
| 1025 | 9. **No-content-in-logs** β scan log lines, error strings and job messages |
| 1026 | for cell content (Β§2.4). |
| 1027 | 10. **Cache coherence** β assert no cell serves a cached value whose |
| 1028 | `cache_version` predates its input's version (Β§4.4). |
| 1029 | 11. **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, |
| 1034 | 2026-08-30). Gate 4 proves the Go engine matches LibreOffice. Gate 7 proves |
| 1035 | the client runs. Neither proves that what a person *sees* is what the engine |
| 1036 | computed β and a gate that compares two of our own layers against each other |
| 1037 | can stay green forever while the product is wrong. Docs shipped exactly that |
| 1038 | bug: a conformance test comparing two runtimes of the same module, proving |
| 1039 | determinism, which WASM guarantees anyway, and never proving the thing the |
| 1040 | rule was about. |
| 1041 | |
| 1042 | The rule to keep: **at least one gate must cross from computed value to |
| 1043 | painted output.** A suite of gates that all live inside one layer is a suite |
| 1044 | that agrees with itself. |
| 1045 | |
| 1046 | Review 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 |
| 1052 | available before it is approved or merged.** Cheaper models draft; the most |
| 1053 | capable available reviews. A review by a weaker model than the author is not |
| 1054 | a review. |
| 1055 | |
| 1056 | Specs get a pass before human approval and again after any material change |
| 1057 | to a settled decision. Branches get one before the PR opens and again after |
| 1058 | any force-push bigger than a rebase. **The reviewing model is recorded** β |
| 1059 | in this spec's status line, and in the branch discussion for a branch, which |
| 1060 | survives a squash where a PR body does not. A review nobody can attribute is |
| 1061 | a review nobody can check. |
| 1062 | |
| 1063 | The highest-capability model is **discovered, not hardcoded**. A session |
| 1064 | that cannot reach a model at least as capable as the author's says so and |
| 1065 | waits. |
| 1066 | |
| 1067 | --- |
| 1068 | |
| 1069 | ## 13. Sequencing |
| 1070 | |
| 1071 | 1. Skeleton, `AGENTS.md`, auth |
| 1072 | 2. Storage and sealing (gates 1, 9, 10 live from here) |
| 1073 | 3. Formula engine |
| 1074 | 4. Grid |
| 1075 | 5. XLSX / CSV |
| 1076 | 6. Sharing, and the home contract |
| 1077 | 7. Live sync |
| 1078 | 8. Version history |
| 1079 | 9. Sort, filter, conditional formatting |
| 1080 | 10. Comments |
| 1081 | 11. **Charts** |
| 1082 | |
| 1083 | **Scope honesty, from the review β narrowing confirmed by Paul, |
| 1084 | 2026-08-30 (ruling 17).** Ruling 5 stands; what it costs: **three of the eleven items are subsystems, not features** β charts, |
| 1085 | version history (restore semantics, replay, retention), and conditional |
| 1086 | formatting (a second expression evaluator plus a rules UI). Naming charts as |
| 1087 | the sole slip candidate understated the risk. |
| 1088 | |
| 1089 | So the slip order is written down rather than discovered: **charts first, |
| 1090 | conditional formatting second.** |
| 1091 | |
| 1092 | **The criterion is "its absence leaves a coherent product", not "it has no |
| 1093 | dependents"** (docs-e6, 2026-08-30). The two usually agree, and where they |
| 1094 | disagree the first is the one that matters. Charts passes on the stronger |
| 1095 | reading: a spreadsheet without charts is still a spreadsheet. Conditional |
| 1096 | formatting is second because a spreadsheet without it is also still a |
| 1097 | spreadsheet, merely a duller one β whereas a spreadsheet without version |
| 1098 | history is one nobody trusts with real data, which is why history is not on |
| 1099 | this list at all. Version history is not a slip candidate |
| 1100 | because Β§4.3's log design is load-bearing for live sync β but **its minimal |
| 1101 | form is named now**: view history and restore a *cell* or a *range*. |
| 1102 | Restore-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' |
| 1107 | comment-thread primitive (Β§10.2). If Docs slips, we ship comments with a |
| 1108 | cell-anchored local implementation and adopt theirs later β decided now so |
| 1109 | it 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 |
| 1157 | amadan remote, so this is a GitHub issue rather than an `amadan discuss`, |
| 1158 | and it needs Paul's go-ahead. Drafted jointly with docs-e6; both consumer |
| 1159 | paragraphs are below. |
| 1160 | |
| 1161 | **Title: An instance needs to verify a signed identity assertion** |
| 1162 | |
| 1163 | Instance-per-account makes single sign-in a day-one problem β `decisions.md` |
| 1164 | says it directly: *"three instances must not mean three credentials."* Two |
| 1165 | apps have now independently arrived at the same answer, which is why this is |
| 1166 | a framework ask rather than an app one. |
| 1167 | |
| 1168 | The shape both reached: **one identity origin holds the credentials and runs |
| 1169 | the ceremony; instances receive identity as a signed assertion and verify a |
| 1170 | signature.** Instances never run WebAuthn, never hold a passkey RP, never |
| 1171 | see a password. |
| 1172 | |
| 1173 | This is Tito's recorded pattern: *identity lives beside the router, outside |
| 1174 | the per-account blast radius, and reaches instances only as **signed** |
| 1175 | assertions β headers are stripped-and-re-minted, and signing (not stripping |
| 1176 | alone) is what stops a peer socket from forging them.* |
| 1177 | |
| 1178 | **What rastrillo has:** `sessions`, `auth`, `password`, `passkey` β every |
| 1179 | one authenticating *at* the app. **What is missing:** anything letting an |
| 1180 | app say "I trust assertions minted by this identity origin; verify this one |
| 1181 | and mint a session from it." |
| 1182 | |
| 1183 | **Why the framework rather than two apps.** The parenthetical in Tito's rule |
| 1184 | is the point β *signing, not stripping alone* β and the failure modes do not |
| 1185 | announce 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 | |
| 1197 | That is squarely "supplies what is hard to get right twice", which is how |
| 1198 | rastrillo describes its own scope β and it is literally twice: two apps, |
| 1199 | same week, same pattern, neither having seen the other's code. |
| 1200 | |
| 1201 | **Rough shape**, not a design: verify against pinned or fetched public keys, |
| 1202 | check audience against this instance's own identity, enforce single-use and |
| 1203 | expiry, return a Subject plus claims, hand off to `sessions.SignIn` β which |
| 1204 | is the whole contract the existing identity plugins have with the core. A |
| 1205 | fourth plugin, not a new seam. |
| 1206 | |
| 1207 | **One thing to state positively, because a reviewer will otherwise propose |
| 1208 | it as an improvement: assertions carry identity, never authorization.** A |
| 1209 | guest's assertion is the same shape as an owner's; the instance decides |
| 1210 | capability from its own grants. Encoding capability would require the |
| 1211 | identity origin to hold an authorization model, and a leaked assertion buys |
| 1212 | exactly what a stolen session buys anyway β so narrowing reduces no blast |
| 1213 | radius while adding a second assertion type and a second audience-binding |
| 1214 | surface. |
| 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 | |