AGENTS.md carries the spec's retractions, and three of its own reasons died
This file's opening rule is that where it disagrees with the spec, the spec wins and this file is wrong — fixed in the same commit. It had drifted on four retractions, and fixing that took three attempts, because each replacement reason was itself false at the pinned version. The Frontend bullet is the whole story in miniature. It first said write classes because the attribute grammar is unreleased — dead at v0.21.0, this repo's first pin. Then because the partials we render alongside write classes — dead at v0.22.0, the current pin, where the partials flipped and class occurrences in ui/partials fell from 124 to 6. Then it blamed the v0.22.0 bump for falsifying the first reason, which it did not: that died at v0.21.0 and the bump changed 130 to 130. The evidence it quoted for the second — tokens.css's header saying every shipped partial writes classes — is stale upstream, and this file repeated it as verified fact. So the bullet gives no reason of its own now. It states ruling 20 and keeps both dead reasons as a recorded graveyard, because each looked sound when written and each will be proposed again. The seven utilities are carved out explicitly: rst-sr-only and its six siblings have no attribute twin at v0.22.0, so an agent following the rule literally writes <span rst-sr-only>, nothing matches, and screen-reader-only text renders visible. And the re-check instruction now says to count ui/partials as well as ui/tokens.css, since checking one and not the other is exactly how the second reason survived a pass looking for it. Carries the RP ID rule, which this file never had, and which is the kind of thing that reads like an improvement: WebAuthn ACCEPTS a suffix RP ID, so widening to oficina.works looks harmless and silently collapses the origin split. Names both doors — passkey.Config has no RP ID field, so widening means widening Origin, and LegacyRPID is a second RP ID accepted for assertions only. Ruling 21's carve-out allows script on the passkey screens, and allows the app glue as well as rastrillo's library: webauthn.mjs exports bare navigator.credentials wrappers and makes no network call, so a rule naming only the library would have forbidden the code that calls it. Also: gate 9 stated as a rule rather than a status, since which gates exist today is a question for git log; auth_sessions is no longer written at v0.22.0, so the readable address at rest is auth_links and sessions.subject — the leak moved rather than closed; and TestVendoredAssetsMatchTheLibrary is a test to add, since this repo was hand-written rather than scaffolded. Reviewed by Fable across four passes; the first three returned CHANGES NEEDED, the fourth approved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+143
−32
AGENTS.md+143 −32
CI passed — run details
Intent
Attributed to this commit: 64.6 g CO₂e (7586716 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on main, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/AGENTS.md b/AGENTS.md| index 1fb6bf9..d6889bb 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -17,18 +17,31 @@ alternative beside each. |
| ## The one rule |
| > **The home is glue: thin, blind, replaceable. It knows that you exist and |
| -> where your teams are. It never stores your address, and it never learns |
| -> what is in anything.** |
| +> where your teams are. It never stores a readable address, and it never |
| +> learns what is in anything.** |
| The falsifiable form: **publish this database in full and the worst anyone |
| learns is how many people exist, what their teams are called, and that some |
| -unidentified person belongs to a particular set of them.** No address, no |
| -document, no cell. |
| - |
| -Two tests enforce it from the first commit: a plaintext grep over the raw |
| -SQLite file and WAL, and a no-address-at-rest test asserting the schema has |
| -no address column and that a full sign-in plus directory upsert leaves no |
| -readable address anywhere. |
| +person belongs to a particular set of them.** No document. No cell. No |
| +readable address. |
| + |
| +*Two absolutes were argued and lost, and stay lost:* **"never stores your |
| +address"** — a magic link's address is at rest between send and click, |
| +sealed (§3.2), not absent — and **"some unidentified person"**: a team-set |
| +fingerprint plus one outside fact names the row (§3.3). |
| + |
| +Three gates enforce it (§12; §13 step 1 lists all three): a **plaintext |
| +grep** over the raw SQLite file and WAL **plus this app's store objects**, |
| +against a seeded corpus of known addresses; a **no-address-at-rest** test |
| +asserting the schema has no address column and that a full sign-in plus |
| +directory upsert **plus rekey** leaves no readable address anywhere, **WAL |
| +included**; and **no-content-in-logs** (gate 9). **Gates 1 and 2 need a |
| +database and the skeleton has none. Gate 9 needs no database** — it scans |
| +strings, and `cmd/home/main.go` already emits log lines — **so it is not |
| +sequenced behind one**. §13 step 1 lists all three beside the skeleton. A guard written after the code it guards is a guard written to |
| +pass. (Stated as the rule rather than as a status: which gates exist today |
| +is a question for `git log`, and a status line in a binding file is a |
| +sentence that goes stale without anyone noticing.) |
| --- |
| @@ -44,7 +57,9 @@ improvement. |
| team in an app. What you may *do* there is the instance's business, |
| decided from its own grants. |
| 3. **Never federate.** Data does not cross an instance boundary. Sharing is |
| - membership. |
| + a grant on the instance — member or guest — never a copy. §6 keeps the |
| + two apart deliberately: "a row implying an org membership someone does |
| + not have is a lie the directory tells on the org's behalf". |
| 4. **Never become a support console.** Reading someone's data to help them |
| is precisely what the one rule forbids. |
| @@ -96,20 +111,48 @@ quietly ignored). |
| minting, and **never renders an account-authored string**. It is the |
| relying party. This is a permanent constraint with its own test, not a |
| naming choice. |
| +- **The RP ID is the full host — `id.oficina.works` — asserted by a test.** |
| + Never widen it to a registrable suffix. WebAuthn *permits* an RP ID that |
| + is a suffix of the origin's hostname, so `oficina.works` would be |
| + accepted; it is also valid at every host in the zone, the directory and |
| + every team instance included, which collapses the very split the two |
| + origins exist to make (§4, rulings 2 and 13; the host itself is ruling |
| + 16). Note where the first door actually is: `passkey.Config` has no RP ID |
| + field — the RP ID is always `Origin`'s hostname — so the way to widen it |
| + is to widen `Origin`, or to bypass `passkey` and build a |
| + `webauthn.Config` directly. The second door is |
| + `passkey.Config.LegacyRPID`, passed straight through as an RP ID accepted |
| + **for assertions only** (registration never takes it), so it must stay |
| + empty unless a rename drill (§5.7) is actually running, and then hold a |
| + full host. The test asserts both: `Origin`'s hostname is exactly |
| + `id.oficina.works`, and `LegacyRPID` is empty or a full host. Both are |
| + accepted silently, which is why neither can rest on care. |
| - Assertions carry **identity and display preferences, never |
| - authorization**. A claim may ride an assertion only if an instance would |
| - behave identically given a hostile value. A wrong theme is ugly; a wrong |
| - role is a breach. |
| -- Assertion expiry is **seconds**. The long-lived thing is the session the |
| - instance mints. |
| + authorization**. The boundary test (§5.4) is **two-sided**: a claim may |
| + ride the assertion only if **(a)** the instance makes no trust decision |
| + based on it, validating against a **closed enumerated set** and treating a |
| + failed match as *absent* rather than as the raw value; **and (b)** the |
| + home already holds it for its own purposes, so carrying it adds nothing to |
| + what the home knows. Theme passes both. A role fails (a). **"Which |
| + documents you can see" passes (a) and fails (b)** — it hands the home a |
| + document-level graph it has no business holding. |
| +- **Do not restore "the instance would behave identically given a hostile |
| + value."** §5.4 retracts it: a hostile theme gives an ugly page, which *is* |
| + different behaviour, so the test's own example fails it — and without (a)'s |
| + closed set, a free-string theme in CSS is an injection, not an ugly page. |
| +- Assertion expiry is **120 seconds, not "seconds"** — sized to worst-case |
| + cold wake plus clock skew, because the exchange request *is* what wakes a |
| + hibernating instance and a tighter window makes the first sign-in to any |
| + cold instance fail and loop. Widening it costs nothing **against the |
| + threat model**, because **single-use `jti` is what stops replay**, not |
| + brevity. The long-lived thing is the session the instance mints. |
| - Every assertion names the **app and instance host** it is for. Without |
| audience binding, team A's assertion replays against team B, and a |
| deployment has thousands of siblings to replay against. |
| - **Signed identity claims are trusted for identity; every other claim must |
| - be cosmetically failable.** The subject itself fails the "hostile value |
| - changes nothing" test — a hostile subject is account takeover — so the |
| - test must exempt identity by construction or it proves too much and gets |
| - argued around. |
| + be cosmetically failable.** The subject flagrantly fails (a) — a hostile |
| + subject is account takeover — so the test must exempt identity by |
| + construction or it proves too much and gets argued around. |
| - **Any "prune when all consumers have caught up" is unbounded in a |
| hibernating fleet.** A dormant instance runs no timers to advance its |
| cursor. Retention is a fixed horizon or a per-consumer TTL, never a |
| @@ -126,7 +169,10 @@ quietly ignored). |
| any delete. `PRAGMA secure_delete` is on. |
| - **The session Subject is an opaque person ref, never an address.** |
| `rastrillo/auth` as shipped sets `Subject: id.Address` and stores readable |
| - addresses in `auth_links` and `auth_sessions` — verified at source, filed |
| + addresses in `auth_links.address` and, via `Subject: id.Address`, |
| + in `sessions.subject` — `auth_sessions` is no longer written at v0.22.0, |
| + its rows having been migrated into the shared `sessions` table, so the |
| + leak moved rather than closed. Verified at source, filed |
| upstream as `rastrillo/rastrillo` #2. We hand-roll session minting until |
| that lands, and use `auth` only for address classification and the Keymail |
| upgrade, which hold nothing. |
| @@ -134,26 +180,91 @@ quietly ignored). |
| instance makes one compromise an offline dictionary attack against the |
| whole suite directory. |
| - Migrations are additive-only. |
| -- **No address, team name, or queue payload in any log line, error string or |
| - metric label.** Platform-held logs are storage we cannot grep. |
| +- **No address, team name, or queue payload in any log line, error string, |
| + job status message or metric label.** Platform-held logs are storage we |
| + cannot grep. |
| **Frontend** |
| -- Write `class="rst-*"`. The bare-attribute grammar is ruled but not |
| - released — the shipped `ui/tokens.css` has zero **bare `rst-*`** attribute |
| - selectors against ~528 `.rst-` class selector occurrences. (It does carry |
| - `[data-tone=]`, `[aria-*]` and `[type=]`; "zero attribute selectors" would |
| - not survive being checked.) |
| -- **Re-copy `static/tokens.css` on every rastrillo upgrade.** It is frozen |
| - at scaffold time while partials upgrade with the module. Adopt |
| - `rastrillo doctor` the day it lands and delete this step. |
| -- Zero JS. **No surface here has any reason to require script**, and the day |
| - one does is the day the spec is wrong. |
| +- **Write the attribute spelling: `rst-btn`, not `class="rst-btn"`** — |
| + spec ruling 20, 2026-09-05. §11 ruled the class vocabulary on 2026-08-30 |
| + and every reason it gave has since died, so it went back to Paul rather |
| + than being propped up with a third. rastrillo's design system rules "One |
| + spelling, not two. No permanent class fallback" and drops the class |
| + selectors before 1.0; this app has no `rst-` markup yet, so it starts on |
| + the spelling that survives — free today, a sweep after the first |
| + template. |
| + |
| + **The seven utilities are the exception, and they are not a loophole.** |
| + `rst-sr-only`, `rst-mono`, `rst-m-hide`, `rst-grow`, `rst-nm`, |
| + `rst-danger` and `rst-cell-mut` keep `class=`, because at v0.22.0 they |
| + have **no attribute twin at all** — five `.rst-sr-only` selectors, zero |
| + `[rst-sr-only]`. tokens.css's header says why ("Utilities keep the class |
| + spelling and are not paired… they stay class after the move") and §6-v3 |
| + agrees ("`class` is for utilities and the app's own CSS"). Write |
| + `<span rst-sr-only>` and nothing matches, so screen-reader-only text |
| + renders **visible**. Kinds, variants and parts are attributes; these |
| + seven are classes. This is also what the 6 surviving `class=` occurrences |
| + in v0.22.0's partials are — utilities, not residue awaiting a flip. |
| + |
| + Record the graveyard, so neither dead reason is revived: |
| + - *"The attribute grammar is ruled but not released."* Dead since |
| + **v0.21.0**, this repo's *first* pin. tokens.css there pairs every |
| + *component* rule with an attribute twin and says so in its own header: "TWO |
| + SPELLINGS, ON PURPOSE, FOR ONE RELEASE." The original claim — zero bare |
| + `rst-*` selectors against ~528 class occurrences — was verified on |
| + 2026-08-30 against v0.20.x, was true then, and was already false by the |
| + time the skeleton existed. No later bump falsified it. |
| + - *"The partials we render alongside write classes."* Dead at |
| + **v0.22.0**, the current pin, where the shipped partials flipped to the |
| + attribute spelling — rastrillo's design-system §6-v3 stage 2. Counted |
| + in `ui/partials/` with template comments stripped, the class spelling |
| + falls from **124 occurrences at v0.21.0 to 6 at v0.22.0**, replaced by |
| + the attribute spelling. (Count the class spelling, not the attribute |
| + one: `class="rst-…"` is unambiguous, while attribute-spelling totals |
| + move with how you treat valued attributes like `rst-tone="…"`, and two |
| + careful counts of it disagreed.) `callout.html` at the pin |
| + is `<div rst-callout rst-tone="…">`. tokens.css's header still claims |
| + "the class spelling is what every app and every shipped partial writes |
| + today" — **that sentence is stale upstream**, and this file quoted it |
| + as evidence until it was checked. |
| + **Re-check on every pin move, name the version you checked, and count |
| + both `ui/tokens.css` and `ui/partials/`** — checking one and not the |
| + other is how the second reason survived a pass that was looking for |
| + exactly this. |
| +- **`rastrillo doctor` has landed** — in v0.21.0, the first version this |
| + repo pinned. "Adopt it the day it lands" was a trigger that had already |
| + fired when it was written. When this app vendors `static/`, run |
| + `rastrillo doctor --fix` on each upgrade rather than re-copying by hand, |
| + and add the `TestVendoredAssetsMatchTheLibrary` that `rastrillo new` |
| + scaffolds as the standing gate — "add", not "keep": this repo was |
| + hand-written, not scaffolded, so it has no `internal/hometest/` and no |
| + copy of that test. There is no vendored `static/` in the |
| + tree today — `internal/home/page.go` links no stylesheet, deliberately — |
| + so this is an instruction with no object until the first asset arrives. |
| +- Zero JS, **with exactly one carve-out** (ruling 21): the passkey |
| + enrolment and ceremony screens **may load script**. rastrillo ships the |
| + hard half — `webauthn.JS()` embeds `js/webauthn.mjs` — but that module |
| + only exports `navigator.credentials` wrappers and makes no network call, |
| + so the page also needs a few lines of app glue to import it, call it and |
| + POST the result. Both are allowed; both are **static same-origin files, |
| + never inline `<script>`**. |
| + That sentence used to read "no surface here has any reason to require |
| + script, and the day one does is the day the spec is wrong" — and the day |
| + came, with passkeys. It was a tripwire and it fired correctly. Everything |
| + else stays scriptless: magic-link sign-in, recovery-code redemption and |
| + the springboard, so script-off keeps a working sign-in and loses only an |
| + optional second factor. The no-`<script>` test carries an **explicit |
| + allowlist of the carved-out screens** — never a blanket exemption, because |
| + a rule with a list is checkable and a rule with an escape hatch is not. |
| **Deploys** |
| - One binary, `CGO_ENABLED=0`, cgo-free SQLite. |
| - A 200 is not a deploy verification. Only `X-Carlos-Version` on the |
| canonical host, after the watch. |
| - A promote is not a deploy. Until the process cycles, the old binary serves. |
| +- **The home does not hibernate.** It is the one app on the estate where |
| + cold-wake latency is a suite-wide outage. Stated because hibernation is |
| + the platform default and would otherwise apply silently. |
| - **A backup you have never restored is a hope, not a backup.** Losing this |
| database loses credentials, which do not rebuild. The restore drill runs |
| before launch. |