oficina / sheets Public

Revision after review: the foundations, corrected

A review dispatched to Fable returned thirteen confirmed findings and a
"not ready to approve" verdict. Three source claims were re-verified by
this session and three by the Docs session before acting on any of them.

The three that would have been migrations rather than edits:

Stable row and column ids do not remove formula reference rewriting.
Relative references are positional offsets, so copy and drag-fill need a
translation pass at paste time — the previous draft claimed all four
dependent features worked when only three did. Section 4.2 now specifies
behaviour per operation, including sort, cut-paste and dangling ids.

The passkey story contradicted itself: section 8 put the second factor on
the instance while ruling 11 put the RP on the home, and passkey.New
takes RPID from the app's own origin — so a person in three teams would
enroll three passkeys, the exact problem decisions.md says to solve on
day one. Resolved as ruling 15: the home's credential origin is the only
RP, instances verify signed assertions and never run WebAuthn. Needs
Paul's confirmation.

The event log's payloads were in neither leak list, and version history
is unimplementable unless they carry old cell values. Section 4.3 now
splits input events from recalc markers so the log grows with edits
rather than with fan-out, and the leak list gains rule constants, filter
values, chart text, sealed blobs and a no-content-in-logs rule.

Two findings were this session's own errors, both verified: AGENTS.md
told us to write the attribute grammar, but the shipped tokens.css has
zero attribute selectors against 382 class selectors and the migration is
ruled-not-started; and scope.Owned is single-column equality that would
return only the creator's rows on a team-shared table, failing as a
plausible empty list rather than an error.

Also: LegacyRPID already exists in rastrillo, so the RP rename drill is
set-a-field-and-size-the-window rather than an invention; the pepper
moves off instances so one compromise no longer exposes the whole
directory; blobs get blobs.Sealed with an opaque content type; the
provisioning conflation with Pegamento doctrine is recorded as an argued
divergence instead of an unclaimed one; and integer cents is corrected to
IEEE doubles, since a spreadsheet cell is not an app-domain amount.

Per Paul, 2026-08-30: light and dark is a per-person preference
switchable from the editor, which makes it suite-level account state with
a before-first-paint constraint.
Reviewed-By: Fable (dispatched; see spec status line)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell a49978ff3797df8eeaefaa7db9ec45d1f1f049fb parent 2f28651
2 files changed, +666 −482
  • AGENTS.md +38 −9
  • docs/superpowers/specs/2026-08-30-sheets-design.md +628 −473
diff --git a/AGENTS.md b/AGENTS.md
index 801585d..e87df07 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,6 +83,15 @@ before the last push" is not CI being green.
Review happens on a **deployed canary**, never localhost. Shared hosts only
ever run merged `main`.
+**Every branch gets a discussion, and that discussion IS the review
+record** — including which model reviewed it (§Review). A discussion
+survives a squash where a PR body does not. Tasks are a checklist, not a
+log: commentary belongs in the discussion.
+
+**A refusal gets surfaced in the discussion, never routed around.** Straight
+from amadan's own house rules, and the rule most likely to be quietly
+ignored.
+
---
## Intent before code
@@ -103,10 +112,17 @@ Rules here are enforced by tests, not by memory. If you find yourself
relying on remembering one, make it a test instead.
**Data**
-- Every query touching user-owned rows goes through `scope.Owned(g, uid)` —
- reads *and* writes, inside transactions too. Scope `tx`, never the outer
- handle, or the one-connection writer pool deadlocks.
-- A row that isn't yours **404s, never 403s**.
+- **Never `scope.Owned` or `scope.OwnedBy` on a team-shared table.** Both
+ are single-column equality (`Owned` is literally `Where("user_id = ?")`),
+ and our sheets, tabs and cells are team-shared. Applied to them, a sheet
+ list returns **only the creator's rows** — it fails as a plausible empty
+ list, not as an error, which is the worst available failure shape and also
+ the default thing to reach for. Access goes through the hand-written
+ grants join. `scope.Owned` is still correct for genuinely per-user rows.
+- A row that isn't yours **404s, never 403s** — preserved by hand in the
+ grants join, and test-enforced by the two-user isolation suite.
+- Scope `tx`, never the outer handle, inside transactions, or the
+ one-connection writer pool deadlocks.
- 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
@@ -114,15 +130,24 @@ relying on remembering one, make it a test instead.
dropping that `ok` scopes every query to uid 0.
- Migrations are additive-only. New code over an old DB is always safe.
Never delete data to update.
-- A float never touches an amount. The formula evaluator's IEEE arithmetic
- is a bounded exception that stops at the storage boundary.
+- **Cell values are IEEE doubles end to end.** Currency is a number format,
+ not a type; `=A1*1.05` produces a double and XLSX stores a double.
+ Storing cents would round the evaluator's own output. The family's "a
+ float never touches an amount" rule binds **app-owned** amounts only —
+ billing, quotas, anything Sheets itself counts.
+- **No cell content, comment text, sheet title or tab name in any log line,
+ error string, job status message or metric label.** `carlos logs` is
+ platform-held storage we cannot grep. Errors carry coordinates and codes.
**Frontend**
- No build step. Jspreadsheet CE is vendored as a static asset, not fetched
from a CDN and not bundled.
-- Write the rastrillo **attribute** grammar (`rst-btn="primary compact"`),
- never the `class="rst-*"` vocabulary — it is mid-migration and the classes
- are the losing spelling.
+- **Write `class="rst-*"` today.** The shipped `ui/tokens.css` has zero
+ attribute selectors and 382 class selectors; the attribute grammar is
+ ruled but *not started*, and its staged path ships dual-matching CSS
+ first. Writing `rst-btn="primary compact"` now renders unstyled against a
+ frozen `static/tokens.css`. Run the codemod when the dual-grammar release
+ lands — not before.
- **Re-copy `static/tokens.css` on every rastrillo upgrade.** It is frozen
at scaffold time while partials upgrade with the module. Adopt
`rastrillo doctor` the day it lands and delete this step.
@@ -131,6 +156,10 @@ relying on remembering one, make it a test instead.
- 300-line JS module cap, test-enforced, ratchet-down only. The vendored
Jspreadsheet blob is exempt and named as such; nothing we write is.
- Every drag-only affordance has a menu equivalent.
+- **Light/dark is per person, not per document**, and must resolve before
+ first paint — cell fills resolve against the theme, so a late preference
+ repaints every coloured cell. Read it synchronously from a cookie, never
+ a fetch.
**Deploys**
- One binary, `CGO_ENABLED=0`, cgo-free SQLite. A cgo driver sneaking in is
diff --git a/docs/superpowers/specs/2026-08-30-sheets-design.md b/docs/superpowers/specs/2026-08-30-sheets-design.md
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.