# 🤖 Sheets — design

**Status:** design, revised 2026-08-30 after review. Approved in
brainstorming by Paul on 2026-08-30; revised the same day against a
review dispatched to **Fable** (13 confirmed findings), with three source
claims independently re-verified by this session and three more by the Docs
session. Every ruling is dated and attributed, with the rejected
alternative beside it.
**Reviewing model, per §12.1:** dispatched to Fable. *(The agent's own
header self-reported as Opus 5; models are unreliable about their own
identity, so the honest record is what was dispatched.)*
**Scope:** the Sheets instance app. The Suite home is a **separate spec in
a separate repo** (§9); this document depends on it and does not define it.

---

## 0. The one rule

> **A sheet's contents are sealed at rest. The bucket never holds a
> plaintext cell.**

The instance holds the key while it runs, so the server *can* read a sheet
it has open. That is a deliberate softening, not an accident, and §2 names
every leak it produces. What the rule forbids absolutely is a plaintext
value reaching the replicated SQLite file or any object storage we write.

`AGENTS.md` opens with this rule verbatim, and is written before any code.

---

## 1. Settled decisions (don't relitigate casually)

| # | Ruling | Rejected | Who, when |
|---|---|---|---|
| 1 | **Sealed at rest, DEK in instance memory** (Seapointish's position) | Plaintext server-trust (Tito); full E2EE (Keymail/Kass); split-by-class (Woodstar) | Paul, 2026-08-30 |
| 2 | **Vendor Jspreadsheet CE v5 (MIT, single UMD file, no bundler); its formula engine is DISABLED — Go owns calc** | Client-side calc; hand-built VanJS grid; Univer (needs a bundler) | Paul, 2026-08-30 |
| 3 | **Cell-level last-write-wins, live over SSE** | Save-and-reload; single-writer locking; CRDT/OT | Paul, 2026-08-30 |
| 4 | **Rows + append-only log + in-memory sheet handle** | Sealed blob per tab; rastrillo's `mergeable` store | Paul, 2026-08-30 |
| 5 | **Slice one includes version history, sort/filter/conditional formatting, comments AND charts** | A smaller slice | Paul, 2026-08-30 |
| 6 | **All four "jank" cuts** | Keeping any of them | Paul, 2026-08-30 |
| 7 | **`day` theme** | `signal`; `plain` | Paul, 2026-08-30 |
| 8 | **Cell fill stored as OKLCH intent; imported hex retained** | Intent only (lossy round-trip); absolute hex (breaks in dark mode) | Paul, 2026-08-30 |
| 9 | **Provisioning: the home queues, an operator drains** | A standing console token in a web-facing app | Paul, 2026-08-30 |
| 10 | **The home is its own repo and its own spec** | Keeping it here; Docs drafting it | Paul, 2026-08-30 |
| 11 | ~~Passkey RP is an `oncarlos.com` host, provisionally~~ — superseded by 18 | A real suite domain now; no passkeys | Paul, 2026-08-30 |
| 12 | **Link sharing is viewer-or-commenter, never editor** | Anonymous link editing | Paul, 2026-08-30 |
| 13 | **Passkey 2FA optional per person, owner-enforceable per team** | Mandatory; none | Paul, 2026-08-30 |
| 14 | **Light/dark is a per-person preference, switchable in the editor** | Per-document; per-team; follow-OS-only | Paul, 2026-08-30 |
| 15 | **One RP: the home's credential origin. Instances never enroll passkeys** — §8.1 | Per-instance passkeys (three teams = three credentials) | Proposed by this session, **confirmed by Paul, 2026-08-30** |
| 16 | **Version history replays inputs and recomputes; derived values are never logged** — §4.3 | Logging derived values (instant restore, log grows with fan-out) | Paul, 2026-08-30 |
| 17 | **Slip order written down: charts first, conditional formatting second. Version history's baseline is restore-a-cell-or-range** — §13 | Holding all eleven at full scope; cutting charts outright today | Paul, 2026-08-30 |
| 18 | **`oficina.works` is the primary domain; the RP is `id.oficina.works`, instances are `<team>.sheets.oficina.works`** — §9.7 | Waiting until passkeys shipped (the move only stays free until then) | Paul, 2026-09-03 |

---

## 2. Trust model, and the leak list

The DEK arrives as platform config (`carlos secrets`), never stored in the
database it decrypts. HKDF derives purpose keys; values are sealed with
AES-GCM. Golden vectors pin the envelope.

### 2.1 Sealed

- Every cell's raw input and every cached computed value.
- Sheet titles, tab names, comment text.
- **Event log payloads.** Version history and undo are unimplementable
  unless the log carries old and new cell values, and those values are user
  content. This was missing from the first draft and the grep gate would
  have caught it in week two — see §4.3 for what the log actually stores.
- **Conditional-formatting rule constants.** `> 1000`, `contains
  "overdue"` — user data living inside a format-adjacent record.
- **Persisted sort and filter criteria.** A filter value *is* a cell value.
- **Chart titles, axis labels and series names.**
- **Every blob**, via `blobs.Sealed` — see §2.3.

### 2.2 Plaintext, and published in the README rather than discovered

- Cell coordinates and stable ids — therefore the **shape and size** of
  every sheet.
- Format records: fills (as OKLCH intent), borders, alignment, number
  format codes. Not the rule constants that select them (§2.1).
- Timestamps, actor ids, and the shape of the edit log.
- Membership, grants, and roles — **including readable email addresses**
  where a grant carries one (see §9.6).

Publish our database and you learn who works with whom, how big their
spreadsheets are, how often they edit and what colour they painted things.
You learn **no value in any cell**.

### 2.3 Blobs, and the metadata that escapes sealing

Imported and exported XLSX files are whole spreadsheets. If they reach
`rastrillo/blobs` on the S3 backend unsealed, that is a plaintext
spreadsheet in a bucket — §0 defeated through a side door.

**`blobs.Sealed` is mandatory for every blob this app writes.** It exists
for exactly this (`blobs/blobs.go`: E2EE apps wrap any backend in Sealed).

Three properties of it that shape our usage, all verified against source:

- **`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.

### 2.4 Logs and error text are storage

`carlos logs` is platform-held. An import error reading *"can't parse
'salary 50000' at B7"* ships cell content off the instance to storage we do
not control and cannot grep.

**Rule: no cell content, comment text, sheet title or tab name in any log
line, error string, job status message, or metric label.** Errors carry
coordinates and error codes; the content stays in the sheet. This is
test-enforced (§12 gate 9).

### 2.5 The residual risk, stated and never contradicted

A compromised *live* host can read any sheet the instance has open, because
the key is in memory. So can anyone who can read platform secrets. **We do
not claim "we can't read your data" anywhere in the product.**

### 2.6 The gate, honestly scoped

The platform runs Litestream into a platform-owned bucket and the member
contract is zero infrastructure access — **CI cannot read that bucket.**
Scoping the gate to "bucket objects" would have been silently narrowed at
implementation time, which is the drift §0 exists to prevent.

What the gate actually covers: **the raw SQLite file and its WAL, byte for
byte, plus every object this app writes to its own store.** The DB file is
byte-identical to what Litestream ships, so covering it covers the
replication path. `plaintextExempt` starts empty; any entry is dated,
justified and listed in the README.

---

## 3. System shape

One repo, `oficina/sheets`, one binary: `cmd/sheets`. One process, one
SQLite file, one hostname per team, hibernating. rastrillo `server` shape
throughout, with exactly one declared client-shape surface (the grid, §6).

---

## 4. Data model

### 4.1 Tables

- `sheets` — id, title (sealed), 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`.

Migrations are additive-only. New code over an old DB is always safe.

### 4.2 Rows and columns have stable identity — and what that does NOT buy

**A1-style coordinates are a view, not a key.** Positions live on the `rows`
and `cols` records; `B7` is derived at render time.

This is load-bearing for **three** things: comments stay attached to the
cell they were written about when a row is inserted above them; conditional
formatting ranges move correctly; chart ranges move correctly.

**It does not eliminate formula reference rewriting, and the first draft
claimed it did.** Relative references in A1 semantics are *positional
offsets*, not identities — copying `=A1` from B1 to B2 must produce `=A2`.
Stable ids remove the rewrite for insert and delete only. Per operation:

| Operation | Reference behaviour |
|---|---|
| **Insert / delete row or col** | No rewrite. Ids are stable; ranges bounded by `(start_id, end_id)` widen or narrow. **This is the case stable ids buy.** |
| **Copy / drag-fill** | **Translation pass at paste time.** Relative refs are re-resolved by offset from the paste anchor; absolute (`$A$1`) refs resolve to the same id. Unavoidable — it is what relative means. |
| **Cut-paste a cell** | The cell keeps its `(row_id, col_id)` key and its *content moves*. Inbound references therefore follow the destination, matching Excel. |
| **Move a whole row** | Position changes, id does not. References follow the row. Matches Excel. |
| **Sort** | **Values move, ids stay with positions.** Sorting rewrites cell *contents* across existing row ids rather than reordering ids — so formulas referencing row 7 still mean "row 7", which is Excel's behaviour and the one users expect. |
| **Delete a referenced row** | The id no longer resolves. The reference renders `#REF!` and the stored id is retained, so an undo restores the reference rather than a broken string. |

**Ranges are stored as `(start_id, end_id)` with an explicit boundary rule:**
a row inserted *strictly between* the bounds joins the range; one inserted
at either edge does not. Written down because the two behaviours are
indistinguishable until someone relies on one.

### 4.3 What the event log stores

Two record kinds, because conflating them is what made the leak list wrong:

- **Input events** — a person's edit. Old and new raw input, sealed. This
  is the version history and the undo stack.
- **Recalc markers** — one row per recalc pass: the triggering event id and
  the count of derived cells touched. **Not** the derived values.

Derived values are not logged (ruling 16), because logging them makes the
log grow with the *fan-out* of every edit rather than with the number of
edits (§4.5) — in a SQLite file Litestream ships in full, continuously.
**The accepted cost: restoring an old version of a large sheet is a
recalculation, so it needs a progress state rather than being instant.**
Restoring a version replays inputs and **recomputes**; that is slower than
reading stored values and it is the only version that stays honest at scale.

**Retention:** the log is checkpointed. Beyond the retention horizon,
per-cell input history is compacted to periodic full snapshots of the
sealed input layer, and the spec's version-history UI is explicit that
history is complete within the horizon and coarse before it. "Additive-only
migrations" is a schema rule and says nothing about a log that grows
forever; this is that rule.

### 4.4 The in-memory handle, and crash recovery

Opening a sheet decrypts it into a refcounted handle holding the dependency
graph. Edits, sort, filter, search and export read it; writes go through to
the tables and the log. Idle handles evict.

**A crash mid-recalc must not leave stale caches serving as truth.** The
input write and a **dirty marker** land in the same transaction; the recalc
pass clears the marker only when it completes. On open, any dirty marker
triggers a dirty-forward recalc before the handle serves a read.
`cache_version` on each cell records the input version its cached value was
derived from, so a stale cache is detectable rather than assumed.

Without this, a deploy, a hibernation eviction or a panic between the input
write and its dependents' rewrite produces a sheet whose displayed totals
disagree with its inputs, silently, forever.

**Budgets, written down because "hold it in RAM" is only true up to a
number:**

- **200,000 cells per tab**, hard, with an honest error rather than a slow
  death. Google's limit is 10M and essentially nobody reaches it.
- **A per-process handle budget**, with eviction under pressure. A tab at
  cap is plausibly tens of megabytes in handle form; several open sheets
  across many instances on one small box needs a ceiling, not an assumption.
- **A measured wake budget.** Cold open at cap is decrypt + parse +
  graph-build + possible dirty recalc. If it exceeds the budget, the answer
  is a progressive open (viewport first), not a longer spinner.

### 4.5 Query scoping — `scope.Owned` is the wrong tool and dangerous here

Verified against source: `scope.Owned` is literally `g.Where("user_id = ?",
owner)`, and its sibling `scope.OwnedBy` is the same single-column equality
with a different column name.

**Our rows are team-shared, not user-owned.** Applied to `sheets` or
`cells`, `Owned` returns only the creator's rows — so a sheet list looks
**empty rather than broken**. It fails as a plausible empty state, not as an
error, which is the worst available failure shape and also the default thing
to reach for.

**Access goes through a grants join, hand-written**, expressing "rows this
team can see, plus rows granted to me individually, minus rows I have been
removed from". The **404-not-403 contract is preserved by hand** and is
test-enforced by the two-user isolation suite (§12 gate 2).

`rastrillo/idear` is the roles/membership addon and is explicitly *not*
core. We hand-roll `members`/`grants` rather than adopt it, because our
guest model (§8.3) is per-sheet rather than per-namespace. **Recorded as a
divergence** so it is a decision rather than an oversight; revisit if idear
grows per-resource grants.

Never bind a request body onto a GORM model: explicit `map[string]any` plus
a `.Select` allowlist. Read the viewer with `auth.From(r)` or
`sessions.Current(r)` — **never `sessions.UserID`**, which returns
`(0, false)` for an email Subject, and dropping that `ok` scopes every query
to uid 0.

---

## 5. The calc engine

Lexer → parser → AST → evaluator, plus a precedent graph with dirty-set
propagation. Cycle detection returns a circular-reference error rather than
hanging. Excel error values throughout, because XLSX round-trips them.
~120 functions to start.

This is the single largest piece of work in the slice — larger than the
grid. Written in Go, from scratch. `excelize` (BSD-3) is confined **strictly
to XLSX I/O**: its calc engine works against a file model rather than an
in-memory dependency graph.

**Cell values are IEEE doubles end to end.** The family's "a float never
touches an amount" rule is about app-domain quantities, and a spreadsheet
cell is not one — currency is a *number format*, and `=A1*1.05` produces a
double that XLSX itself stores as a double. Storing cents would round the
evaluator's own output and diverge from Excel on re-read. **The cents rule
binds app-owned amounts only** — billing, quotas, anything Sheets itself
counts. The first draft applied it to cell storage, which was incoherent.

**Golden vectors are generated from LibreOffice headless** — expected values
produced by a real spreadsheet, not by our reading of a spec. It is the only
honest way to claim 120 functions.

**Go formats; the client paints strings it is given.** Ruling 2 put one
engine behind cell *values* — but a value is not what a cell shows. `123.456`
under a currency format displays `€123.46`, and if the browser renders that
from a raw number, **number formatting is a second implementation of the
same truth**, which is the exact bug class ruling 2 exists to prevent, one
layer down. So the wire carries the display string alongside the raw value;
Jspreadsheet paints what it is handed. Formats are still edited as
structured records — the client sends "currency, 2dp", never a rendered
string.

*(Found by analogy: docs-e6's r1 declared "one engine, one truth" and then
chose contentEditable, handing line-breaking to the browser — a second
layout authority inside a spec whose founding rule forbade one. The same
shape was sitting in this spec's formatting layer, unnamed. 2026-08-30.)*

**Recalc fan-out is priced, not assumed.** One edit to a cell feeding a
whole-column formula dirties thousands of dependents, each needing a seal, a
cell update and an SSE payload entry. The dirty set is capped per pass; past
the cap the client is told to refetch the viewport rather than being sent a
huge delta. Derived cells are **not** individually logged (§4.3), which is
what keeps the log linear in edits rather than in fan-out.

---

## 6. The grid, and where zero-JS stops

Jspreadsheet CE v5, vendored as a static asset — no CDN, no bundler, no
build step.

**Script-required, stated plainly:** the grid editor, drag-fill, live sync.

**Fully scriptless:** sign-in, the sheet list, sharing and membership,
settings, version history, comments, import and export, and a **read-only
table view of any sheet with per-cell edit forms** — **paginated**, because
a 200,000-cell tab rendered as one HTML table is a several-hundred-megabyte
page.

**No form in the app is script-required.** A test fetches every page
script-stripped and diffs for every affordance.

---

## 7. Collaboration

Cell-level last-write-wins over SSE, plus presence.

**Derived cells carry the actor of the edit that caused them**, not the
owner of the formula, and a `derived` flag so version history can show "your
edit changed 400 cells" rather than attributing authorship of a formula's
output to whoever typed a number.

**Every SSE message carries the cell's version**, and clients discard
out-of-order paints by version rather than by arrival. Without this,
optimistic painting plus LWW produces visibly wrong grids under two
concurrent editors — the exact bug the optimism is meant to hide.

**The SSE transport check is a streamed 200**, not a 101. SSE is
`text/event-stream` over a normal response; 101 is the WebSocket upgrade.
The test verifies an unbuffered stream through the real edge — a check
written against 101 would never pass. *(The source doc conflates these;
the conflation stops here.)*

**Idle policy, written down rather than emergent.** Connections drop after a
defined idle period with no edits and no presence heartbeat. **Heartbeats
stop on a hidden tab and have a ceiling**, so a tab left open all weekend
cannot hold an instance awake and billable indefinitely. Held sockets and
hibernation pull against each other; this is where that is resolved.

**Undo is own-edits-only, cell-level.** Ctrl-Z reverts your most recent
input event, applied as a fresh LWW write — so if someone has since written
over that cell, your undo takes it back and is itself undoable. Multi-user
undo has no correct answer; this one is predictable and explainable, which
is the next best thing.

---

## 8. Identity and sharing

### 8.1 One relying party — RULED, needs Paul's confirmation

**The first draft contradicted itself and the review caught it.** §8 put
`passkey.Handlers.Gate` on the instance; ruling 11 put the RP on the home.
Verified against source: `passkey.New` sets `RPID` from `Config.Origin` —
*the app's own origin*. So the obvious wiring enrolls a credential per
instance, and a person in three teams enrolls three passkeys. That is
precisely the "three instances must not mean three credentials" problem
`decisions.md` says to solve on day one.

**Resolution: the home's credential origin is the only RP.** Instances never
enroll passkeys and never mount `Gate`.

- **Sign-in happens at the home**, for members and guests alike: magic link
  auto-upgrading to Keymail, with the passkey second factor — optional per
  person, owner-enforceable per team (ruling 13) — completed there, on the
  RP origin, before any assertion exists. rastrillo's `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.

**Assertions carry identity, never authorization** (docs-e6, 2026-08-30 —
stated positively because a reviewer will otherwise propose the opposite as
an improvement).

A guest's assertion is the same shape as an owner's. The assertion says *who
you are*; the instance decides *what you may do* from its own `grants`
table. Narrowing a comment-only guest's assertion is tempting and belongs on
the wrong side of the boundary: encoding capability requires the home to
know capability, which puts an authorization model inside the identity
origin and cuts against §9's design, where the home knows you have access to
a team in an app and nothing else.

The security argument does not survive either — a leaked assertion buys
exactly the grants of the person it names, which is what a stolen session
buys anyway. Narrowing reduces no blast radius while adding a second
assertion type, its key handling, and a second audience-binding surface.

**Assertion expiry is seconds, not minutes.** It is exchanged for a session
immediately; the long-lived thing is the session, which is `sessions`'
problem and already solved.

**Forward, and recorded so this is a staging post rather than a ceiling
(Paul, 2026-08-30):** CARLOS is expected to grow support for per-instance
auth. One RP at the home is the right answer for Sheets now, and it is
explicitly *not* a bet against per-instance auth later — it is a bet that we
do not need it yet.

What would make us revisit: sign-in availability becoming a real complaint
rather than a theoretical one; a team needing to authenticate against its
own instance under its own domain for a compliance reason; or CARLOS
shipping per-instance auth with an answer to the N-credentials problem. Any
of those, and §8.1 is reopened with this paragraph as the reason it was
always going to be.

**Named consequence:** the credential origin is an authority, and a single
point of failure for *new* sign-ins. Existing sessions keep working, and
§9.1.1's hint ruling covers the directory — but sign-in is deliberately not
a hint, and the README says so. Rejected alternative: per-instance passkeys,
which keeps sign-in working when the home is down at the cost of N
credentials per person and an RP that breaks whenever a team attaches a
custom domain.

### 8.2 Roles

Team: owner, member, guest. Per sheet: owner, editor, commenter, viewer. A
guest holds no team-wide rights — they see exactly the sheets granted to
them and learn nothing about the team beyond §9.5's disclosure.

**Team standing comes from the roster the home holds** (suite §5) in suite
shape, and from `$SHEETS_OWNERS` and the local member rows in lone shape.
**Per-sheet roles stay here.** The doctrine deliberately does not unify
them: a shared role vocabulary designed from three specs at once would fit
none of them, and it may move to the contract module when two apps have
shipped the same one (suite §5.6). Sheets has `commenter`; calendar has no
such role and Docs has none yet, so that moment has not arrived.

This is Pegamento's *"sharing is membership"* taken literally: the sheet
never moves, and the outside person becomes a scoped member of the instance
that owns it. There is no federation story and there will not be one.

### 8.3 Link sharing

**"Sharing is membership" does not forbid link sharing.** It forbids data
crossing a *team* boundary (suite ruling 2), and a link does not move data —
whoever holds it reads the sheet here. This sentence exists because the rule
reads like a prohibition and someone will later think it was one. (docs-e6,
2026-08-30.)

**A link is not a rung on the sharing ladder** (suite ruling 15). A sheet is
private, granted to named people, or shared with the team, and only the team
rung is team-visible — so a sheet that is link-shared but not team-shared is
one no sibling may hold a copy of. Making a link team-visible would turn
"whoever I sent this URL to" into "everyone on the team, findable through
memoria's search", which is a different grant than the one the sharer made.

**Ruling 12 below is now the suite's rule**, adopted verbatim as suite
ruling 15's read-only half. The doctrine cites it; it stays here as the
original, and the reason it gives is the reason the suite took it.

Off by default, high-entropy, revocable, **viewer-or-commenter only, never
editor** (ruling 12) — every version row carries an actor, and an anonymous
editor would write unattributed rows into version history.

**Link tokens are stored hashed**, per Pegamento's service contract. Stored
plaintext, the replicated database becomes a bundle of live read
capabilities to sealed sheets — §0 defeated through a side door for the
second time (§2.3 is the first).

**A view link mints nothing** — read-only against a capability token; no
member, no directory entry. The moment someone wants to *write* — comment —
they sign in, which mints a guest member and produces a directory entry
then. **A link viewer gets no presence cursor and no name in the presence
stack**, because presence is a write to shared awareness state (docs-e6's
corollary, 2026-08-30).

The line, and it is the same in Docs: **anything that writes is attributed,
and attribution is what mints membership.**

---

## 9. The home, as a dependency

The home is a **separate repo and spec** (ruling 10), drafted by this
session with Docs as a declared consumer. This section records only what
Sheets depends on.

### 9.1 The contract, cited rather than restated

**Sheets consumes the home contract as the home spec states it, and the
suite contract as `oficina/suite` states it.** Neither is copied here.

This section used to enumerate five calls. It had already caught itself
drifting once — *"the first draft called this the four-call contract and
then admitted a fifth in a subsection; an implementer reading the heading
builds four"* — and the same thing happened again from the other side: the
home now has six calls, with a seventh and eighth designed, and nothing made
this copy notice. The note was right about the mechanism and could not fix
it, because the fix is not to count more carefully. It is to stop keeping a
second copy (suite §10).

What is Sheets' own, and stays:

- **The home hashes, and returns the person ref**, which we store against
  our member rows. An earlier revision of this section said the instance
  sends `HMAC(pepper, …)`, contradicting §9.6 of this same document; an
  implementer reading it would have built instance-side hashing, which needs
  the pepper on every instance.
- **A directory row never grants team standing** (suite ruling 12), whatever
  its `kind` says. Membership is the home's own record, written only by an
  owner.

Instance→home auth follows Pegamento's service contract: per-consumer
bearer token stored hashed, consumer identity is its origin, origins are an
allowlist.

**Bootstrap, which the first draft left unstated:** a freshly provisioned
instance receives its home bearer token as platform config and is added to
the home's origin allowlist by the operator drain (§9.4), in the same step
that creates it. An instance that has neither cannot call the home and, per
§9.1.1, works fine without it.

### 9.1.1 The directory is a hint, never an authority — RULED 2026-08-30

The blueprint's position on glue is that losing it **costs a convenience,
never content**. (Caught by docs-e6 reviewing as a consumer.)

- **A failed directory upsert never fails the share.** The person is
  granted access; the instance is the authority; the directory catches up.
- Call 2 goes through an **outbox in the instance**, never a call in the
  request path.
- **Every instance re-checks authorization on every request**, whatever the
  directory says. The directory answers "where might I have things", never
  "may I have this".

**The outbox drains on wake and on each share mutation — not on a ticker.**
A hibernating instance runs no timers, so a `time.Ticker` retry loop is a
retry loop that dies at hibernation. For a quiet team a failed upsert may
wait weeks for an organic wake, which is acceptable precisely because the
directory is a hint.

Note this ruling does **not** extend to sign-in (§8.1), which is an
authority by design and says so.

### 9.2 Split origins — RULED 2026-08-30

A team name is account-authored, so a home rendering team names on its
passkey RP origin is what `decisions.md` warns against. The answer is
Tito's: *credentials only ever on the root origin, account-authored content
only on the account's own host.*

- **Credential origin** — sign-in and the WebAuthn ceremony, nothing else.
  No team names, no titles, no account-authored string, ever. This is the RP.
- **Directory origin** — a different hostname. Renders team names, holds no
  credentials.

The directory exchanges a signed assertion from the credential origin for
its own session. An XSS on the directory cannot reach the RP.

**Rejected for now: names in a sealed blob decrypted in the browser.** It
needs a client-held key; our key source is passkey PRF; passkeys are
optional (ruling 13). It would force passkeys on everyone or show unnamed
rows to the least technical users. It becomes correct when Pegamento's vault
facet lands, and split origins make it an upgrade rather than a redesign.

### 9.3 Theme is per-person suite state — RULED 2026-08-30 by Paul

**Light/dark is a per-person preference, switchable from the spreadsheet
editor** (ruling 14). Not per document, not per team.

It therefore lives with the home as **suite-level account state**, or a
person sets it separately in every team they belong to — the same shape as
the credentials problem in §8.1.

**It must resolve before first paint.** For a grid this is sharper than for
plain chrome: cell fills resolve against the theme (§10.2), so a
late-arriving preference does not merely flip the chrome, it **repaints
every coloured cell in the sheet**. The preference must therefore be
readable synchronously at render — a cookie on the instance's own origin,
written from the assertion at session mint and refreshed when the person
changes it — never a fetch, and never a value the first paint has to wait on.

The in-editor toggle writes through to the home and updates the local cookie
optimistically, so the switch is instant and the preference follows the
person to their next team.

### 9.4 Provisioning, and a recorded divergence from Pegamento

Signup writes a provisioning request; an operator command drains it under a
real console credential, creates instances, delivers the home bearer token
and allowlist entry (§9.1), and sends the welcome mail. Team ready in
minutes, and the copy says so rather than faking a spinner.

**Recorded divergence.** Pegamento doctrine §9 is explicit that provisioning
and instance lifecycle are *not* Pegamento, and flags Eleven's home — a
blind vault *and* a graph-aware provisioning broker in one binary — as "the
one place blindness breaks", adding "Pegamento does not repeat that
conflation." Our home carries sign-in, directory *and* provisioning enqueue.

That is a divergence and the first draft claimed doctrinal compliance it did
not have. **The divergence is deliberate and bounded:** call 4 is an opaque
enqueue with no directory read and no graph access; the queue lives in its
own store; and the drain runs outside the home process entirely, under a
human's credential. The conflation the doctrine warns about is a *blind*
service that also knows person↔instance — ours knows person↔instance in the
directory already (§9.5 discloses it), so the enqueue adds no knowledge the
service lacks.

**Revisit trigger:** the home spec is the right place to decide whether the
queue moves to a separate binary. Recorded here so the home spec inherits an
argued position rather than an accident.

### 9.5 A guest grant discloses the granting team's name

Sharing one sheet with an outside guest puts a row in their directory
carrying **the granting team's name**.

**Ruled: keep the behaviour, disclose it in the share UI.** The alternative
— guest grants producing no directory row — removes the reason the
directory exists. What is wrong is an emergent property nobody decided. The
share dialog says, in words, who will be able to see the team's name.
(docs-e6, 2026-08-30.)

### 9.6 Re-keying, and the pepper's blast radius

The directory is keyed on `HMAC(pepper, lowercased email)`.

**Email change.** The person's HMAC changes and their rows orphan. Call 5 is
a cursor-based rekey journal that instances pull, carrying `old_hmac →
new_hmac`. The home learns both addresses transiently and stores neither.

**Resolved by the home spec, 2026-08-30: instances hold readable addresses;
the home never does.** The sharer typed the address, so the team already
knows it — storing it in that team's instance leaks nothing the team lacks,
whereas the home holding every address is the aggregation risk. It also
keeps share notification possible, since nobody can email a hash.

**The journal keys on person refs, not hashes.** An instance holds no hashes
and can compute none, so a hash-keyed journal would have matched nothing it
had. The ref arrives in call 2's response and is also the assertion's `sub`,
so one identifier links assertion to grant to journal. Rows carry the sealed
new address alongside it, scoped per consumer, with a per-consumer retention
TTL rather than a slowest-cursor rule.

**The pepper is a suite-wide secret with per-team blast radius, and that is
the wrong shape.** Call 2 has the *instance* compute the HMAC, so every
instance holds the pepper — and one compromised instance enables offline
dictionary attacks against the entire suite directory. That inverts factor
VIII, where the point of instance-per-team is that a compromise is bounded
to one team.

**Ruled: instances never hold the pepper.** Call 2 sends the address over
the authenticated channel and **the home hashes it**, learning it
transiently exactly as it already does at sign-in (§9.1). This costs the
home a transient plaintext address it already handles, and removes a
suite-wide secret from every instance on the estate.

**Pepper rotation** then becomes the home's business alone. Under §9.1.1 a
rotation is a convenience outage rather than data loss: access is
unaffected, and the directory refills as instances re-upsert. Rotation
requires a **re-announce sweep**, not merely outbox retry — retry
redelivers *failed* upserts and does not re-enumerate every existing grant.
That sweep is a named operator verb, not an emergent behaviour.

### 9.7 The RP rename drill — scheduled, then made unnecessary

Ruling 11 put the RP on a platform-default host provisionally, which
**scheduled** this rename rather than avoiding it. Ruling 18 then ran it
early, for free.

**The hostnames, settled 2026-09-03.** The suite's primary domain is
**`oficina.works`**, registered and ours. The credential origin is
**`id.oficina.works`** — that is the RP — and a Sheets instance is
**`<team>.sheets.oficina.works`**, one team to one hostname. Hosting stays
on Carloku, with `oficina.works` as a custom domain in front of it.

This replaces the provisional `<app>.<sqid>.<apps-domain>` on the hosted
apps-domain `oncarlos.com` under account sqid **`bds`**
(`console.carloku.com/a/bds/`). `bds` was a hosting artifact and does not
survive the move. Earlier drafts naming `carloku.com` — the console brand —
were wrong about the old domain too; none of it matters now except as the
`LegacyRPID` value, if it is ever needed.

**The drill did not have to run.** At the time of the move no `passkey.Config`
existed in any app in the suite — sign-in was magic-link throughout — so no
credential had ever been scoped to an `oncarlos.com` host. The window below
is sized for the *next* move, not this one, and its cost is the argument for
having moved before passkeys shipped rather than after.

**Never widen the RP ID to a registrable suffix.** WebAuthn permits an RP ID
that is a suffix of the origin's hostname, so `oficina.works` would be
*accepted* for `id.oficina.works`. The objection used to be other Carloku
tenants on a shared platform domain; on a zone we own it is that such an RP
ID is valid at every host in the zone — this instance included — so the
credential the home holds would be presentable straight to a Sheets
instance, which is the separation ruling 15 exists to keep. The RP ID is the
full host, always.

**But the mechanism already exists.** `passkey.Config.LegacyRPID` —
verified at source — is *"a relying party this instance used to be, accepted
for assertions (never registration) so a hostname move doesn't strand
enrolled keys."* That is the accepted-for-assertion-never-registration
crossover this spec described as something to invent. (Found by docs-e6,
2026-08-30, verified by this session.)

So the drill is: set `LegacyRPID` to the old host, run the window, drop it.
Not a multi-phase invention.

**The window is still sized by the guest tail, not by active members.** The
population least likely to survive is the third-party guest who signed in
once, months ago, to comment on one sheet — no ongoing relationship, no
reason to be paying attention, invisible to any plan that assumes people
re-authenticate when nudged. The window is measured in months. **A drill
that quietly drops the guests is a drill that failed while reporting
success.** `LegacyRPID` removes the machinery cost; it does not shorten the
window.

**Trigger:** a real suite domain exists.

---

## 9a. Consumed suite rulings

The suite sharing doctrine (`oficina/suite`) binds this spec. Read against
**revision 4, commit `210312f`**.

| # | What it settles for Sheets |
|---|---|
| 1 | The team is the boundary. A team's instances across every app are one trust domain, so a same-team memoria may hold team-visible sheets. |
| 2 | Sharing is membership across teams. Nothing crosses a team label. |
| 3 | Custody and authorization are two rules, and authorization is never cached at any scope. |
| 5 | Two shapes, one switch. The sibling-token switch is independent of whether a home is present. |
| 6 | The egress sentence, suite-wide. |
| 7 | One contract, one module, fixtures as the interface. |
| 8 | Two credentials. A consumer token proves a same-team sibling; an assertion proves a person. |
| 9 | A write names its real actor — the rule §8.3's ruling 12 already served. |
| 12 | Membership is the home's own record. A directory row never grants team standing. |
| 14 | Custody is bounded at acquisition and retired by the feed. |
| 15 | Three rungs and a read-only link. Sheets ruling 12 is the original this adopts. |

Sheets serves **S1, S2, S3 and S6** wherever sibling consumer tokens are in
its platform config. S6 is Sheets' alone: an enumerated aggregate over cell
refs, capped, never expression text, so memoria can total a column without
holding the sheet.

**Not yet consumed:** S8 write-as-self (suite §7.2) needs a designated-area
grant in the share UI, and the hand-off (suite §7.1) needs a receipt route
with pending state. Neither is specified here.

**One thing the doctrine took from Sheets rather than the other way round.**
Ruling 12 — link sharing is viewer-or-commenter, never editor — is now suite
ruling 15's read-only half, and the reason Sheets gave for it is the reason
the suite took it: every version row carries an actor, and an anonymous
editor writes unattributed rows into history. A draft of the doctrine
proposed an anonymous link editor and had to be corrected against this
spec.

---

## 10. Design system

`day` theme (ruling 7). rastrillo tokens.

### 10.1 Markup: classes now, attributes after the dual-grammar release

**The first draft got this backwards and it would have shipped an unstyled
app.** Verified against `origin/main` post-design-system-v2 (2026-08-30):
`ui/tokens.css` contains **zero bare `rst-*` attribute selectors** against
~528 `.rst-` class selector occurrences, and the rake line `::after` is
still live. (It *does* carry attribute selectors for `[data-tone=]`,
`[aria-*]` and `[type=]` — an earlier wording said "zero attribute
selectors", which was imprecise and would not survive being checked. The
conclusion is unchanged.) The attribute grammar is "RULED, **not yet
started**", and its staged path is explicitly *ship a `tokens.css` matching
both spellings → codemod → drop the class selectors.*

So: **write `class="rst-*"` today.** When the dual-grammar release ships,
run the codemod. Writing `rst-btn="primary compact"` now renders unstyled
buttons against a frozen `static/tokens.css`, which is the worst of both
spellings.

This session over-applied advice from the design-system session. Their
advice — don't invest in the losing spelling — was about *investment*, not
about writing markup no shipped stylesheet matches.

**`tokens.css` is frozen into `static/` at scaffold time while partials
upgrade with the module.** Re-copy deliberately on every rastrillo upgrade.
`rastrillo doctor` was proposed to make this mechanical, with this app as
its named consumer; adopt it the day it lands and delete the manual step.

**Design system v2 is a dated dependency, not an assumption.** Everything
this spec takes from it is either *shipped* (verified in `ui` at
`origin/main`) or *staged* (ruled in rastrillo's spec, not in released
code). Shipped: `ui.TokensCSS()`, the class vocabulary, the three themes.
Staged, and therefore unavailable to us today: the bare-attribute grammar,
`--rst-header-rule`, `Pair`, `Allocate`, `rastrillo doctor`. Neither of the
two consuming sessions could tell a ruling from a released API by reading
the design-system spec, which is why this paragraph exists rather than a
reference to it.

**Upstream recording is a risk, not a fact.** `rastrillo doctor`, the
`(hue, chroma, background) → {fill, on-fill}` pair function and the
allocation entry point exist in this spec and in cross-session messages, and
in **zero files in the rastrillo repo**. Cross-session agreements that live
only downstream are agreements the upstream can silently never ship. **Task:
get each recorded upstream as an issue or spec section; until then treat all
three as unavailable and keep the §10.3 shim.**

### 10.2 The four jank cuts, made concrete

- **Top bar is title, Share, avatar.** No upgrade button, no app waffle, no
  AI sparkle, no right-hand rail.
- **Toolbar is contextual and grouped** — around ten controls that change
  with selection. The long tail lives in a command palette.
- **Menus go shallow:** File, Edit, Insert, Format, Data, Help.
- **Home is your sheets by recency and a New button.** No template carousel.

The app shell is **app-local**. Shared primitives are not.

**Destination: `oficina/estilo`** — a CARLOS suite design-system variant,
**not** rastrillo's `ui` (Paul, 2026-08-30; repo created same day). A
variant depends on `ui`, keeps its bones — tokens, the partial vocabulary,
the accessibility floors — and adds components, themes and its own gallery.
Not a fork, and more than a theme, since a theme cannot add a component.
`cmd/dsgen` went public 2026-08-30 and generates a gallery for any module's
own vocabulary; **read it before anyone writes a second one.**

**The distinction that produced this, worth keeping** (ui-guidelines-52,
2026-08-30): *two callers at design time is the bar for **extraction**, and
it says nothing about **destination**.* Both consuming sessions were told
"propose it to the library" and applied the right bar to the wrong question,
because the first answer looked like it settled the second. It will recur
for every piece either app hands upward.

**Primitives split with Docs:** we own **toolbar groups**; Docs owns
inspector panel, presence stack and **comment thread** (their anchoring case
is harder, so it owns the design and we consume it — a cross-repo dependency
inside slice one, noted in §13's risk). Same standards either way: twelve
locales, the script-required line stated per primitive, 24×24 targets, 320px
reflow, colour never carrying meaning alone.

**The colour engine is unaffected and stays in `ui`.** `Pair` and allocation
are contrast arithmetic against floors the framework already gates — a
function, not a component — so our cell fills and Docs' highlights are
callers of the same thing regardless of where their components live.

### 10.3 Cell fill colour

Stored as **OKLCH intent** (hue + chroma), resolved to a `{fill, on-fill}`
**pair** — the pair must clear 4.5:1, because a user picks a fill and then
types in it. Imported hex is retained alongside for lossless XLSX
round-trips, dropped when a user re-picks (ruling 8).

**Signature takes a literal background, not a scheme:**
`(hue, chroma, background) → {fill, on-fill}`. A `scheme` parameter could
only mean "resolve against the theme's surface token", which is wrong
whenever the cell sits on something else — conditional formatting paints
under a user fill, selection tints, frozen headers. Passing the real
background is what makes our contrast gate assert the pair the cell actually
shows, rather than certifying the wrong thing while passing.

**Allocation is a second entry point**, a pure function of a **canonically
ordered** key set (member id), never of arrival order — otherwise two
clients viewing the same sheet allocate differently and the same person is
green for one viewer and blue for another. The `avoid` set is a parameter,
so collisions occur only past capacity. Sheets is a caller for **presence
cursors** (§7). Stability of a person's colour is best-effort; separation is
the hard guarantee.

**Until `ui` ships these: a deliberately crippled shim.** A hardcoded table
of six to eight intents with hand-computed pairs, behind the corrected
signature, gated by a contrast test that **copies rastrillo's arithmetic
verbatim** — not merely models it, or the gates diverge by a rounding
decision and swapping the table changes the test's meaning silently. Header
comment names it a placeholder and links §10.1's upstream-recording task as
its deletion trigger.

**Colour never carries meaning alone.** A fill is decoration; whatever it
*means* must survive greyscale.

### 10.4 Accessibility

**The grid claims 1.4.10's two-dimensional-content exemption.** A data table
is the WG's canonical example and a spreadsheet is that case at its most
literal. So the grid scrolls in both axes at 320px, and **every other
surface must reflow or it is a bug, not an exemption** — falsifiable, which
is why it holds.

Target size 24×24 throughout, including a 24px hit area around the fill
handle's small visual. **Every drag-only affordance gets a menu
equivalent**, which buys the scriptless path at the same time. We do not
lean on 2.5.8's spacing exception.

---

## 11. XLSX and CSV

`excelize` both directions. Import maps incoming hex fills to the nearest
offered intent while retaining the original hex (§10.3); export resolves
intent to concrete hex.

A **golden round-trip corpus** is a gate: import → export → import must be
stable, and our output must open cleanly in Excel and LibreOffice.

Import runs as a rastrillo background job, 303 to `/jobs/{id}`, with
`StatusPage`/`Fragment`/`Events` mounted behind `sessions.Require`. The
status page works scriptless. Jobs are idempotent — a deploy ends them
mid-flight — and `location` is a server-built path, never user input. **Job
error text carries coordinates and codes, never content (§2.4).**

Uploaded and generated files go to `blobs.Sealed` with
`ContentType: application/octet-stream` (§2.3).

**Import's HTTP surface built 2026-09-02.** The converter and its
round-trip corpus predated this by two days; what did not exist was any
way to reach them — no route, no upload, no job, and a `PRODUCT.md` that
listed import among the things "built and working". `POST /sheets/import`
now takes a workbook, makes the sheet, and runs `xlsx.Import` as a
`jobs` job that 303s to `/jobs/{id}` and lands on the finished sheet.
Three things the design had not had to decide, decided here:

- **The sheet is created before the job, and removed if the job fails.**
  `jobs.Start` needs the finished location up front and that location
  must be server-built, so the sheet id has to exist first. The cost is
  a sheet that briefly exists without contents; a failed import
  soft-deletes it, because a half-imported sheet looks complete, sits in
  a list the whole team can see, and its only contradiction is a status
  page nobody else opened.
- **The upload is not retained at all.** The rule above governs keeping
  one; not keeping it satisfies the same concern more simply, and no
  plaintext workbook reaches storage in any form. `xlsx.StoreSealed` is
  still there for the task that wants the original back.
- **Job error text is replaced, never wrapped.** rastrillo's runner
  slogs the error a job returns, and `carlos logs` is platform-held. So
  `importFailure` builds fresh text from a coordinate and a code, and
  `xlsx.CellError` carries that coordinate as data — its `Error()` omits
  its own cause by construction, so printing one cannot leak a cell even
  by accident.

**Drag and drop is the form.** The list's drop zone fills the same file
input and submits the same form, so there is one route and one code
path, and §6's scriptless floor holds without a second implementation to
keep in step with the first.

### 11.1 A retained fill can be unreadable, and that is correct

Retaining an imported fill's original hex (§10.3) means we also carry the
author's font colour, and the pair may fail WCAG AA. This is not
hypothetical: Excel's own light-yellow conditional-formatting preset ships
`#9C6500` on `#FFEB9C` — **4.12:1, below the 4.5:1 floor** — confirmed
against an independent source, so it is what the incumbent *offers by
default*, not merely what a careless user can construct.

**We render it as authored.** Faithfully displaying a document somebody else
wrote is a different act from generating a colour, and silently correcting
imported formatting would lie about the file — the same class of wrong as
inventing a font colour on export (§10.3).

**So the guarantee is narrower than it reads.** Our offered set cannot
*produce* an unreadable pairing; a cell in this app can nonetheless *be*
one, when the file said so. Anyone reading the first claim as the second
will eventually find a failing cell and file a bug against a guarantee that
is working exactly as specified.

**The obligation that follows is surfacing, not overriding** — telling a
person their imported file contains a pairing that fails, and leaving the
decision to them. That was grid work and belonged to plan 2 (§14).

**Built 2026-09-01.** It needed one thing this spec had assumed: the
authored *ink*. `readStyle` retained fills and never font colours, so the
pair §11.1 is about could not exist in our data at all — a fill alone
cannot fail, since the other half was always our own theme's. `Format`
gained `InkHex`, XLSX import and export carry it, and the grid and table
views name the failing cells in a notice. No cell is altered.

*(Found 2026-08-31 by ui-guidelines-52 while building rastrillo's colour
engine, from Excel's documented preset fill/ink pairs. Related: `#0000FF`
carries black ink at 2.44:1 and cannot be a wash at all — the case where a
user's chosen hue and their ink genuinely conflict.)*

---

## 12. Testing gates

From the first commit:

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.**
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).
11. **Computed value to painted output.** A browser drive reads the *text
    actually painted* in cells and compares it against the golden corpus's
    expected display strings — crossing from the engine to the screen.

**Why gate 11 exists, and why gates 4 and 7 do not cover it** (docs-e6,
2026-08-30). Gate 4 proves the Go engine matches LibreOffice. Gate 7 proves
the client runs. Neither proves that what a person *sees* is what the engine
computed — and a gate that compares two of our own layers against each other
can stay green forever while the product is wrong. Docs shipped exactly that
bug: a conformance test comparing two runtimes of the same module, proving
determinism, which WASM guarantees anyway, and never proving the thing the
rule was about.

The rule to keep: **at least one gate must cross from computed value to
painted output.** A suite of gates that all live inside one layer is a suite
that agrees with itself.

Review on a deployed canary, never localhost. Verify a deploy with
`X-Carlos-Version` on the canonical host after the watch — never a 200.

### 12.1 Review — RULED 2026-08-30 by Paul

**Every spec and every branch is reviewed by the highest-capability model
available before it is approved or merged.** Cheaper models draft; the most
capable available reviews. A review by a weaker model than the author is not
a review.

Specs get a pass before human approval and again after any material change
to a settled decision. Branches get one before the PR opens and again after
any force-push bigger than a rebase. **The reviewing model is recorded** —
in this spec's status line, and in the branch discussion for a branch, which
survives a squash where a PR body does not. A review nobody can attribute is
a review nobody can check.

The highest-capability model is **discovered, not hardcoded**. A session
that cannot reach a model at least as capable as the author's says so and
waits.

---

## 13. Sequencing

1. Skeleton, `AGENTS.md`, auth
2. Storage and sealing (gates 1, 9, 10 live from here)
3. Formula engine
4. Grid
5. XLSX / CSV
6. Sharing, and the home contract
7. Live sync
8. Version history
9. Sort, filter, conditional formatting
10. Comments
11. **Charts**

**Scope honesty, from the review — narrowing confirmed by Paul,
2026-08-30 (ruling 17).** Ruling 5 stands; what it costs: **three of the eleven items are subsystems, not features** — charts,
version history (restore semantics, replay, retention), and conditional
formatting (a second expression evaluator plus a rules UI). Naming charts as
the sole slip candidate understated the risk.

So the slip order is written down rather than discovered: **charts first,
conditional formatting second.**

**The criterion is "its absence leaves a coherent product", not "it has no
dependents"** (docs-e6, 2026-08-30). The two usually agree, and where they
disagree the first is the one that matters. Charts passes on the stronger
reading: a spreadsheet without charts is still a spreadsheet. Conditional
formatting is second because a spreadsheet without it is also still a
spreadsheet, merely a duller one — whereas a spreadsheet without version
history is one nobody trusts with real data, which is why history is not on
this list at all. Version history is not a slip candidate
because §4.3's log design is load-bearing for live sync — but **its minimal
form is named now**: view history and restore a *cell* or a *range*.
Restore-a-whole-sheet is the extension, not the baseline.

**Progress is measured by gates green, not by items ticked.**

**One cross-repo dependency inside the slice:** comments consume Docs'
comment-thread primitive (§10.2). If Docs slips, we ship comments with a
cell-anchored local implementation and adopt theirs later — decided now so
it is a fallback rather than a scramble.

---

## 14. Deliberately not built yet

| Thing | Trigger |
|---|---|
| Pegamento's real vault facet (sealed team names, §9.2) | The vault facet ships with a recovery story |
| Moving the provisioning queue out of the home (§9.4) | The home spec rules on it |
| **Destructive sort — physically reordering rows** | calc's dependency graph is keyed on POSITION (`store/handle.go`'s `cellKey` builds `CellKey{rowPos+1, colPos+1}`), so moving a row changes what every existing formula points at, silently. Plan 1's review flagged this as "valid only until rows move". Trigger: **calc's key space rebuilt on stable ids**, which is a subsystem and not a feature. Sort and filter ship non-destructively meanwhile — they reorder what is displayed and never what is stored, which is correct rather than a stopgap. |
| **Importing a workbook's second and later worksheets** | `xlsx.Import` reads `GetSheetList()[0]` and stops, so a three-worksheet file becomes a one-tab sheet. Stated in the import form's own hint rather than discovered afterwards (§11), because a workbook that arrives looking whole is the quiet kind of wrong PRODUCT.md's first principle forbids. Trigger: **a second tab is reachable in the UI**. Sheets already has a `Tab` table and `SheetSummary.TabID` already means "the first tab", but nothing creates, names, lists or switches tabs — so importing into tabs today would write rows no screen can open, which is worse than importing one worksheet honestly. |
| Pivot tables | Asked for twice |
| Rich presence beyond who's-here | Teams above ~5 concurrent editors |
| Offline editing | — |
| Mobile-native apps | — |
| `client` shape for anything but the grid | A surface genuinely needs client-held keys |
| Print / PDF export | Asked for. docs-e6 keeps its layout engine's page model generic over content boxes so this stays open: paginated grid, repeating header rows, scale-to-width, explicit page breaks, charts as vector |
| Live sheet range embedded in a doc | A known future crossing of two collaboration models |
| ~~Formatting from the scriptless table view~~ | **Closed 2026-09-04.** PRODUCT.md's second principle says the scriptless path is the product, not the fallback, and this row was the debt while it went unmet. Bold, italic, alignment, number format, fill and text colour reached the table view on 2026-09-02; **column width was the last one left** and had no form behind it at all — `handleSizePost` decoded JSON only. It now takes a form the same way `handleFormatPost` does: `sizeFromForm` converges on the same `sizeWrite`, and the exactly-one-of-col-and-row rule, the bounds and the store call still run once, so the two decoders cannot drift into two rule sets. The control is one column picker beside the sort and filter form rather than an input in each of 26 headers. Undo for a format or a resize is still absent and still has its own row below — that is a different gap with a different trigger. |
| **Undo for formatting and for a resize** | Undo replays input events (§7), and neither a format write nor a resize appends one — so Ctrl-Z after bolding undoes the last VALUE edit instead, which is surprising in the way this app tries not to be. The UI therefore does not offer undo for either. Trigger: **a format event kind**, which changes the undo model and is not a thing to do in passing. |
| ~~Surfacing an imported fill/ink pairing that fails WCAG AA~~ | **Trigger fired and built, 2026-09-01.** Plan 2's grid work landed, and with it `Format.InkHex` (the authored font colour, which nothing retained before), a WCAG ratio, and a notice naming the failing cells on both the grid and the table view. Surfaced, never overridden — §11.1's ruling stands. |

---

## 15. Open

- **Assertion verification is the remaining shared gap, and it is smaller
  than the one it replaced.** Ruling 15 dissolves the cross-origin WebAuthn
  problem — the ceremony finishes at the home before any assertion exists,
  so nothing needs handing across origins. What is left is that an instance
  must verify a signed identity assertion, and rastrillo has no helper for
  it. Docs needs the identical thing. To be settled jointly and filed once.
- **Upstream recording** of `rastrillo doctor`, the pair function and
  allocation (§10.1). Until done, all three are unavailable.
- **The platform provisioning credential** — filed as `carlos/platform` #4.
- **Repo move verbs** — filed as `amadan/amadan` #28.
- **A real suite domain** — triggers §9.7.
- **The home spec itself** — drafted next, in its own repo.
- **Language neutrality upstream.** A discussion proposing CARLOS picks the
  language per app rather than assuming Go. Nothing here depends on the
  outcome; Go stays right for this app either way.

---

## Appendix A — assertion verification, filing draft (awaiting Paul's nod)

**Destination: `github.com/rastrilloorg/rastrillo`.** Rastrillo has no
amadan remote, so this is a GitHub issue rather than an `amadan discuss`,
and it needs Paul's go-ahead. Drafted jointly with docs-e6; both consumer
paragraphs are below.

**Title: An instance needs to verify a signed identity assertion**

Instance-per-account makes single sign-in a day-one problem — `decisions.md`
says it directly: *"three instances must not mean three credentials."* Two
apps have now independently arrived at the same answer, which is why this is
a framework ask rather than an app one.

The shape both reached: **one identity origin holds the credentials and runs
the ceremony; instances receive identity as a signed assertion and verify a
signature.** Instances never run WebAuthn, never hold a passkey RP, never
see a password.

This is Tito's recorded pattern: *identity lives beside the router, outside
the per-account blast radius, and reaches instances only as **signed**
assertions — headers are stripped-and-re-minted, and signing (not stripping
alone) is what stops a peer socket from forging them.*

**What rastrillo has:** `sessions`, `auth`, `password`, `passkey` — every
one authenticating *at* the app. **What is missing:** anything letting an
app say "I trust assertions minted by this identity origin; verify this one
and mint a session from it."

**Why the framework rather than two apps.** The parenthetical in Tito's rule
is the point — *signing, not stripping alone* — and the failure modes do not
announce themselves:

- **Audience binding.** An assertion must name the instance it is for, or
  one minted for team A replays against team B. Both apps are multi-tenant
  by construction, so every deployment has thousands of sibling instances to
  replay against. This is the one most likely to differ subtly between two
  independent implementations.
- **Replay.** Single-use identifier plus short expiry — seconds, not
  minutes; the long-lived thing is the session minted afterwards.
- **Key distribution and rotation**, without a flag day.
- **Algorithm confusion.**

That is squarely "supplies what is hard to get right twice", which is how
rastrillo describes its own scope — and it is literally twice: two apps,
same week, same pattern, neither having seen the other's code.

**Rough shape**, not a design: verify against pinned or fetched public keys,
check audience against this instance's own identity, enforce single-use and
expiry, return a Subject plus claims, hand off to `sessions.SignIn` — which
is the whole contract the existing identity plugins have with the core. A
fourth plugin, not a new seam.

**One thing to state positively, because a reviewer will otherwise propose
it as an improvement: assertions carry identity, never authorization.** A
guest's assertion is the same shape as an owner's; the instance decides
capability from its own grants. Encoding capability would require the
identity origin to hold an authorization model, and a leaked assertion buys
exactly what a stolen session buys anyway — so narrowing reduces no blast
radius while adding a second assertion type and a second audience-binding
surface.

**Named consumers:**

- **Sheets (`oficina/sheets`)** — the home's credential origin is the sole
  RP, confirmed by Paul 2026-08-30; instances verify assertions and never
  enroll passkeys.
- **Docs (`oficina/docs`)** — same shape, same week, independently reached.
  Docs adds one operational requirement Sheets does not surface: its guest
  population is dominated by single-purpose visitors who signed in once to
  comment and never returned, so the single-use replay store churns against
  a population far larger than the membership list and unbounded in time.
  **The store must be self-pruning, sized by assertion lifetime rather than
  by user count** — a naive implementation is correct on day one and a
  growing table for the life of the deployment. That belongs in the helper,
  because "single-use" is the helper's promise to keep.
