# 🤖 Sheets

A CARLOS spreadsheet. One instance per team.

This file is binding. Where it disagrees with a general skill, this file
wins. Where it disagrees with the design spec, the spec wins and this file
is wrong — fix it in the same commit. Both files live here, so that is a
rule this repository can actually keep.

Where the design spec disagrees with the suite sharing doctrine
(`oficina/suite`), the doctrine wins and **the spec is wrong — fix it by
citing the ruling, not by restating it**. That cannot be one commit, because
the doctrine is another repository; a cross-repository request goes through
`amadan discuss` on the repo whose text must change.

**The design:** `docs/superpowers/specs/2026-08-30-sheets-design.md`. Its
§1 is the settled-decisions table: dated, attributed, with the rejected
alternative beside each. Don't relitigate one in a drive-by.

---

## 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 the spec's
§2 names every leak it produces. What the rule forbids absolutely is a
plaintext value reaching the replicated SQLite file or object storage.

A test greps raw SQLite bytes and bucket objects for plaintext from the
first commit. `plaintextExempt` starts empty; any entry is dated, justified,
and listed in the README.

**We never claim "we can't read your data."** A compromised live host can
read any open sheet. The README says so and the UI never implies otherwise.

---

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

Not the model that happened to write it, and not whichever model the
session started on. Cheaper models draft; the most capable one available
reviews. A review by a weaker model than the author is not a review.

Mechanically:

- **Specs** — a review pass before the spec is presented for human approval,
  and again after any material change to a settled decision.
- **Branches** — a review pass before the PR is opened, and again after any
  force-push that changes more than a rebase.
- **Record which model reviewed**, 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.** At the time
of writing that is Opus; naming a model here without saying how to re-derive
it is how this rule goes stale silently. The check is: what is the most
capable model this session can actually reach right now. If a session cannot
reach one at least as capable as the author's, it says so and waits rather
than passing a weaker review off as done.

This is a floor, not a ceiling — an extra pair of eyes, human or model, is
always welcome on top.

---

## Shipping cadence — RULED 2026-08-30 by Paul

**Everything happens on a worktree, on a branch.** Never directly on
`main`, and never in the primary checkout.

1. **Worktree per session.** One isolated workspace per piece of work.
2. **Branch per piece of work**, cut from current `main`.
3. **Commit regularly and push to the branch.** Small commits that tell the
   story, pushed as they land — not one heroic commit at the end. A branch
   that only exists locally is work nobody can see or recover.
4. **Squash-merge to `main`** when, and only when, all three hold:
   - the work is **done** — not "done except",
   - it is **approved** — including the §Review pass by the
     highest-capability model available,
   - **CI is green** — actually green, checked, not assumed.

Any one of the three missing means it does not merge. "CI was green
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

CARLOS's first principle: the design doc precedes the build, and its
decisions are taken. If one proves wrong, **stop and flag it** rather than
quietly redesigning around it.

Keep the spec's "Deliberately not built yet" table honest. Every deferred
thing has a written trigger. A thing deferred without a trigger is a thing
forgotten.

---

## Mechanical rules

Rules here are enforced by tests, not by memory. If you find yourself
relying on remembering one, make it a test instead.

**Data**
- **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.
- Resolve the viewer through the app's `identity` and `actor` helpers. Local
  sessions retain verified addresses; opt-in Home sessions contain opaque Home
  subjects and must resolve through the shared connection to existing members.
  Never create members from Home subjects or infer links from matching addresses.
  Never use `sessions.UserID`: it does not resolve either identity shape.
- Home links require independent fresh local and Home proofs plus confirmation.
  Recheck existing admission and sheet grants on every Home request. Member IDs
  are AUTOINCREMENT and must never be reassigned; any future deletion path must
  remove the member's `home_identity_links` rows in the same transaction.
- Migrations are additive-only. New code over an old DB is always safe.
  Never delete data to update.
