| 1 | # π€ Sheets |
| 2 | |
| 3 | A CARLOS spreadsheet. One instance per team. |
| 4 | |
| 5 | This file is binding. Where it disagrees with a general skill, this file |
| 6 | wins. Where it disagrees with the design spec, the spec wins and this file |
| 7 | is wrong β fix it in the same commit. Both files live here, so that is a |
| 8 | rule this repository can actually keep. |
| 9 | |
| 10 | Where the design spec disagrees with the suite sharing doctrine |
| 11 | (`oficina/suite`), the doctrine wins and **the spec is wrong β fix it by |
| 12 | citing the ruling, not by restating it**. That cannot be one commit, because |
| 13 | the doctrine is another repository; a cross-repository request goes through |
| 14 | `amadan discuss` on the repo whose text must change. |
| 15 | |
| 16 | **The design:** `docs/superpowers/specs/2026-08-30-sheets-design.md`. Its |
| 17 | Β§1 is the settled-decisions table: dated, attributed, with the rejected |
| 18 | alternative beside each. Don't relitigate one in a drive-by. |
| 19 | |
| 20 | --- |
| 21 | |
| 22 | ## The one rule |
| 23 | |
| 24 | > **A sheet's contents are sealed at rest. The bucket never holds a |
| 25 | > plaintext cell.** |
| 26 | |
| 27 | The instance holds the key while it runs, so the server *can* read a sheet |
| 28 | it has open. That is a deliberate softening, not an accident, and the spec's |
| 29 | Β§2 names every leak it produces. What the rule forbids absolutely is a |
| 30 | plaintext value reaching the replicated SQLite file or object storage. |
| 31 | |
| 32 | A test greps raw SQLite bytes and bucket objects for plaintext from the |
| 33 | first commit. `plaintextExempt` starts empty; any entry is dated, justified, |
| 34 | and listed in the README. |
| 35 | |
| 36 | **We never claim "we can't read your data."** A compromised live host can |
| 37 | read any open sheet. The README says so and the UI never implies otherwise. |
| 38 | |
| 39 | --- |
| 40 | |
| 41 | ## Review β RULED 2026-08-30 by Paul |
| 42 | |
| 43 | **Every spec and every branch is reviewed by the highest-capability model |
| 44 | available before it is approved or merged.** |
| 45 | |
| 46 | Not the model that happened to write it, and not whichever model the |
| 47 | session started on. Cheaper models draft; the most capable one available |
| 48 | reviews. A review by a weaker model than the author is not a review. |
| 49 | |
| 50 | Mechanically: |
| 51 | |
| 52 | - **Specs** β a review pass before the spec is presented for human approval, |
| 53 | and again after any material change to a settled decision. |
| 54 | - **Branches** β a review pass before the PR is opened, and again after any |
| 55 | force-push that changes more than a rebase. |
| 56 | - **Record which model reviewed**, in the PR body or the spec's status line. |
| 57 | A review nobody can attribute is a review nobody can check. |
| 58 | |
| 59 | **The highest-capability model is discovered, not hardcoded.** At the time |
| 60 | of writing that is Opus; naming a model here without saying how to re-derive |
| 61 | it is how this rule goes stale silently. The check is: what is the most |
| 62 | capable model this session can actually reach right now. If a session cannot |
| 63 | reach one at least as capable as the author's, it says so and waits rather |
| 64 | than passing a weaker review off as done. |
| 65 | |
| 66 | This is a floor, not a ceiling β an extra pair of eyes, human or model, is |
| 67 | always welcome on top. |
| 68 | |
| 69 | --- |
| 70 | |
| 71 | ## Shipping cadence β RULED 2026-08-30 by Paul |
| 72 | |
| 73 | **Everything happens on a worktree, on a branch.** Never directly on |
| 74 | `main`, and never in the primary checkout. |
| 75 | |
| 76 | 1. **Worktree per session.** One isolated workspace per piece of work. |
| 77 | 2. **Branch per piece of work**, cut from current `main`. |
| 78 | 3. **Commit regularly and push to the branch.** Small commits that tell the |
| 79 | story, pushed as they land β not one heroic commit at the end. A branch |
| 80 | that only exists locally is work nobody can see or recover. |
| 81 | 4. **Squash-merge to `main`** when, and only when, all three hold: |
| 82 | - the work is **done** β not "done except", |
| 83 | - it is **approved** β including the Β§Review pass by the |
| 84 | highest-capability model available, |
| 85 | - **CI is green** β actually green, checked, not assumed. |
| 86 | |
| 87 | Any one of the three missing means it does not merge. "CI was green |
| 88 | before the last push" is not CI being green. |
| 89 | |
| 90 | Review happens on a **deployed canary**, never localhost. Shared hosts only |
| 91 | ever run merged `main`. |
| 92 | |
| 93 | **Every branch gets a discussion, and that discussion IS the review |
| 94 | record** β including which model reviewed it (Β§Review). A discussion |
| 95 | survives a squash where a PR body does not. Tasks are a checklist, not a |
| 96 | log: commentary belongs in the discussion. |
| 97 | |
| 98 | **A refusal gets surfaced in the discussion, never routed around.** Straight |
| 99 | from amadan's own house rules, and the rule most likely to be quietly |
| 100 | ignored. |
| 101 | |
| 102 | --- |
| 103 | |
| 104 | ## Intent before code |
| 105 | |
| 106 | CARLOS's first principle: the design doc precedes the build, and its |
| 107 | decisions are taken. If one proves wrong, **stop and flag it** rather than |
| 108 | quietly redesigning around it. |
| 109 | |
| 110 | Keep the spec's "Deliberately not built yet" table honest. Every deferred |
| 111 | thing has a written trigger. A thing deferred without a trigger is a thing |
| 112 | forgotten. |
| 113 | |
| 114 | --- |
| 115 | |
| 116 | ## Mechanical rules |
| 117 | |
| 118 | Rules here are enforced by tests, not by memory. If you find yourself |
| 119 | relying on remembering one, make it a test instead. |
| 120 | |
| 121 | **Data** |
| 122 | - **Never `scope.Owned` or `scope.OwnedBy` on a team-shared table.** Both |
| 123 | are single-column equality (`Owned` is literally `Where("user_id = ?")`), |
| 124 | and our sheets, tabs and cells are team-shared. Applied to them, a sheet |
| 125 | list returns **only the creator's rows** β it fails as a plausible empty |
| 126 | list, not as an error, which is the worst available failure shape and also |
| 127 | the default thing to reach for. Access goes through the hand-written |
| 128 | grants join. `scope.Owned` is still correct for genuinely per-user rows. |
| 129 | - A row that isn't yours **404s, never 403s** β preserved by hand in the |
| 130 | grants join, and test-enforced by the two-user isolation suite. |
| 131 | - Scope `tx`, never the outer handle, inside transactions, or the |
| 132 | one-connection writer pool deadlocks. |
| 133 | - Never bind a request body onto a GORM model. Explicit `map[string]any` |
| 134 | plus a `.Select` allowlist. |
| 135 | - Resolve the viewer through the app's `identity` and `actor` helpers. Local |
| 136 | sessions retain verified addresses; opt-in Home sessions contain opaque Home |
| 137 | subjects and must resolve through the shared connection to existing members. |
| 138 | Never create members from Home subjects or infer links from matching addresses. |
| 139 | Never use `sessions.UserID`: it does not resolve either identity shape. |
| 140 | - Home links require independent fresh local and Home proofs plus confirmation. |
| 141 | Recheck existing admission and sheet grants on every Home request. Member IDs |
| 142 | are AUTOINCREMENT and must never be reassigned; any future deletion path must |
| 143 | remove the member's `home_identity_links` rows in the same transaction. |
| 144 | - Migrations are additive-only. New code over an old DB is always safe. |
| 145 | Never delete data to update. |
| 146 | - **Cell values are IEEE doubles end to end.** Currency is a number format, |
| 147 | not a type; `=A1*1.05` produces a double and XLSX stores a double. |
| 148 | Storing cents would round the evaluator's own output. The family's "a |
| 149 | float never touches an amount" rule binds **app-owned** amounts only β |
| 150 | billing, quotas, anything Sheets itself counts. |
| 151 | - **No cell content, comment text, sheet title or tab name in any log line, |
| 152 | error string, job status message or metric label.** `carlos logs` is |
| 153 | platform-held storage we cannot grep. Errors carry coordinates and codes. |
| 154 | |
| 155 | **Frontend** |
| 156 | - No build step. Jspreadsheet CE is vendored as a static asset, not fetched |
| 157 | from a CDN and not bundled. |
| 158 | - **Both vendored stylesheets paint in literals, and every rule the app |
| 159 | can render must be restated in `sheets.css`.** `TestVendorPaintIsRestated` |
| 160 | holds the list and checks it in both directions β that each vendored |
| 161 | selector still exists (v5 renamed every `.jexcel` to `.jss_*` once |
| 162 | already) and that we restate it. Rules the app cannot render are named |
| 163 | in that test with a checkable reason, never silently dropped. |
| 164 | Two more guards sit beside it: no colour literal may appear in |
| 165 | `sheets.css` at all, and `sheets.css` must stay linked before the head |
| 166 | block that carries the vendored links. |
| 167 | - **Restate at `> tbody > tr > td.foo`, never at the library's own |
| 168 | `.jss_worksheet .foo`.** Two independent reasons, and the second is the |
| 169 | one that bites: our grid-line rule is one specificity step higher than |
| 170 | the library's, *and* `sheet.html` links the vendored stylesheets into a |
| 171 | head block that `layout.html` renders after `sheets.css`, so the |
| 172 | library is later in source order and an equal-specificity restatement |
| 173 | loses on order alone. Ours must be strictly higher, never merely equal. |
| 174 | Written short, a restatement parses, uses a token, and paints nothing β |
| 175 | which is how the grid's selection outline went missing in dark for the |
| 176 | whole of the `core` branch. |
| 177 | - **Write the attribute grammar: `rst-btn="primary compact"`, not |
| 178 | `class="rst-btn rst-btn--primary"`.** This reverses the earlier rule in |
| 179 | this file, which was written against v0.20.0 where the attribute |
| 180 | spelling genuinely did not exist. It landed in v0.21.0 and is complete |
| 181 | in the pinned v0.23.0: measured against |
| 182 | `$(go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.23.0/ui/tokens.css`, |
| 183 | every class selector has an attribute twin, and rastrillo enforces that |
| 184 | itself with `TestEveryClassSelectorHasAnAttributeTwin`. Both spellings |
| 185 | are specificity `(0,1,0)`, so pairing changes no cascade. The class |
| 186 | spelling is scheduled for removal before 1.0. |
| 187 | - The grammar: a bare attribute is the KIND (`rst-callout`); a token in |
| 188 | its value is a VARIANT, matched with `~=` so values compose like a |
| 189 | class list; a PART is its own flat attribute (`rst-callout-body`), |
| 190 | never a value. |
| 191 | - **Utilities stay classes.** Seven names are class-only by design, and |
| 192 | correctly so: `rst-grow`, `rst-mono`, `rst-sr-only`, `rst-m-hide`, |
| 193 | `rst-danger`, `rst-nm`, `rst-cell-mut`. An attribute says what a thing |
| 194 | *is*; a class says what is being applied to it. |
| 195 | - `rst-tone` pairs with `data-tone` rather than with a class. |
| 196 | - Adopt per screen, never per element β mixed spellings inside one |
| 197 | component match neither. |
| 198 | - **rastrillo is `amadan.net/rastrillo/rastrillo`.** The old path, |
| 199 | `github.com/carlosframework/rastrillo`, stops at v0.23.0: code importing |
| 200 | it gets no fix, and its own checks cannot tell (Sheets zoomed iOS on |
| 201 | every tap for a month that way). `TestNoPreMoveRastrillo` allows one |
| 202 | import of it, native.go's bridge to rastrillo/native's server, until |
| 203 | that server moves. The CLI is pinned as a `tool` in go.mod: run it as |
| 204 | `go tool rastrillo β¦`, never a `rastrillo` from PATH. |
| 205 | - **After `go tool rastrillo doctor --fix`, delete the scripts it adds** |
| 206 | (`static/rastrillo.js`, `select.js`, `datetime.js`, `calendar.js`). |
| 207 | Sheets serves estilo's shim and busy.js from memory and uses no other |
| 208 | rastrillo script; the shim doctor adds at this pin still carries the |
| 209 | old busy rule, which fights estilo's. `TestBusyRuleIsLinkedAndServed` |
| 210 | fails if one is left behind. |
| 211 | - **Run `rastrillo doctor` on every rastrillo upgrade**, and |
| 212 | `rastrillo doctor --fix` to re-copy what drifted. `static/tokens.css` is |
| 213 | frozen at scaffold time while the partials it styles keep upgrading with |
| 214 | the module, so an app can run new markup against old CSS and see nothing |
| 215 | worse than a slightly wrong screen. `doctor` shipped in v0.22.0 (verified: |
| 216 | `rastrillo doctor --help` offers `-fix`, `-force`, `-theme`). It reports a |
| 217 | version mismatch as a mismatch rather than as drift and refuses `--fix` |
| 218 | across one, since copying newer assets into an app built against an older |
| 219 | framework manufactures the fault it is looking for. **So upgrade the |
| 220 | `doctor` binary alongside the module** β a `doctor` one release behind |
| 221 | compares against its own library copy and calls the upgrade you just did |
| 222 | the drift. |
| 223 | - **Theme bytes come from the Estilo module pin.** There is no app-local |
| 224 | `static/theme.css` to copy. On framework upgrades, update the Estilo pin |
| 225 | to the same released `day` body and run the theme asset tests. Estilo's |
| 226 | byte-for-byte test and this app's `theme_assets_test.go` detect drift; |
| 227 | `rastrillo doctor` reports the absent theme as not compared. Its token |
| 228 | checks still apply. Do not recreate an embedded theme file that the |
| 229 | served filesystem would shadow. This matters at v0.23.0 because tokens |
| 230 | require `--rst-header-rule`; an older theme silently loses the rule. |
| 231 | - Don't build on `.rst-page-header::after` β the rake line is retired in |
| 232 | favour of `--rst-header-rule` (rastrillo v0.23.0, design doc Β§6-v2.2). |
| 233 | It was retired because, built from the same parts in the same order, it |
| 234 | was indistinguishable from a determinate progress bar at 12%. The rule |
| 235 | is decorative and carries **no** contrast floor: it is deliberately |
| 236 | absent from rastrillo's contrast pair table, and a later pass must not |
| 237 | argue it up to 1.4.11's 3:1. |
| 238 | - 300-line JS module cap, test-enforced, ratchet-down only. The vendored |
| 239 | Jspreadsheet blob is exempt and named as such; nothing we write is. |
| 240 | - Every drag-only affordance has a menu equivalent. |
| 241 | - **Light/dark is per person, not per document**, and must resolve before |
| 242 | first paint β cell fills resolve against the theme, so a late preference |
| 243 | repaints every coloured cell. Read it synchronously from a cookie, never |
| 244 | a fetch. |
| 245 | |
| 246 | **Deploys** |
| 247 | - One binary, `CGO_ENABLED=0`, cgo-free SQLite. A cgo driver sneaking in is |
| 248 | a bug. |
| 249 | - Review on a deployed canary, never localhost. |
| 250 | - **Deploy with `carlos deploy`, never `carlos ship` on its own.** `ship` |
| 251 | publishes a release and stops there: the release sits unpromoted, no |
| 252 | channel points at it, and nothing serves it. `deploy` ships, promotes |
| 253 | and waits for the URL β one command, and it is the one that tells you |
| 254 | when it is actually live. (Paul, 2026-08-31.) |
| 255 | - **Build shipping binaries with `make release`, never a bare `go build`.** |
| 256 | It stamps `rastrillo.BuildVersion` via `-ldflags -X` and refuses a dirty |
| 257 | tree. An unstamped binary reports `dev` from `/api/version` forever. |
| 258 | - A 200 is not a deploy verification, and neither is `X-Carlos-Version`. |
| 259 | That header is the **router's adopted release**; it flips the moment the |
| 260 | promote lands and says nothing about which binary is running. `GET |
| 261 | /api/version` is served by the process itself. Check the process, not |
| 262 | the edge. |
| 263 | - A promote is not a deploy. Until the process cycles, the old binary |
| 264 | serves β measured at over 90 seconds here on three consecutive deploys, |
| 265 | and about ten on another app, so poll rather than sample. A single early |
| 266 | read looks like a failed deploy; a single read against an unstamped |
| 267 | binary reports `dev` and looks like a successful one. |
| 268 | - The two facts only disagree when the binary is stamped. Unstamped, they |
| 269 | are structurally incapable of disagreeing, so a check comparing them has |
| 270 | no failing case and proves nothing while passing. |
| 271 | |
| 272 | --- |
| 273 | |
| 274 | ## Authorship |
| 275 | |
| 276 | π€ / π¨ markers, `Co-Authored-By: Claude β¦` trailers, published prompt and |
| 277 | carbon ledgers. AI is a guest with a name tag: opt-in, disclosed, and every |
| 278 | path works with it switched off. |
| 279 | |
| 280 | ## Work in a worktree, and catch main up first |
| 281 | |
| 282 | Take a worktree for the work. Do not work in the shared checkout. |
| 283 | |
| 284 | git -C <repo> fetch origin |
| 285 | git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main |
| 286 | |
| 287 | Both halves matter, for different reasons. |
| 288 | |
| 289 | **The worktree**, because several sessions share this machine and some |
| 290 | share this repository, and a checkout has one HEAD, one index and one |
| 291 | working tree between all of them. A branch switch under somebody else's |
| 292 | uncommitted work destroys it with nothing on screen to say so, and two |
| 293 | sessions editing the same paths disagree quietly instead of conflicting |
| 294 | loudly. A worktree gives the work its own HEAD and its own files, and |
| 295 | costs a directory. |
| 296 | |
| 297 | **From `origin/main`, after a fetch** β not from whatever the checkout |
| 298 | happens to be sitting on. A branch cut from a stale main is a merge |
| 299 | conflict scheduled for later, against files somebody else has renamed in |
| 300 | the meantime. A branch cut from another task's branch is worse: it |
| 301 | carries that task's commits into yours, where they are reviewed as |
| 302 | though you wrote them, and the two can only be separated by hand. |
| 303 | |
| 304 | Remove it when the work has landed β `git worktree remove` β so that |
| 305 | `git worktree list` keeps saying something true. |
| 306 | |
| 307 | Written down because of 2026-09-17, in `meet`. The shared checkout was |
| 308 | sitting on one task's branch while holding a *different* task's feature |
| 309 | uncommitted on disk β some four hundred lines, a new migration among |
| 310 | them β put there by a session nobody else knew was working in that tree. |
| 311 | Two other sessions were meanwhile coordinating over the same checkout by |
| 312 | asking each other not to touch it, which is the arrangement a worktree |
| 313 | makes unnecessary. Nothing was lost. Nothing about the arrangement would |
| 314 | have said so if it had been, and by then the same feature existed twice, |
| 315 | once on each side of a rename, because the two sessions had branched |
| 316 | from different starting points. |
| 317 | |