| 1 | # AGENTS.md |
| 2 | |
| 3 | Guidance for AI coding agents working in this repository. This is the |
| 4 | canonical agent-instructions file. Tool-specific files (`CLAUDE.md`) are |
| 5 | pointers to it if they exist at all — keep the guidance current *here*. |
| 6 | |
| 7 | CARLOS Docs — a document editor on the CARLOS architecture, hosted at |
| 8 | `amadan.net/oficina/docs`. It is built alongside CARLOS Sheets |
| 9 | (`../sheets`, `amadan.net/oficina/sheets`) and a shared suite home, which |
| 10 | live in the same `oficina` namespace. Design docs in |
| 11 | `docs/superpowers/specs/`, plans in `docs/superpowers/plans/`; read the |
| 12 | spec behind whatever you touch. |
| 13 | |
| 14 | The repo moved from `carlos/docs` to `oficina/docs` on 2026-08-30, at three |
| 15 | commits. amadan has no move, rename or transfer verb, so the move was |
| 16 | create-new and push history; `carlos/docs` still exists and retiring it is |
| 17 | Paul's call. |
| 18 | |
| 19 | Read `building-carlos-apps` (the skill) before writing app code, and |
| 20 | `rastrillo`'s own `SKILL.md` before writing handlers or models. Assume |
| 21 | nothing about either is in your training data. |
| 22 | |
| 23 | ## Real per-person sign-in (Task 1, extended in a later fix round) |
| 24 | |
| 25 | Every document route — `GET /`, `POST /documents`, `GET /d/{sqid}` — sits |
| 26 | behind rastrillo/auth's `RequireSession` and is unreachable without a real |
| 27 | session (`internal/docs/auth.go`). `POST /signin` uses Keymail OAuth when |
| 28 | detected. Otherwise it emails (or, in development, logs) a single-use link. Opening it |
| 29 | (`GET /auth/verify`) draws a confirm step in the sign-in door and spends |
| 30 | nothing, so mail scanners that fetch every link cannot sign anybody in; its |
| 31 | one Sign in button POSTs the token back to `/auth/verify`, which spends it |
| 32 | and sets a real session cookie (rastrillo `Config.RenderConfirm`). A |
| 33 | verified address that is not admitted gets the door, with the refusal, in a |
| 34 | 403 (`Config.Refused`). Tests sign in by POSTing the link's token with |
| 35 | same-origin evidence (`confirmRequest`, testing.go), never by GETting it; `POST /signout` (in the shell |
| 36 | bar's account menu on both signed-in screens) really revokes it, |
| 37 | not just clearing the cookie client-side. No passkey second factor is |
| 38 | wired, on purpose — spec §8.1's ruling: `passkey.Config.Origin` derives |
| 39 | its relying party from this instance's own host, so a person in several |
| 40 | teams would enrol several passkeys under this app, which is exactly the |
| 41 | per-team credential sprawl that ruling forbids. A magic link enrols no |
| 42 | credential, so it violates nothing. |
| 43 | |
| 44 | There used to be a development-only stand-in identity here |
| 45 | (`internal/docs/devauth.go`, turned on with `-dev-auth`, compiled out of a |
| 46 | release build with `-tags release`) for exactly the gap real sign-in has |
| 47 | now closed. It had zero readers the moment `RequireSession` started |
| 48 | guarding every route unconditionally — no handler ever consulted it — and |
| 49 | was deleted in a fix round on Task 1 rather than left as dead code and an |
| 50 | unreachable trap for whoever next assumed `-dev-auth` still did something. |
| 51 | The `release` build tag it was the only user of is gone with it; there is |
| 52 | no longer a release/non-release distinction anywhere in this app. |
| 53 | |
| 54 | `Config.Env` still exists (`"development"` or `"production"`), but it now |
| 55 | governs exactly one thing, independent of any identity flag: whether |
| 56 | `InstanceKey` and `Mailer` (`internal/docs/auth.go`) may default — an |
| 57 | empty `$DOCS_INSTANCE_KEY` or unconfigured mail is fine only in |
| 58 | development (a fixed dev key, and magic links logged instead of mailed, |
| 59 | clearly marked); anywhere else it is a startup refusal, the same |
| 60 | fail-closed shape `Origin` already used. `cmd/docs/main.go`'s `-env` flag |
| 61 | sets it; `$DOCS_ENV` is consulted too but can never select |
| 62 | `"development"` on its own (fix round 2 on Task 1 — platform instance |
| 63 | config *is* environment variables, so a bare `$DOCS_ENV=development` |
| 64 | sitting beside `$DOCS_ORIGIN`/`$DOCS_INSTANCE_KEY`/`$DOCS_SMTP_*` would |
| 65 | be a production-reachable path to the public dev `InstanceKey` and to |
| 66 | every magic link, token included, landing in the instance's own log — |
| 67 | see `chooseEnv`'s own doc comment). The default, with no flag and no |
| 68 | env var (or with `$DOCS_ENV=development` and no flag), is |
| 69 | `"production"` — `"development"` always takes an explicit `-env` |
| 70 | flag, never a silent fallback. |
| 71 | |
| 72 | ### Sign-in allowlist (Task 4 security fix, pre-launch) |
| 73 | |
| 74 | Real sign-in above closed "who can get a session" but left a separate |
| 75 | hole open: `rastrillo/auth`'s `Config.Authorize` — the admission gate — |
| 76 | is consulted only in its own callback handler, after `POST /signin` |
| 77 | (`Begin`) has already emailed the link. With `Authorize` left `nil` (as |
| 78 | it was through Task 3), that meant anyone who could reach the instance |
| 79 | could POST any address and get a working sign-in link mailed to it — |
| 80 | an open relay on our own SES sending identity, and unrestricted account |
| 81 | creation on an app whose whole premise is instance-per-team. Found by |
| 82 | the CARLOS Sheets session on their own deployed instance; every existing |
| 83 | test passed because they all tested admission, none tested delivery. |
| 84 | |
| 85 | The fix is `$DOCS_ALLOWED_ADDRESSES` (`docs.Config.AllowedAddresses`, |
| 86 | `internal/docs/auth.go`), a normalized allowlist checked by both |
| 87 | `rauth.Config.Authorize` and `admissionMailer`. The mailer suppresses delivery |
| 88 | for refused addresses and failed grant lookups. Classification and rate limits |
| 89 | run independently of admission, so an OAuth start does not disclose membership. |
| 90 | Refused email starts still receive `303 ?sent=1`; their expiring tokens are never |
| 91 | delivered. Callback authorization and per-request admission remain required. |
| 92 | |
| 93 | Detected Keymail addresses use OAuth, as §8.1 requires and Paul reaffirmed on |
| 94 | 2026-09-09. `force=1` remains an explicit email fallback. The OAuth start ends |
| 95 | with a `200` continuation page (meta refresh plus link), preserving the pending |
| 96 | cookie and the existing CSP: Safari enforces `form-action` across redirects. |
| 97 | Never widen that directive to arbitrary Keymail hosts. Account linking still |
| 98 | requires a fresh provider `AuthTime`; an ordinary OAuth session is not enough. |
| 99 | |
| 100 | Empty is allowed only in development (admits everyone, so `TestSignIn` |
| 101 | and `run-dev.sh` need no setup); anywhere else it is a startup refusal, |
| 102 | the same fail-closed shape `Origin`/`InstanceKey`/`Mailer` already use, |
| 103 | chosen over silently admitting nobody because a silently-empty allowlist |
| 104 | would boot looking identical to a working instance. |
| 105 | |
| 106 | ## Running it locally |
| 107 | |
| 108 | `./scripts/run-dev.sh` builds and runs the app in one command: `-env |
| 109 | development`, a fixed port (`localhost:8099`, override with |
| 110 | `$DOCS_DEV_PORT`), and a scratch SQLite database that starts empty every |
| 111 | run. It prints the URL to open, and signing in there gets a real session |
| 112 | via a magic link logged to the terminal instead of mailed — the same flow |
| 113 | a real deployment uses, just not actually delivered anywhere. |
| 114 | |
| 115 | **Origin is not optional in anything but development — but know what it |
| 116 | actually guards.** rastrillo v0.21.0's `csrf.Protect` checks |
| 117 | `Sec-Fetch-Site` *first*, when a browser sends it — which every current |
| 118 | browser does on a state-changing request. `same-origin`/`none` pass |
| 119 | immediately, anything else is refused immediately, and `Config.Origin` is |
| 120 | never read in that path at all. Only when `Sec-Fetch-Site` is entirely |
| 121 | absent (a legacy browser, or a non-browser client like curl) does the |
| 122 | check fall back to comparing `Origin`/`Referer` against `Config.Origin` |
| 123 | exactly. So a wrong `Config.Origin` does not make a real browser's genuine |
| 124 | same-origin request fail, and it does not open up a genuine cross-site |
| 125 | request either — both are decided by `Sec-Fetch-Site` alone. What it |
| 126 | actually breaks is the legacy fallback path, silently: a misconfigured |
| 127 | origin will not show up as "the app looks broken" in ordinary manual |
| 128 | testing with a real browser, which is exactly why it still needs to be |
| 129 | right rather than merely untested. `cmd/docs/main.go` reads `$DOCS_ORIGIN` |
| 130 | (the real per-instance origin belongs there — spec §9.2's |
| 131 | `<team>.docs.oficina.works`) and falls back to deriving one from `-addr` |
| 132 | only for local/dev use; with neither set, or with a value that cannot |
| 133 | parse as an absolute `http(s)://host[:port]`, `internal/docs.New` refuses |
| 134 | to start rather than silently defaulting or booting on something |
| 135 | malformed. `run-dev.sh` never needs `$DOCS_ORIGIN` set by hand — its fixed |
| 136 | `-addr` derives it. |
| 137 | |
| 138 | ## Review by the highest available capability model (Paul's rule, 2026-08-30) |
| 139 | |
| 140 | **Specs and whole branches are reviewed by the highest capability model |
| 141 | available at the time.** Today that is **Fable** (`claude-fable-5`). The |
| 142 | rule is "the highest available", not "Fable" — when a more capable model |
| 143 | ships, the rule follows it up the ladder without an edit here. |
| 144 | |
| 145 | Two things get that review, and they are the two where a mistake is |
| 146 | expensive to unwind: |
| 147 | |
| 148 | - **A spec, before it becomes a plan.** A spec is the document every |
| 149 | later decision inherits from. Reviewing it after implementation means |
| 150 | reviewing the code's rationalisation of it. |
| 151 | - **A branch, before it squash-merges to `main`.** Read as a single |
| 152 | diff, not commit by commit — that is the one reading that catches a |
| 153 | branch which is individually-sensible commits adding up to the wrong |
| 154 | thing. |
| 155 | |
| 156 | Name the model explicitly when dispatching a review. An omitted model |
| 157 | silently inherits the session's, which is how a review quietly happens on |
| 158 | a weaker model than the rule requires. |
| 159 | |
| 160 | The rest of the delegation ladder, unchanged from the family's policy: |
| 161 | |
| 162 | - **Haiku** — transcription-grade work: the plan contains the exact code, |
| 163 | single-file mechanical fixes, doc edits. |
| 164 | - **Sonnet** — implementation from a precise brief: 1–3 files, clear |
| 165 | interfaces, established conventions to follow. |
| 166 | - **Opus** — design judgment, multi-file integration, anything security- |
| 167 | or production-touching. |
| 168 | - **Fable** — spec review, whole-branch review, and implementation whose |
| 169 | correctness rests on several interacting invariants at once. It burns |
| 170 | usage budget fastest; never spend it where a precise Sonnet brief plus |
| 171 | an Opus review already fits. |
| 172 | |
| 173 | A precise brief beats a capable model. Exact values, file paths and |
| 174 | interfaces go in the brief. |
| 175 | |
| 176 | ## Shipping cadence (Paul's rule, 2026-08-30) |
| 177 | |
| 178 | **Work happens on a worktree, on a branch. Never on `main`.** |
| 179 | |
| 180 | 1. **Worktree per piece of work.** `git worktree add` (or the harness's |
| 181 | worktree isolation) so `main` stays clean and two pieces of work never |
| 182 | share a checkout. The branch is created with the worktree, not after. |
| 183 | 2. **Push regular commits to the branch.** Commit as you go and push |
| 184 | often — small commits, pushed, so the work is visible, recoverable, |
| 185 | and reviewable as a sequence. Do not sit on a large uncommitted |
| 186 | working tree, and do not squash locally as you go; the branch's |
| 187 | history is the record of how the work happened. |
| 188 | 3. **Squash merge to `main` when, and only when, all three are true:** |
| 189 | - the work is **done** — not "done except", not "done pending a |
| 190 | follow-up commit"; |
| 191 | - it is **approved** — the branch has had its highest-capability-model |
| 192 | review above, and Paul has approved anything that was his call; |
| 193 | - **CI is green** — `amadan ci status -branch <n>` exits 0. Exit 2 is |
| 194 | pending, not green. Exit 3 means nothing has run, which is not green |
| 195 | either. |
| 196 | |
| 197 | Nothing reaches `main` any other way: no direct commit, no local |
| 198 | fast-forward, no "it's only a docs change". That includes doc-only edits, |
| 199 | one-line fixes, and reverts. |
| 200 | |
| 201 | The reason is timing rather than ceremony. A change merged locally still |
| 202 | faces the same gates, but only *after* it is on `main`, where a red run is |
| 203 | an incident to clean up instead of a merge that simply does not happen. |
| 204 | |
| 205 | **The review record lives on amadan.** The pull-request equivalent is a |
| 206 | branch discussion — see below. **CI is `.amadan/ci.d/10-gate`**, triggered |
| 207 | by a push to the remote, and it is the same script you can run locally. It |
| 208 | covers the Rust crate, the wasm artifact, Yjs wire compatibility in both |
| 209 | directions, and the Go build and tests. `amadan ci status -branch <n>` |
| 210 | exit 0 is the merge gate; exit 2 is pending and exit 3 means nothing ran, |
| 211 | and neither is green. |
| 212 | |
| 213 | **Finishing a branch is always the same two steps, in this order** (Paul's |
| 214 | rule, 2026-08-30): **push and open the branch discussion, then squash-merge |
| 215 | to `main`.** Never merge without the discussion existing first, and never |
| 216 | merge locally instead of pushing. |
| 217 | |
| 218 | The order is the point rather than the ceremony. A squash merge deliberately |
| 219 | flattens the commit history that carried the branch's reasoning — the |
| 220 | rulings, the reversals, the things that turned out to be wrong twice. The |
| 221 | discussion is where that survives. If it does not exist before the merge, |
| 222 | the reasoning is not scattered, it is gone. |
| 223 | |
| 224 | ## Working through amadan |
| 225 | |
| 226 | This repo is hosted on an amadan hub (`AMADAN_HUB=https://amadan.net`), |
| 227 | not GitHub. There is no `gh`, no pull request, and no GitHub Actions. The |
| 228 | full house rules are installed as a skill at |
| 229 | `.claude/skills/amadan/SKILL.md` — read it. What follows is what this repo |
| 230 | does with them. |
| 231 | |
| 232 | **Post as the agent account, never as Paul.** An agent account is |
| 233 | `<name>~<operator keymail>`; everything it writes is badged and |
| 234 | attributable. The CLI detects a harness and refuses a human credential. |
| 235 | `-as-human` exists for a person genuinely typing, and it warns every time |
| 236 | — an agent reaching for it is the antipattern the badge exists to prevent. |
| 237 | |
| 238 | **The prompt ledger is on, at `prompts` level.** `amadan ledger init` has |
| 239 | installed the post-commit hook, the Claude Code hooks in |
| 240 | `.claude/settings.json`, and the ledger refspecs, so prompts record to |
| 241 | `refs/amadan/ledger/<branch>` with nothing to do per commit. Two |
| 242 | consequences worth knowing: |
| 243 | |
| 244 | - **A bare `git push` now pushes HEAD *and* the ledger refs**, because |
| 245 | `remote.origin.push` is set and `push.default` no longer applies. |
| 246 | - **Never edit ledger refs by hand.** `amadan ledger redact` and |
| 247 | `amadan ledger withhold` are the only sanctioned removals, and both |
| 248 | leave a visible mark. That is the point of them. |
| 249 | |
| 250 | **Every branch gets a discussion, and it is the review record.** |
| 251 | `amadan discuss new <ns>/docs -branch <n>` opens it; that thread is where |
| 252 | the branch is read as one piece, where the highest-capability-model review |
| 253 | is posted, and where approval is recorded. One thread per topic — reply on |
| 254 | it rather than opening a sibling. Branch discussions belong to the branch; |
| 255 | repo-level ones are for anything wider. Close what you opened. |
| 256 | |
| 257 | **Tasks are a checklist, not a log.** `amadan task add -branch <n>` for |
| 258 | work someone will do, `amadan task advance` when it is done. Commentary |
| 259 | belongs in the discussion, not in task titles. A branch with real work on |
| 260 | it should have tasks that a reader can scan to see what is left. |
| 261 | |
| 262 | **Rebuilding the editor bundle is not hand-mergeable** (branch |
| 263 | convention, agreed 2026-08-31 between the three sessions then working in |
| 264 | this repo). `internal/docs/static/editor.<hash>.js` is a single |
| 265 | committed artifact whose name is the hash of its own contents, and |
| 266 | `assets.go` panics at init if it finds more or fewer than one. Two |
| 267 | branches that both ran `fixtures/build-editor.mjs` therefore conflict |
| 268 | mechanically even when no source line overlaps. Whoever squash-merges |
| 269 | second discards their own bundle from the merge and re-runs the build on |
| 270 | top of `main`. Never reconcile the minified file by hand. |
| 271 | |
| 272 | **When something is refused, say so in the discussion rather than working |
| 273 | around it.** An `agent` grant is writer-equivalent within one namespace: |
| 274 | push, post, manage tasks, nothing beyond. Refusals are usually a missing |
| 275 | grant or a genuine boundary, and both are worth surfacing rather than |
| 276 | routing around. |
| 277 | |
| 278 | ## Working with the sibling sessions |
| 279 | |
| 280 | Docs is one of three moving parts and the other two are live: |
| 281 | |
| 282 | - **`../sheets`** (`amadan.net/oficina/sheets`) — shares the suite home, |
| 283 | the design-system primitives, and several rulings. Its spec is |
| 284 | `docs/superpowers/specs/2026-08-30-sheets-design.md`. Docs is a |
| 285 | declared consumer of the home contract in its §9. |
| 286 | - **The design system** — `rastrillo.org/design-system`, spec at |
| 287 | `docs/superpowers/specs/2026-08-28-design-system-design.md` in the |
| 288 | rastrillo repo. Vendor tokens through `ui.TokensCSS()` and |
| 289 | `ui.ThemeCSS(name)` rather than copying files by hand. |
| 290 | |
| 291 | When a decision affects one of them, tell that session rather than |
| 292 | writing around it. Several of this project's better decisions came from |
| 293 | one session's question exposing another's bug. |
| 294 | |
| 295 | ## Conventions that are already settled |
| 296 | |
| 297 | - **Markup uses the bare `rst-*` attribute grammar** (`<div rst-inspector>`), |
| 298 | variants in the value matched with `~=`. Unparked by Paul 2026-08-31, once |
| 299 | both of his conditions were met and verified: `rastrillo v0.21.0` styles |
| 300 | **both** spellings (`ui/tokens.css` carries 343 bare-attribute selectors |
| 301 | alongside 398 class ones), and the design-system guidance exists. |
| 302 | Two constraints come with it. **Adopt per screen, not per element** — |
| 303 | mixed authoring inside one component matches neither spelling by design. |
| 304 | And **pin `v0.21.0` or later**, because on anything earlier attributes |
| 305 | render unstyled. |
| 306 | *(This reversed twice before landing here, both times by applying a |
| 307 | conclusion drawn about one repo to a different one without re-deriving it. |
| 308 | The reasoning is in spec §10.1.1.)* |
| 309 | - **Anything Docs contributes to estilo takes an `estilo-*` prefix, never |
| 310 | `rst-*`.** That namespace is the framework's and a future version may claim |
| 311 | a name we took — rastrillo's own migration found `rst-form__foot` and |
| 312 | `rst-form-foot` both live inside `ui`. |
| 313 | |
| 314 | - **A spec ruling, code on `main`, and code in the latest release are three |
| 315 | different things.** Reading one tells you nothing about the other two. |
| 316 | `v0.19.0` (2026-08-24) is 18 commits behind rastrillo's `main` and lacks |
| 317 | the themes, shells and locales the design-system spec describes, so |
| 318 | `go get` alone will not give you them. Say which of the three you mean |
| 319 | whenever you cite the framework, and stamp any commit count with the sha |
| 320 | and date you took it at — a bare number ages into a wrong fact. |
| 321 | |
| 322 | ### Watch for tools that answer wrongly instead of failing |
| 323 | |
| 324 | A tool that errors teaches you something. A tool that returns a confident |
| 325 | wrong answer does not, and both of the ones below cost this project real |
| 326 | time in a single afternoon: |
| 327 | |
| 328 | - **Citing your working tree instead of `origin/main`.** A checkout parked |
| 329 | on a feature branch gives you *true statements about the wrong tree*, |
| 330 | complete with real line numbers that verify. Cite `origin/main` after a |
| 331 | fetch, or a tag, and say which. Never your checkout. |
| 332 | - **`git branch --merged` against a squash-merging repo.** It returns an |
| 333 | empty list that reads as "never merged" while `main` carries every line, |
| 334 | because a squashed branch is not an ancestor. Compare content, not |
| 335 | ancestry. |
| 336 | - **`carlos channels` after a promote.** It reports which release a |
| 337 | channel *points at*, which is not the same question as which release |
| 338 | the box is *serving*. Adoption runs on an hourly timer, so the two can |
| 339 | disagree for up to an hour after `carlos promote`: observed |
| 340 | 2026-09-01, `edge` reading the new version while the live host served |
| 341 | the previous one for five minutes straight, closed within seconds by |
| 342 | `carlos restart`. Neither command fails and nothing says "not live |
| 343 | yet". Trust `x-carlos-version` on the real host, or use `carlos |
| 344 | deploy`, which ships, promotes and then watches the URL until it |
| 345 | actually serves the new version. `carlos channels` cannot distinguish |
| 346 | "serving it" from "pointed at it". |
| 347 | *(Recorded from the live-sync deploy; not re-verified since, so the |
| 348 | timer's exact behaviour is one observation rather than a measurement.)* |
| 349 | |
| 350 | - **A library function that ignores its own "dry run" argument.** |
| 351 | `y-prosemirror`'s exported `undo` is `state => ...undoManager?.undo()` |
| 352 | — it takes no `dispatch` and undoes immediately. ProseMirror's command |
| 353 | contract says a null `dispatch` performs nothing and only reports |
| 354 | whether it could, which is how any toolbar decides whether to grey a |
| 355 | button out — on every state update, because that is when the answer |
| 356 | changes. So the formatting bar asked "could I undo?" after every |
| 357 | change and the library answered by undoing it: typing did nothing, |
| 358 | every command did nothing, no error anywhere, Go suite green. |
| 359 | (`undoCommand`/`redoCommand` are the dispatch-aware pair.) The general |
| 360 | form: **a convention is not a guarantee**, and the functions that break |
| 361 | one are the ones that look most like they honour it. Found 2026-09-01 |
| 362 | on the formatting-and-export branch, by a browser drive, after an |
| 363 | evening of looking at the wrong file. |
| 364 | |
| 365 | The shape to watch for is a verification that cannot distinguish "no" from |
| 366 | "I can't see it". When you catch one, write it here rather than resolving |
| 367 | to be more careful — care is not a mechanism. |
| 368 | |
| 369 | **A new test must be seen to fail.** Corollary of the above, and cheap: |
| 370 | after writing one, break the thing it covers and watch it go red, then |
| 371 | put it back. `scripts/browser-drive.sh` was kept only after it was |
| 372 | confirmed to fail on the build whose bug it had just found — without |
| 373 | that step a green run means "passed" and "could not see" equally. |
| 374 | |
| 375 | **And it must fail for the right reason.** Watching it go red is not |
| 376 | enough — read *why* it went red. Three of these were found in one |
| 377 | evening on the `list-thumbnails` branch, in three different shapes: |
| 378 | |
| 379 | - A test that could not fail at all. `TestPageOneDoesNotDisturbPDFExport` |
| 380 | exported a document, ran the thumbnail layout, exported again and |
| 381 | compared bytes — but the two calls share no state, so only a |
| 382 | package-level global could ever have reddened it. It stayed green |
| 383 | while a deliberate `l.cursor += 100` inside the code it claimed to |
| 384 | guard reddened a different test. Two comments and a commit message |
| 385 | cited it as proof. Deleted. |
| 386 | - A mutation that reddened the right test for the wrong reason. Proving |
| 387 | a canary test could fail, the injected leak used a template variable |
| 388 | that was not in scope, so the page 500ed and the test failed on its |
| 389 | status check without the canary ever firing — a red test proving |
| 390 | nothing. Redone with a leak that actually renders. **If your mutation |
| 391 | changes the response code, you have tested the mutation, not the |
| 392 | test.** |
| 393 | - A hit test that measured something else. Recorded in `list.html`: |
| 394 | restated declarations "fixed" row clicking, and the probe was hitting |
| 395 | rastrillo's own overlay both before and after. |
| 396 | |
| 397 | The generalisation, and the question worth asking of any gate: **what |
| 398 | would make this go red, and have I actually done it?** A CSS rule keyed |
| 399 | on an attribute nothing writes is the same defect in another costume — |
| 400 | it matches no element, so it is invisible to every test and every |
| 401 | screenshot, and any bug inside it is unreachable. The Sheets session |
| 402 | found one of those the same night, hiding a second defect behind it. |
| 403 | Worth a grep when touching a stylesheet; not worth a permanent gate, |
| 404 | because it has enough false positives (comments quoting old selectors, |
| 405 | framework-emitted hooks) that people would learn to skim it — and a |
| 406 | gate nobody reads is another probe that answers the same in both |
| 407 | worlds. |
| 408 | |
| 409 | **Do not pass on somebody else's finding as your own verification.** A |
| 410 | report from another session is evidence about what they saw, not a fact |
| 411 | you have checked, and relaying it flatly launders it into one. Say where |
| 412 | it came from, or check it — and note that "two of us noticed |
| 413 | independently" is not corroboration when the second of you is the one |
| 414 | who mentioned it first. (Written after exactly that: a merge commit on |
| 415 | `main` was reported here as a breach of the shipping cadence and passed |
| 416 | on to Paul as one, when `amadan branch merge` produces a merge commit |
| 417 | itself and the branch had landed correctly. One minute with the tool |
| 418 | would have caught it.) |
| 419 | - **rastrillo is `amadan.net/rastrillo/rastrillo`.** The old path, |
| 420 | `github.com/carlosframework/rastrillo`, stops at v0.23.0: code importing |
| 421 | it gets no fix, and its own checks cannot tell (Docs zoomed iOS on every |
| 422 | tap for a month that way). `TestNoPreMoveRastrillo` (internal/lint) |
| 423 | allows one import of it, native.go's bridge to rastrillo/native's |
| 424 | server, until that server moves. The CLI is pinned as a `tool` in |
| 425 | go.mod: run it as `go tool rastrillo …`, never a `rastrillo` from PATH. |
| 426 | `scripts/build-release.sh` stamps `amadan.net/rastrillo/rastrillo.BuildVersion`; |
| 427 | the old symbol leaves `/api/version` answering "dev" |
| 428 | (`TestReleaseBinaryReportsItsVersion`). |
| 429 | - **Docs serves rastrillo's and estilo's assets from memory** |
| 430 | (render.go's `vendoredStaticFS`), so `go tool rastrillo doctor` finds |
| 431 | nothing vendored to check. If a future `doctor --fix` ever writes a |
| 432 | rastrillo.js, busy.js or tokens.css to disk, delete it: doctor's shim |
| 433 | carries the old busy rule, which fights estilo's busy.js. |
| 434 | `TestBusyRuleIsLinkedAndServed` fails if one is left behind. |
| 435 | - **iOS zooms into any text control under 16px.** tokens.css lifts |
| 436 | controls to 16px under `(pointer: coarse)`; a Docs rule that sets a |
| 437 | control's font with a heavier selector must restate |
| 438 | `font-size: max(1rem, 1em)` there too. `TestNoZoomOnTap` (browser tag: |
| 439 | `go test -tags browser -run OnTap ./internal/docs/`, needs a Chromium via |
| 440 | `RASTRILLO_CHROME` or a Playwright cache) measures every field. |
| 441 | - **Re-copy `tokens.css` deliberately on every rastrillo upgrade.** It is |
| 442 | frozen into `static/` at scaffold time while the partials upgrade with the |
| 443 | module. `rastrillo doctor` will make this mechanical; until it lands it is |
| 444 | a manual step in the upgrade runbook. |
| 445 | - **Every user-visible string is a catalog key**, resolved at render time. |
| 446 | Twelve base locales; a hardcoded English string is a bug. |
| 447 | - **WCAG 2.2 AA, no exemptions**: 24×24 targets, 320px reflow, and colour |
| 448 | never carries meaning alone. |
| 449 | - **An ARIA role is a promise about behaviour, and an unimplemented one |
| 450 | is worse than none.** `role="toolbar"` says "this is a single tab stop |
| 451 | and the arrow keys move within it". Written without the roving |
| 452 | tabindex behind it — as the formatting bar was, on this branch, until |
| 453 | it was caught in review — it tells a screen-reader user that arrow |
| 454 | keys are the way in, so those are what they try first, and the silence |
| 455 | that follows reads as their own mistake rather than as a missing |
| 456 | feature. Omitting the role would have been more honest and less |
| 457 | harmful. Whenever you write a role, implement its keyboard contract in |
| 458 | the same commit or do not write it. |
| 459 | Two things that pattern hides, both found the same way: |
| 460 | **a roving tab stop parked on a `disabled` control makes the whole |
| 461 | group unreachable**, not merely awkward, and disabled is often the |
| 462 | *default* state (undo and redo start that way on every page load) — so |
| 463 | the "which control is the tab stop" decision has to re-run whenever |
| 464 | anything is enabled or disabled, not on load. And **a closed native |
| 465 | `<select>` changes its own value on Left/Right**, so a toolbar |
| 466 | containing one must consume those keys and leave Up/Down alone. |
| 467 | Neither is in the ARIA practices; both are unmissable in a browser and |
| 468 | invisible in a unit test, which is what `scripts/browser-drive.sh` is |
| 469 | for. |
| 470 | |
| 471 | ## Work in a worktree, and catch main up first |
| 472 | |
| 473 | Take a worktree for the work. Do not work in the shared checkout. |
| 474 | |
| 475 | git -C <repo> fetch origin |
| 476 | git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main |
| 477 | |
| 478 | Both halves matter, for different reasons. |
| 479 | |
| 480 | **The worktree**, because several sessions share this machine and some |
| 481 | share this repository, and a checkout has one HEAD, one index and one |
| 482 | working tree between all of them. A branch switch under somebody else's |
| 483 | uncommitted work destroys it with nothing on screen to say so, and two |
| 484 | sessions editing the same paths disagree quietly instead of conflicting |
| 485 | loudly. A worktree gives the work its own HEAD and its own files, and |
| 486 | costs a directory. |
| 487 | |
| 488 | **From `origin/main`, after a fetch** — not from whatever the checkout |
| 489 | happens to be sitting on. A branch cut from a stale main is a merge |
| 490 | conflict scheduled for later, against files somebody else has renamed in |
| 491 | the meantime. A branch cut from another task's branch is worse: it |
| 492 | carries that task's commits into yours, where they are reviewed as |
| 493 | though you wrote them, and the two can only be separated by hand. |
| 494 | |
| 495 | Remove it when the work has landed — `git worktree remove` — so that |
| 496 | `git worktree list` keeps saying something true. |
| 497 | |
| 498 | Written down because of 2026-09-17, in `meet`. The shared checkout was |
| 499 | sitting on one task's branch while holding a *different* task's feature |
| 500 | uncommitted on disk — some four hundred lines, a new migration among |
| 501 | them — put there by a session nobody else knew was working in that tree. |
| 502 | Two other sessions were meanwhile coordinating over the same checkout by |
| 503 | asking each other not to touch it, which is the arrangement a worktree |
| 504 | makes unnecessary. Nothing was lost. Nothing about the arrangement would |
| 505 | have said so if it had been, and by then the same feature existed twice, |
| 506 | once on each side of a rename, because the two sessions had branched |
| 507 | from different starting points. |
| 508 | |