- **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.
- **Both vendored stylesheets paint in literals, and every rule the app
  can render must be restated in `sheets.css`.** `TestVendorPaintIsRestated`
  holds the list and checks it in both directions — that each vendored
  selector still exists (v5 renamed every `.jexcel` to `.jss_*` once
  already) and that we restate it. Rules the app cannot render are named
  in that test with a checkable reason, never silently dropped.
  Two more guards sit beside it: no colour literal may appear in
  `sheets.css` at all, and `sheets.css` must stay linked before the head
  block that carries the vendored links.
- **Restate at `> tbody > tr > td.foo`, never at the library's own
  `.jss_worksheet .foo`.** Two independent reasons, and the second is the
  one that bites: our grid-line rule is one specificity step higher than
  the library's, *and* `sheet.html` links the vendored stylesheets into a
  head block that `layout.html` renders after `sheets.css`, so the
  library is later in source order and an equal-specificity restatement
  loses on order alone. Ours must be strictly higher, never merely equal.
  Written short, a restatement parses, uses a token, and paints nothing —
  which is how the grid's selection outline went missing in dark for the
  whole of the `core` branch.
- **Write the attribute grammar: `rst-btn="primary compact"`, not
  `class="rst-btn rst-btn--primary"`.** This reverses the earlier rule in
  this file, which was written against v0.20.0 where the attribute
  spelling genuinely did not exist. It landed in v0.21.0 and is complete
  in the pinned v0.23.0: measured against
  `$(go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.23.0/ui/tokens.css`,
  every class selector has an attribute twin, and rastrillo enforces that
  itself with `TestEveryClassSelectorHasAnAttributeTwin`. Both spellings
  are specificity `(0,1,0)`, so pairing changes no cascade. The class
  spelling is scheduled for removal before 1.0.
  - The grammar: a bare attribute is the KIND (`rst-callout`); a token in
    its value is a VARIANT, matched with `~=` so values compose like a
    class list; a PART is its own flat attribute (`rst-callout-body`),
    never a value.
  - **Utilities stay classes.** Seven names are class-only by design, and
    correctly so: `rst-grow`, `rst-mono`, `rst-sr-only`, `rst-m-hide`,
    `rst-danger`, `rst-nm`, `rst-cell-mut`. An attribute says what a thing
    *is*; a class says what is being applied to it.
  - `rst-tone` pairs with `data-tone` rather than with a class.
  - Adopt per screen, never per element — mixed spellings inside one
    component match neither.
- **rastrillo is `amadan.net/rastrillo/rastrillo`.** The old path,
  `github.com/carlosframework/rastrillo`, stops at v0.23.0: code importing
  it gets no fix, and its own checks cannot tell (Sheets zoomed iOS on
  every tap for a month that way). `TestNoPreMoveRastrillo` allows one
  import of it, native.go's bridge to rastrillo/native's server, until
  that server moves. The CLI is pinned as a `tool` in go.mod: run it as
  `go tool rastrillo …`, never a `rastrillo` from PATH.
- **After `go tool rastrillo doctor --fix`, delete the scripts it adds**
  (`static/rastrillo.js`, `select.js`, `datetime.js`, `calendar.js`).
  Sheets serves estilo's shim and busy.js from memory and uses no other
  rastrillo script; the shim doctor adds at this pin still carries the
  old busy rule, which fights estilo's. `TestBusyRuleIsLinkedAndServed`
  fails if one is left behind.
- **Run `rastrillo doctor` on every rastrillo upgrade**, and
  `rastrillo doctor --fix` to re-copy what drifted. `static/tokens.css` is
  frozen at scaffold time while the partials it styles keep upgrading with
  the module, so an app can run new markup against old CSS and see nothing
  worse than a slightly wrong screen. `doctor` shipped in v0.22.0 (verified:
  `rastrillo doctor --help` offers `-fix`, `-force`, `-theme`). It reports a
  version mismatch as a mismatch rather than as drift and refuses `--fix`
  across one, since copying newer assets into an app built against an older
  framework manufactures the fault it is looking for. **So upgrade the
  `doctor` binary alongside the module** — a `doctor` one release behind
  compares against its own library copy and calls the upgrade you just did
  the drift.
