oficina / sheets Public
π€ 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.
- Worktree per session. One isolated workspace per piece of work.
- Branch per piece of work, cut from current
main. - 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.
- Squash-merge to
mainwhen, 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.Ownedorscope.OwnedByon a team-shared table. Both are single-column equality (Ownedis literallyWhere("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.Ownedis 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]anyplus a.Selectallowlist. - Resolve the viewer through the app's
identityandactorhelpers. 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 usesessions.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_linksrows 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.05produces 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 logsis 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.TestVendorPaintIsRestatedholds the list and checks it in both directions β that each vendored selector still exists (v5 renamed every.jexcelto.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 insheets.cssat all, andsheets.cssmust 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, andsheet.htmllinks the vendored stylesheets into a head block thatlayout.htmlrenders aftersheets.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 thecorebranch. - Write the attribute grammar:
rst-btn="primary compact", notclass="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 withTestEveryClassSelectorHasAnAttributeTwin. 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-tonepairs withdata-tonerather 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).TestNoPreMoveRastrilloallows one import of it, native.go's bridge to rastrillo/native's server, until that server moves. The CLI is pinned as atoolin go.mod: run it asgo tool rastrillo β¦, never arastrillofrom 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.TestBusyRuleIsLinkedAndServedfails if one is left behind. - Run
rastrillo doctoron every rastrillo upgrade, andrastrillo doctor --fixto re-copy what drifted.static/tokens.cssis 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.doctorshipped in v0.22.0 (verified:rastrillo doctor --helpoffers-fix,-force,-theme). It reports a version mismatch as a mismatch rather than as drift and refuses--fixacross one, since copying newer assets into an app built against an older framework manufactures the fault it is looking for. So upgrade thedoctorbinary alongside the module β adoctorone 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.cssto copy. On framework upgrades, update the Estilo pin to the same releaseddaybody and run the theme asset tests. Estilo's byte-for-byte test and this app'stheme_assets_test.godetect drift;rastrillo doctorreports 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, nevercarlos shipon its own.shippublishes a release and stops there: the release sits unpromoted, no channel points at it, and nothing serves it.deployships, 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 barego build. It stampsrastrillo.BuildVersionvia-ldflags -Xand refuses a dirty tree. An unstamped binary reportsdevfrom/api/versionforever. - 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/versionis 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
devand 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.