| index 843c7be..9655e25 100644 |
| --- a/docs/superpowers/specs/2026-08-30-sheets-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-sheets-design.md |
| @@ -1,11 +1,16 @@ |
| # 🤖 Sheets — design |
| |
| -**Status:** design, 2026-08-30. Approved by Paul in brainstorming on |
| -2026-08-30; every ruling below is dated and attributed, and the rejected |
| -alternative is recorded beside it. |
| -**Scope:** the Sheets instance app — a CARLOS spreadsheet, one instance per |
| -team. The Suite home is a **separate spec in a separate repo** (see §9); |
| -this document depends on it and does not define it. |
| +**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. |
| |
| --- |
| |
| @@ -14,12 +19,10 @@ this document depends on it and does not define it. |
| > **A sheet's contents are sealed at rest. The bucket never holds a |
| > plaintext cell.** |
| |
| -Everything below derives from it. 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 object storage, and a test greps the raw bytes for exactly that from |
| -the first commit. |
| +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. |
| |
| @@ -34,14 +37,16 @@ the first commit. |
| | 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**: no commercial/platform chrome, no dense undifferentiated toolbar, no deep menu bar, no template-carousel home | Keeping any of them | Paul, 2026-08-30 | |
| -| 7 | **`day` theme** | `signal` (recommended by this session on the strength of its stated refusal matching the jank brief); `plain` | Paul, 2026-08-30 | |
| -| 8 | **Cell fill stored as OKLCH intent; imported hex retained for lossless round-trip** | Intent only (lossy XLSX round-trip); absolute hex (breaks in dark mode) | 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**; this session drafts it, Docs reviews as declared consumer | Keeping it in this spec; Docs drafting it | Paul, 2026-08-30 | |
| -| 11 | **Passkey RP is a carloku.com subdomain, provisionally** — this SCHEDULES the rename drill (§9.5) rather than avoiding it | A real suite domain now; no passkeys at all | Paul, 2026-08-30 | |
| -| 12 | **Link sharing is viewer-or-commenter, never editor** | Anonymous link editing (what Google does) | Paul, 2026-08-30 | |
| -| 13 | **Passkey 2FA optional per person, owner-enforceable per team** | Mandatory for everyone; none at all | 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 a carloku.com subdomain, provisionally** | 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) | This session, 2026-08-30 — **needs Paul's confirmation** | |
| |
| --- |
| |
| @@ -49,221 +54,376 @@ the first commit. |
| |
| 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, per blueprint.md's crypto section. |
| - |
| -**Sealed:** every cell's raw input; every cached computed value; sheet |
| -titles; tab names; comment text. |
| - |
| -**Plaintext, and published in the README rather than discovered:** |
| - |
| -- Cell coordinates — therefore the **shape and size** of every sheet. |
| -- Format records, **including fill colours**. |
| -- Timestamps, actor ids, and the full edit log's shape. |
| +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.1.2, which is deciding whether it must). |
| + 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. |
| +spreadsheets are, how often they edit and what colour they painted things. |
| You learn **no value in any cell**. |
| |
| -**Residual risk, stated plainly and never contradicted by the UI:** 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. Seapointish's |
| -README is the model for the wording. |
| +### 2.3 Blobs, and the metadata that escapes sealing |
| |
| -**The gate:** a test greps raw SQLite bytes *and* bucket objects for known |
| -plaintext, from the first commit. `plaintextExempt` starts empty. Any entry |
| -is dated, justified and listed in the README. |
| +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). |
| |
| -## 3. System shape |
| +Three properties of it that shape our usage, all verified against source: |
| |
| -One repo, `carlos/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). |
| +- **`ContentType` passes through as visible metadata.** We write |
| + `application/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.Size` is the **sealed** size. Any quota or progress figure shown to |
| + a person must account for that or it will be visibly wrong. |
| |
| -The home used to live here. It doesn't — see §9 and ruling 10. |
| +### 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. |
| |
| -## 4. Data model |
| +**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). |
| |
| -- `sheets` — id, title (sealed), created_by, timestamps, deleted_at. |
| -- `tabs` — id, sheet_id, name (sealed), position, frozen_rows, frozen_cols, |
| - column widths and row heights. |
| -- `cells` — PK `(tab_id, row, col)`, sparse. Raw input (sealed), cached |
| - computed value (sealed), format_id, version, updated_by, updated_at. |
| -- `formats` — **interned**. A column of 5,000 identically-styled cells is |
| - one row, not 5,000. |
| -- `events` — append-only. Does four jobs at once: SSE fan-out, version |
| - history, undo, audit trail. This is why ruling 4 pays. |
| -- `members`, `grants`, `comments`, `link_grants`. |
| +### 2.5 The residual risk, stated and never contradicted |
| |
| -Migrations are additive-only. New code over an old DB is always safe. |
| +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.** |
| |
| -Every query touching user-owned rows goes through `scope.Owned(g, uid)` — |
| -reads and writes, inside transactions too, scoping `tx` and never the outer |
| -handle. A row that isn't yours 404s, never 403s. Never bind a request body |
| -onto a GORM model: explicit `map[string]any` plus a `.Select` allowlist. |
| +### 2.6 The gate, honestly scoped |
| |
| -Read the viewer with `auth.From(r)` or `sessions.Current(r)`. **Never |
| -`sessions.UserID`** — it returns `(0, false)` for an email Subject, and |
| -dropping that `ok` scopes every query to uid 0. |
| +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. |
| |
| -### 4.1 The in-memory handle |
| +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. |
| |
| -Opening a sheet decrypts it once into a refcounted handle holding the |
| -dependency graph. Edits, sort, filter, search and export all read it; writes |
| -go through to the table and the log. Idle handles evict. |
| +--- |
| |
| -This is also the hibernation story, and the SSE idle policy lives here (§7), |
| -so a held socket cannot quietly keep a team's instance awake and billable. |
| +## 3. System shape |
| |
| -**Hard cap: 200,000 cells per tab**, with the reasoning in a comment beside |
| -the constant. Past it, an honest error rather than a slow death. Google's |
| -limit is 10M and essentially nobody reaches it. |
| +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.2 Rows and columns have stable identity |
| +## 4. Data model |
| + |
| +### 4.1 Tables |
| + |
| +- `sheets` — id, title (sealed), created_by, timestamps, deleted_at. |
| +- `tabs` — id, sheet_id, name (sealed), position, frozen_rows, 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), cache_version (§4.4), format_id, version, |
| + updated_by, updated_at. |
| +- `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`. |
| |
| -**A1-style coordinates are a view, not a key.** Rows and columns carry |
| -stable ids; `cells` is keyed by `(tab_id, row_id, col_id)` with position |
| -held on the row and column records. `B7` is derived at render time. |
| +Migrations are additive-only. New code over an old DB is always safe. |
| |
| -This surfaced from a question docs-e6 asked about comment orphaning |
| -(2026-08-30), but it is load-bearing for four features at once, which is why |
| -it is in the model rather than bolted onto one of them: |
| +### 4.2 Rows and columns have stable identity — and what that does NOT buy |
| |
| -- **Comments** stay attached to the cell they were written about when a row |
| - is inserted above them. |
| -- **Conditional formatting ranges** move correctly on insert and delete. |
| -- **Chart ranges** move correctly — already flagged in §13 as the hard part |
| - of charts, and this is most of the answer. |
| -- **Formula references** are stored against stable ids and rendered as A1, |
| - so insert and delete need no reference-rewriting pass. This is strictly |
| - simpler than what Excel does, not a divergence for its own sake. |
| +**A1-style coordinates are a view, not a key.** Positions live on the `rows` |
| +and `cols` records; `B7` is derived at render time. |
| |
| -**Deleting a row destroys its cells and orphans their comments.** Orphaned |
| -comments are not deleted — they surface in a per-sheet "detached comments" |
| -list with their last known coordinate and content. A comment is somebody's |
| -words about something; losing the anchor is not permission to lose the words. |
| +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. |
| |
| -So orphaning is a shared problem with Docs after all, arriving by a different |
| -route: their anchor is a position inside content that can vanish, ours is a |
| -stable identity whose content can be destroyed. Same obligation, same answer. |
| +**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, because logging them makes the log grow with |
| +the *fan-out* of every edit rather than with the number of edits (§4.5). |
| +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: an edit marks dependents dirty and topological recalc touches |
| -only those. Cycle detection returns a circular-reference error rather than |
| +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. It is 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, so borrowing it would couple our data |
| -model to theirs and be slow. |
| - |
| -**A float never touches an amount.** Money is integer cents throughout the |
| -storage and XLSX layers. The evaluator's own arithmetic is IEEE double |
| -because that is what a spreadsheet's semantics *are* — this is a deliberate, |
| -bounded exception and it stops at the storage boundary. |
| - |
| -**Golden vectors are generated from LibreOffice headless** — a corpus of |
| -expressions with expected values produced by a real spreadsheet, not by our |
| -reading of a spec. It is the only honest way to claim 120 functions. |
| - |
| -**Known cost of ruling 2:** Jspreadsheet's own formula engine is disabled, |
| -so every edit round-trips to the server for recalculation, painted |
| -optimistically. One engine, one truth, and XLSX export can never disagree |
| -with what is on screen. |
| +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. |
| + |
| +**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. It supplies selection, keyboard navigation, drag-fill, column |
| -resize and freeze on day one. |
| +build step. |
| |
| -**Script-required, stated plainly rather than fudged:** the grid editor, |
| -drag-fill, live sync. |
| +**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 |
| -HTML table view of any sheet with per-cell edit forms**. A sheet is always |
| -readable and always editable without script — just not fluently. |
| +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.** That is the test ui-guidelines-52 |
| -set — acceptable for a grid, unacceptable for a form — and this app meets it. |
| - |
| -A test fetches every page script-stripped and diffs for every affordance. |
| +**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. Each cell is already a discrete versioned write, |
| -so LWW falls out of the storage model; it is lossy only when two people type |
| -into the *same cell* within the same moment. Changes broadcast over SSE to |
| -everyone with the sheet open, plus presence (who is here, whose cursor is |
| -where). |
| +Cell-level last-write-wins over SSE, plus presence. |
| |
| -**The SSE idle policy is written down, not emergent:** a connection drops |
| -after a defined idle period with no edits and no presence heartbeat, and the |
| -client reconnects on activity. Held sockets and hibernation pull against each |
| -other; the policy is where that tension is resolved deliberately. |
| +**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. |
| |
| -Verify the SSE 101 through the real edge, never assume it. |
| +**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.)* |
| |
| -## 8. Identity and sharing |
| +**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. |
| |
| -`rastrillo/auth`: magic link that auto-upgrades to "Sign in with Keymail" |
| -when the address has a claimed inbox. Classification fails open, so every |
| -address has a working path — which matters more here than usual, because |
| -guests arrive from outside and we can assume nothing about their email. |
| -No passwords. |
| +**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. |
| |
| -**Second factor** via `Config.SecondFactor = passkey.Handlers.Gate`, with |
| -recovery codes from `RegenerateRecoveryCodes` behind `sessions.RequireFresh`. |
| -Optional per person; **the team owner can require it for the team.** Business |
| -spreadsheets hold payroll and commissions, and a team that wants the floor |
| -raised should not have to ask us. |
| +--- |
| + |
| +## 8. Identity and sharing |
| |
| -**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 that. |
| +### 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 `Gate` is 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.1` call 1 is therefore unambiguous:** the home mints the session, |
| + the instance trusts a signed assertion. The first draft left both plausible. |
| + |
| +**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. |
| |
| 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.1 Link sharing |
| +### 8.3 Link sharing |
| |
| **"Sharing is membership" does not forbid link sharing.** It forbids data |
| crossing an instance boundary, and a link does not move data. This sentence |
| -exists because the rule reads like a prohibition and someone will later think |
| -it was one. (Raised by docs-e6, 2026-08-30.) |
| +exists because the rule reads like a prohibition and someone will later |
| +think it was one. (docs-e6, 2026-08-30.) |
| |
| Off by default, high-entropy, revocable, **viewer-or-commenter only, never |
| -editor** (ruling 12). The reason is our own audit story: every version row |
| -carries an actor, so an anonymous editor would write unattributed rows into |
| -version history and quietly break the thing that makes version history |
| -trustworthy. |
| +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** — the instance serves 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 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.** |
| @@ -272,162 +432,194 @@ 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, so implementation is not blocked on that spec landing. |
| +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 four-call contract |
| +### 9.1 The five-call contract |
| |
| -1. Sign-in and session minting. |
| -2. Directory upsert/revoke — called by an instance when a grant changes, |
| - carrying `HMAC(pepper, lowercased email) → (app, team sqid, host)`. The |
| - `app` discriminator means a mixed list of sheets and docs was always the |
| - shape. |
| -3. Directory list for a signed-in person — every team across every suite app. |
| -4. Provisioning enqueue. |
| +1. **Sign-in and session minting** (§8.1 — the home is the identity |
| + authority, not merely a directory). |
| +2. **Directory upsert/revoke** — instance→home on grant change, carrying |
| + `HMAC(pepper, lowercased email) → (app, team sqid, host, kind)` where |
| + `kind` is member or guest. |
| +3. **Directory list** for a signed-in person — every team across every |
| + suite app. |
| +4. **Provisioning enqueue** (§9.4 — and see the doctrine divergence there). |
| +5. **Rekey journal pull** (§9.6). |
| |
| -Instance→home auth follows Pegamento's service contract even though we are |
| -not building Pegamento: per-consumer bearer token stored hashed, consumer |
| -identity is its origin, origins are an allowlist. |
| +*(The first draft called this "the four-call contract" and then admitted a |
| +fifth in a subsection. An implementer reading the heading builds four.)* |
| |
| -The home never receives a readable address. It learns one transiently at |
| -sign-in to compute the HMAC and never stores it — Kass's pattern, where the |
| -readable address lives solely in unclaimed sign-in links. |
| +Instance→home auth follows Pegamento's service contract: per-consumer |
| +bearer token stored hashed, consumer identity is its origin, origins are an |
| +allowlist. |
| |
| -**Call 3's rows carry a kind discriminator — member or guest.** A guest is |
| -not "in" a team the way a member is, and a row that implies an org membership |
| -they do not have is a lie the directory tells on the org's behalf. Cheap now, |
| -awkward once rows exist. (docs-e6, 2026-08-30.) |
| +**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**. §9 as first written did not say what happens when call 2 |
| -fails, and the obvious implementation — a synchronous upsert inside the share |
| -request — would have made the home load-bearing for sharing, which is exactly |
| -the failure the blueprint names. (Caught by docs-e6 reviewing as a consumer, |
| -2026-08-30.) |
| - |
| -So, as a rule rather than an implementation detail: |
| - |
| -- **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 with retry**, never a call |
| - in the request path. |
| +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". |
| |
| -A home that is down means people reach their teams by bookmark instead of by |
| -list. Nothing becomes unreachable and nothing becomes unshareable. |
| +**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. |
| |
| -### 9.1.2 Re-keying: email change and pepper rotation |
| +Note this ruling does **not** extend to sign-in (§8.1), which is an |
| +authority by design and says so. |
| |
| -The directory is keyed on `HMAC(pepper, lowercased email)`. Two ordinary |
| -events invalidate keys, and neither had a procedure. (docs-e6, 2026-08-30.) |
| +### 9.2 Split origins — RULED 2026-08-30 |
| |
| -**Email change.** The person's HMAC changes and every directory row they own |
| -is orphaned. The home cannot repair this alone — it holds no readable address |
| -to re-hash. This is **a fifth call, admitted rather than discovered**: a |
| -cursor-based rekey journal that instances pull, carrying `old_hmac → |
| -new_hmac` and nothing else. The home learns both addresses transiently at the |
| -change (it must, to hash them) and stores neither. |
| +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.* |
| |
| -**Open, and the home spec resolves it:** whether instances hold readable |
| -addresses at all. They need one to render "shared with alice@example.com" in |
| -a sharing UI; they may not need one for auth, because §9.2 puts sign-in on |
| -the credential origin rather than the instance. That single question decides |
| -whether the rekey journal must also carry the new readable address, and it is |
| -the home spec's to settle, not this one's. Flagged here so it is not |
| -rediscovered. |
| - |
| -**Pepper rotation, and §9.1.1 is what makes it survivable.** Rotating the |
| -pepper kills every row in the directory simultaneously. Under the hint |
| -ruling that is a **convenience outage, not a data loss**: access is |
| -unaffected because instances are authoritative, and the directory refills as |
| -instances re-upsert through their outboxes. So the pepper is rotatable |
| -rather than a permanent secret with no recovery story — which is the better |
| -answer, and it falls out of a ruling made for a different reason. |
| - |
| -### 9.1.3 A guest grant discloses the granting team's name |
| +- **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. |
| |
| -Sharing one sheet with an outside guest puts a row in their directory |
| -carrying **the granting team's name**. Somebody shown a single file learns |
| -the name of the org that shared it. |
| +The directory exchanges a signed assertion from the credential origin for |
| +its own session. An XSS on the directory cannot reach the RP. |
| |
| -**Ruled: keep the behaviour, disclose it in the share UI.** Google does the |
| -same and it is not wrong; the alternative — guest grants producing no |
| -directory row — removes the reason the directory exists, since the original |
| -case was a guest shared into three orgs needing somewhere to land. What is |
| -wrong is an emergent property nobody decided. The share dialog says who will |
| -be able to see the team's name, in words, at the moment of sharing. |
| -(Raised by docs-e6, 2026-08-30.) |
| +**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.2 Split origins — RULED 2026-08-30 |
| +### 9.3 Theme is per-person suite state — RULED 2026-08-30 by Paul |
| |
| -A team name is account-authored, so a home that renders team names on its |
| -passkey RP origin is exactly what `decisions.md` warns against. The answer is |
| -Tito's, already recorded: *"credentials only ever on the root origin, |
| -account-authored content only on the account's own host."* |
| +**Light/dark is a per-person preference, switchable from the spreadsheet |
| +editor** (ruling 14). Not per document, not per team. |
| |
| -- **Credential origin** — sign-in and the WebAuthn ceremony, nothing else. |
| - No team names, no titles, no account-authored string of any kind, ever. |
| - This is the RP. |
| -- **Directory origin** — a different hostname. Renders team names freely, |
| - holds no credentials. |
| +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. |
| |
| -The directory exchanges a signed assertion from the credential origin for its |
| -own session. An XSS on the directory cannot reach the RP, because it is not |
| -the same origin — which is the actual threat. |
| +**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. |
| |
| -**Rejected for now: names in a sealed blob decrypted in the browser** |
| -(docs-e6's option b). It needs a client-held key; our key source is passkey |
| -PRF; passkeys are optional (ruling 13). It would either force passkeys on |
| -everyone or show unnamed rows to the least technical users. It becomes |
| -correct when Pegamento's vault facet lands, and the split-origin design makes |
| -it an upgrade rather than a redesign. |
| +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.3 Provisioning |
| +### 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, sends the welcome mail. Team |
| -ready in minutes, and the copy says so honestly rather than faking a spinner. |
| - |
| -This is the right architecture either way: when the platform grows a |
| -provisioning credential, the drainer's body becomes a direct API call and |
| -nothing else changes. |
| +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 |
| |
| -### 9.4 The filed gap |
| - |
| -`POST /api/cli/apps/{account}/{app}/instances` does what we need, but console |
| -auth is a 90-day bearer token from a device-code browser flow — a human's |
| -terminal credential. `auth.go:76` names why holding one in a web-facing app is |
| -wrong: *"a long-lived token on it is a standing credential nobody is |
| -watching."* |
| +Sharing one sheet with an outside guest puts a row in their directory |
| +carrying **the granting team's name**. |
| |
| -Filed against `amadan.net/carlos/platform` as a product gap, per platform.md: |
| -*"a product gap to file, never a workaround to build."* Draft text is in the |
| -appendix. |
| +**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.5 The RP rename drill — a scheduled task, not a risk |
| +### 9.6 Re-keying, and the pepper's blast radius |
| |
| -Ruling 11 puts the RP on a carloku.com subdomain provisionally. That |
| -**schedules** the multi-phase rename drill rather than avoiding it. Split |
| -origins contain it — only the credential origin moves, and it holds no |
| -content — but it is still a drill with live credentials on it. |
| +The directory is keyed on `HMAC(pepper, lowercased email)`. |
| |
| -**Trigger:** a real suite domain exists. It is written here as a procedure |
| -somebody executes, not a problem somebody discovers. Docs carries the same |
| -note; its users hold credentials on the same origin. |
| +**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. |
| |
| -**Size the crossover window by the guest tail, not by active members** |
| -(docs-e6, 2026-08-30). The population least likely to survive a credential |
| -migration 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, and invisible to any plan that assumes people re-authenticate |
| -when nudged. The window is therefore measured in months and sized by that |
| -tail. A drill that quietly drops the guests is a drill that failed while |
| -reporting success. |
| +**Open, and the home spec resolves it:** whether instances hold readable |
| +addresses at all. They need one to render "shared with alice@example.com"; |
| +they no longer need one for auth, since §8.1 moved sign-in to the home. That |
| +question decides whether the journal must also carry the new address. |
| + |
| +**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 — smaller than the first draft thought |
| + |
| +Ruling 11 puts the RP on a carloku.com subdomain provisionally, which |
| +**schedules** the rename rather than avoiding it. |
| + |
| +**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. |
| |
| --- |
| |
| @@ -435,190 +627,169 @@ reporting success. |
| |
| `day` theme (ruling 7). rastrillo tokens. |
| |
| -**Write the attribute grammar, not the class vocabulary.** `<button |
| -rst-btn="primary compact">`, matched with `~=`; `data-tone` becomes |
| -`rst-tone`. Ruled 2026-08-30 in rastrillo's own spec, staged, with no |
| -permanent fallback. Writing `class="rst-*"` now means writing the losing |
| -spelling of 585 instances. |
| +### 10.1 Markup: classes now, attributes after the dual-grammar release |
| |
| -**`tokens.css` is frozen into `static/` at scaffold time while partials |
| -upgrade with the module.** Re-copy it deliberately on every rastrillo |
| -upgrade. This is a step in the upgrade runbook, not a thing to remember. |
| -`rastrillo doctor` was approved 2026-08-30 to make it mechanical, with this |
| -app as its named downstream consumer; adopt it the day it lands and delete |
| -the manual step. |
| +**The first draft got this backwards and it would have shipped an unstyled |
| +app.** Verified against `origin/main`: the shipped `ui/tokens.css` contains |
| +**zero attribute selectors and 382 class selectors**, and the rake line |
| +`::after` is still live. 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. |
| |
| -**Do not build on `.rst-page-header::after`** — the accent "rake line" is |
| -retired in favour of `--rst-header-rule`. |
| +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. |
| |
| -The app shell (menu bar, toolbar, formula bar, sheet tabs) is **app-local**, |
| -handed upstream when a second app wants it — ui-guidelines-52's ruling. |
| +**`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. |
| |
| -**Primitives split with Docs (2026-08-30):** this app owns **toolbar |
| -groups**; Docs owns inspector panel, presence stack and **comment thread**. |
| -Comment thread went to Docs because their case is harder — anchors that are |
| -Yjs relative positions surviving concurrent edits, plus suggestion mode — and |
| -the harder case should own the design. We consume theirs. |
| +**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.2 shim.** |
| |
| -### 10.1 The four jank cuts, made concrete |
| +### 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; number formatting appears when the selection is numeric. The long |
| - tail lives in a command palette, which is the honest answer to "where did |
| - the 25th icon go". |
| -- **Menus go shallow:** File, Edit, Insert, Format, Data, Help. The palette is |
| - the real navigation. |
| +- **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. |
| |
| -### 10.2 Cell fill colour |
| - |
| -Stored as **OKLCH intent** (hue + chroma), resolved per theme and scheme to a |
| -`{fill, on-fill}` **pair** — the pair is what must clear 4.5:1, because a user |
| -picks a fill and then types in it. Imported hex is retained alongside so |
| -XLSX import→export round-trips losslessly, and is dropped the moment a user |
| -re-picks that cell (ruling 8). |
| - |
| -**ui-guidelines-52 owns the implementation**, in `ui`, as a pure function over |
| -a bounded offered hue set, callable outside a render. |
| - |
| -**The signature takes a literal background, not a scheme** (corrected by |
| -ui-guidelines-52, 2026-08-30, after Docs turned out to need highlights on |
| -paper white in every theme including dark ones): |
| - |
| -``` |
| -(hue, chroma, background) → {fill, on-fill} |
| -``` |
| - |
| -A `scheme` parameter could only ever mean "resolve against the theme's |
| -surface token", which is wrong for any caller whose content does not sit on |
| -that surface. Passing the background makes the function honest about what it |
| -measures against, and it is strictly better for us too: a cell's real |
| -background is not always the sheet's surface — conditional formatting can |
| -paint under a user fill, and a selected or frozen cell sits on something |
| -else again. We pass what the cell actually sits on and the gate asserts the |
| -pair against that exact colour. |
| - |
| -**Allocation is a pure function of a canonically ordered key set, never of |
| -arrival order** (ui-guidelines-52, 2026-08-30). If displacement resolves by |
| -"who arrived later", two clients viewing the same sheet allocate differently |
| -— the same person is green for one viewer and blue for another, and both |
| -trust it. With six people in a sheet that is a visible and confusing bug. |
| -Order by member id and displace within that order. The `avoid` set is a |
| -parameter of the API rather than a caller-side workaround, so collisions |
| -occur only past the set's capacity — the honest limit rather than an |
| -accident. |
| - |
| -Stability of a person's colour is **best-effort by design; separation is the |
| -hard guarantee.** |
| - |
| -**Second entry point: distinguishable-colour allocation.** Contrast- |
| -correctness and perceptual separation are different guarantees, so allocation |
| -is its own function — hash a stable identity into the bounded hue set with a |
| -separation guarantee, then resolve each through the pair function above. |
| -**Sheets is a caller**, not just Docs: presence cursors (§7) need collaborators |
| -mutually distinguishable, which is the identical problem to their comment |
| -author colours. If conditional formatting later wants "N distinguishable |
| -fills" — a categorical scale — that is this entry point, not N calls to the |
| -first one. |
| - |
| -**Until it ships: a deliberately crippled shim.** A hardcoded table of six to |
| -eight intents with hand-computed pairs, behind the corrected signature above |
| -— never the scheme-shaped one, so the swap stays a deletion — gated by |
| -a contrast test that **copies rastrillo's arithmetic verbatim** — not merely |
| -models it, or the two gates diverge by a rounding decision and swapping the |
| -table changes the test's meaning silently. The file carries a header comment |
| -naming it a placeholder and linking this section as its deletion trigger: |
| -**when `ui` ships the generator, delete the file and call theirs.** |
| - |
| -The offered set and the hand-computed pairs go to ui-guidelines-52 as input |
| -to the generator. |
| - |
| -**Framework rule we are extending, not bending:** colour never carries meaning |
| -alone. A fill is decoration; whatever it *means* must survive greyscale. |
| - |
| -### 10.3 Accessibility |
| - |
| -WCAG 2.2 AA, gated rather than asserted. |
| +The app shell is **app-local**, handed upstream when a second app wants it. |
| +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; we consume it — a cross-repo dependency |
| +inside slice one, noted in §13's risk). |
| + |
| +### 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 — a cell's meaning *is* its row and column. So the grid scrolls in |
| -both axes at 320px, and **every other surface must reflow or it is a bug, |
| -not an exemption.** That framing is falsifiable, which is why it holds. |
| +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 — the hit area is honest and the exception is an argument |
| -you have to keep making. |
| +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` for both directions. Import maps incoming hex fills to the nearest |
| -offered intent while retaining the original hex (§10.2). Export resolves |
| -intent to concrete hex per scheme. |
| +`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 a file we imported and exported must open cleanly in Excel and |
| -LibreOffice. |
| +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).** |
| |
| -Import runs as a rastrillo background job (`jobs.New` → `Start`), 303 to |
| -`/jobs/{id}`, with `StatusPage`/`Fragment`/`Events` mounted behind |
| -`sessions.Require`. The status page works with scripts off. Jobs are |
| -idempotent — a deploy ends them mid-flight — and `location` is a server-built |
| -path, never user input. |
| +Uploaded and generated files go to `blobs.Sealed` with |
| +`ContentType: application/octet-stream` (§2.3). |
| |
| --- |
| |
| ## 12. Testing gates |
| |
| -From the first commit, not a later hardening pass: |
| +From the first commit: |
| |
| -1. **Plaintext grep** over raw SQLite bytes and bucket objects. |
| -2. **Two-user isolation suite**, copied from `examples/notes`. |
| +1. **Plaintext grep** over the raw SQLite file and WAL, plus every object |
| + this app writes to its own store (§2.6). |
| +2. **Two-user isolation suite**, including the grants-join 404-not-403 |
| + contract (§4.5). |
| 3. **Golden XLSX round-trip corpus.** |
| 4. **Formula golden vectors** from LibreOffice headless. |
| 5. **Contrast test** copying `ui/contrast_test.go`'s arithmetic verbatim. |
| -6. **Scriptless-affordance diff** — fetch script-stripped, diff for every |
| - affordance. |
| -7. **Browser drives in CI.** A JS syntax error takes the whole client down and |
| - only a real engine sees it (the keymail rule). |
| -8. **Module-size cap** on JS, test-enforced, ratchet-down only. The vendored |
| - Jspreadsheet blob is exempt and named as such; nothing we write is. |
| +6. **Scriptless-affordance diff.** |
| +7. **Browser drives in CI.** A JS syntax error takes the whole client down |
| + and only a real engine sees it. |
| +8. **Module-size cap** on JS, ratchet-down only. The vendored Jspreadsheet |
| + blob is exempt and named; nothing we write is. |
| +9. **No-content-in-logs** — scan log lines, error strings and job messages |
| + for cell content (§2.4). |
| +10. **Cache coherence** — assert no cell serves a cached value whose |
| + `cache_version` predates its input's version (§4.4). |
| + |
| +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 one available reviews. A review by a weaker model than the author is |
| -not a review. |
| +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 |
| -the PR body or the spec's status line — a review nobody can attribute is a |
| -review nobody can check. |
| - |
| -The highest-capability model is **discovered, not hardcoded**; naming one |
| -here without saying how to re-derive it is how the rule goes stale silently. |
| -A session that cannot reach a model at least as capable as the author's says |
| -so and waits, rather than passing a weaker review off as done. |
| - |
| -Full wording in `AGENTS.md`. |
| +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. |
| |
| -Review happens on a deployed canary, never localhost. Verify a deploy with |
| -`X-Carlos-Version` on the canonical host after the watch — never a 200. |
| +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 |
| |
| 1. Skeleton, `AGENTS.md`, auth |
| -2. Storage and sealing (gate 1 live from here) |
| +2. Storage and sealing (gates 1, 9, 10 live from here) |
| 3. Formula engine |
| 4. Grid |
| 5. XLSX / CSV |
| @@ -629,10 +800,24 @@ Review happens on a deployed canary, never localhost. Verify a deploy with |
| 10. Comments |
| 11. **Charts** |
| |
| -**Charts is last and is the named slip candidate.** It is the only item in |
| -scope that is a separate subsystem rather than a feature — chart types, ranges |
| -that move when rows insert, XLSX chart round-tripping. If anything is cut, it |
| -is this, and it will be flagged before it is late rather than after. |
| +**Scope honesty, from the review.** Ruling 5 is Paul's and 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.** 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. |
| |
| --- |
| |
| @@ -641,60 +826,30 @@ is this, and it will be flagged before it is late rather than after. |
| | 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 | |
| | 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 is keeping 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 | Recorded as a known future crossing of two collaboration models, not a surprise | |
| +| 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 | |
| |
| --- |
| |
| ## 15. Open |
| |
| -- **The platform provisioning credential** (§9.4) — filed, not blocked on. |
| -- **A real suite domain** — triggers the RP rename drill (§9.5). |
| -- **The home spec itself** — this session drafts it next, in its own repo. |
| -- **Language neutrality upstream.** Paul asked docs-e6 to file a discussion |
| - on `carlos/platform` proposing that CARLOS picks the appropriate language |
| - per app rather than assuming Go, stating the platform contract as five |
| - language-neutral requirements. If accepted it changes blueprint.md's "Go |
| - server" section, which is upstream of this spec. Nothing here depends on |
| - the outcome — Go remains right for this app either way, and the formula |
| - engine is the only piece a different answer would even invite revisiting — |
| - but read it when it lands. |
| - |
| ---- |
| - |
| -## Appendix — platform gap, draft issue text |
| - |
| -``` |
| -Title: No machine credential for programmatic instance provisioning |
| - |
| -A hosted suite that mints one tenant instance per team has no |
| -credential story. POST /api/cli/apps/{account}/{app}/instances does |
| -exactly what's needed, but console auth is a 90-day bearer token from |
| -a device-code browser flow — a human's terminal credential. |
| - |
| -For an app to call it, the app must hold that token. auth.go:76 already |
| -names why that's wrong: "a long-lived token on it is a standing |
| -credential nobody is watching." |
| - |
| -So the shape that's missing is a scoped, non-interactive credential that |
| -can create and manage instances for one app within one account, and |
| -nothing else — no deploys, no secrets, no billing, no other app. |
| -Revocable independently of the human who minted it, and visible as a |
| -thing that exists so it isn't unwatched. |
| - |
| -Found while designing Sheets (carlos/sheets), where each team is an |
| -instance and signup should mint one. Working around it for now: the app |
| -queues provisioning requests, an operator drains the queue under a real |
| -console credential. That's fine at low volume and swaps to a direct API |
| -call the day this exists — but it means self-serve signup can't complete |
| -without a human, which won't hold. |
| - |
| -Not urgent for us. Filing it because platform.md says a gap like this is |
| -a thing to file rather than work around, and because any second suite |
| -app hits it identically. |
| -``` |
| +- **Ruling 15** (one RP at the home) needs Paul's confirmation — it resolves |
| + a contradiction, and some resolution is required, but the shape is this |
| + session's call. |
| +- **The passkey/assertion gap is one gap with two consumers.** To be |
| + resolved jointly with Docs and filed once, not twice. |
| +- **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. |