- **Theme bytes come from the Estilo module pin.** There is no app-local
  `static/theme.css` to copy. On framework upgrades, update the Estilo pin
  to the same released `day` body and run the theme asset tests. Estilo's
  byte-for-byte test and this app's `theme_assets_test.go` detect drift;
  `rastrillo doctor` reports the absent theme as not compared. Its token
  checks still apply. Do not recreate an embedded theme file that the
  served filesystem would shadow. This matters at v0.23.0 because tokens
  require `--rst-header-rule`; an older theme silently loses the rule.
- Don't build on `.rst-page-header::after` — the rake line is retired in
  favour of `--rst-header-rule` (rastrillo v0.23.0, design doc §6-v2.2).
  It was retired because, built from the same parts in the same order, it
  was indistinguishable from a determinate progress bar at 12%. The rule
  is decorative and carries **no** contrast floor: it is deliberately
  absent from rastrillo's contrast pair table, and a later pass must not
  argue it up to 1.4.11's 3:1.
- 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
  a bug.
- Review on a deployed canary, never localhost.
- **Deploy with `carlos deploy`, never `carlos ship` on its own.** `ship`
  publishes a release and stops there: the release sits unpromoted, no
  channel points at it, and nothing serves it. `deploy` ships, promotes
  and waits for the URL — one command, and it is the one that tells you
  when it is actually live. (Paul, 2026-08-31.)
- **Build shipping binaries with `make release`, never a bare `go build`.**
  It stamps `rastrillo.BuildVersion` via `-ldflags -X` and refuses a dirty
  tree. An unstamped binary reports `dev` from `/api/version` forever.
- A 200 is not a deploy verification, and neither is `X-Carlos-Version`.
  That header is the **router's adopted release**; it flips the moment the
  promote lands and says nothing about which binary is running. `GET
  /api/version` is served by the process itself. Check the process, not
  the edge.
- A promote is not a deploy. Until the process cycles, the old binary
  serves — measured at over 90 seconds here on three consecutive deploys,
  and about ten on another app, so poll rather than sample. A single early
  read looks like a failed deploy; a single read against an unstamped
  binary reports `dev` and looks like a successful one.
- The two facts only disagree when the binary is stamped. Unstamped, they
  are structurally incapable of disagreeing, so a check comparing them has
  no failing case and proves nothing while passing.

---

## Authorship

🤖 / 👨 markers, `Co-Authored-By: Claude …` trailers, published prompt and
carbon ledgers. AI is a guest with a name tag: opt-in, disclosed, and every
path works with it switched off.

## Work in a worktree, and catch main up first

Take a worktree for the work. Do not work in the shared checkout.

    git -C <repo> fetch origin
    git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main

Both halves matter, for different reasons.

**The worktree**, because several sessions share this machine and some
share this repository, and a checkout has one HEAD, one index and one
working tree between all of them. A branch switch under somebody else's
uncommitted work destroys it with nothing on screen to say so, and two
sessions editing the same paths disagree quietly instead of conflicting
loudly. A worktree gives the work its own HEAD and its own files, and
costs a directory.

**From `origin/main`, after a fetch** — not from whatever the checkout
happens to be sitting on. A branch cut from a stale main is a merge
conflict scheduled for later, against files somebody else has renamed in
the meantime. A branch cut from another task's branch is worse: it
carries that task's commits into yours, where they are reviewed as
though you wrote them, and the two can only be separated by hand.

Remove it when the work has landed — `git worktree remove` — so that
`git worktree list` keeps saying something true.

Written down because of 2026-09-17, in `meet`. The shared checkout was
sitting on one task's branch while holding a *different* task's feature
uncommitted on disk — some four hundred lines, a new migration among
them — put there by a session nobody else knew was working in that tree.
Two other sessions were meanwhile coordinating over the same checkout by
asking each other not to touch it, which is the arrangement a worktree
makes unnecessary. Nothing was lost. Nothing about the arrangement would
have said so if it had been, and by then the same feature existed twice,
once on each side of a rename, because the two sessions had branched
from different starting points.
