| 1 | # Oficina home — §13 step 3: assertion minting, the redirect protocol, gate 3 |
| 2 | |
| 3 | **Status: DRAFT, unreviewed.** Written 2026-09-07 immediately after §13 |
| 4 | step 2 landed. It has had no review pass; the step-2 plan took seven, and |
| 5 | every one of them found something. Do not execute this as though it were |
| 6 | finished. |
| 7 | |
| 8 | **What this step unblocks.** §13's own note: "Sheets and Docs are |
| 9 | unblocked from step 3, not step 9 — assertions plus a stub directory is |
| 10 | enough for both." Nothing in the suite can authenticate a person against |
| 11 | an instance until this exists. |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## Global constraints |
| 16 | |
| 17 | Everything in the step-2 plan's Global Constraints still binds: the |
| 18 | gates, the cadence, `CGO_ENABLED=0`, the review requirement. Two more |
| 19 | apply only here. |
| 20 | |
| 21 | **Every claim on the assertion faces §5.4's two-sided test.** A claim may |
| 22 | ride only if (a) the instance makes no trust decision on it, validating |
| 23 | against a closed enumerated set and treating a failed match as *absent*, |
| 24 | **and** (b) the home already holds it for its own purposes. There is |
| 25 | exactly one exception, added 2026-09-05 and scoped by the suite doctrine |
| 26 | to the call-8 path: `roster_epoch`, which is an invalidation hint and |
| 27 | never a decision. |
| 28 | |
| 29 | **The credential origin renders no account-authored string, ever** (§4). |
| 30 | That constrains where every page in this step may live, and it is why |
| 31 | §5.6's return host is validated rather than displayed. |
| 32 | |
| 33 | --- |
| 34 | |
| 35 | ## Two prerequisites that are not the executor's |
| 36 | |
| 37 | ### 1. The origin split is not wired |
| 38 | |
| 39 | §4 requires two hostnames with different jobs: `id.oficina.works` (the |
| 40 | credential origin, the WebAuthn RP) and `oficina.works` (the directory, |
| 41 | at the apex). **Today both names serve the same deployment** — checked |
| 42 | 2026-09-07, both answer 200 from `/api/version` `0d0262a`, which is the |
| 43 | skeleton build — which means |
| 44 | §4's guarantee — "an XSS on the directory cannot reach the RP" — is |
| 45 | currently not true, it is merely unexercised, because nothing renders a |
| 46 | team name yet. |
| 47 | |
| 48 | Step 2 already hard-codes `Origin = "https://id.oficina.works"` as the |
| 49 | CSRF origin, the session cookie origin and the WebAuthn RP ID, and its |
| 50 | `TestRPIDIsExactlyTheCredentialHost` asserts it. So the app is already |
| 51 | written as though the split exists. |
| 52 | |
| 53 | **What must be true before this step ships:** a request to |
| 54 | `oficina.works` and a request to `id.oficina.works` reach code that knows |
| 55 | which one it is, and the session cookie set on the credential origin is |
| 56 | not readable by the directory. The cookie half is already true — it is |
| 57 | `__Host-`, host-only, asserted by |
| 58 | `TestNoCookieOnTheCredentialOriginCarriesADomain`. The routing half is |
| 59 | not. |
| 60 | |
| 61 | **Verify before planning further, and do not assume:** what |
| 62 | `oficina.works` and `id.oficina.works` currently resolve to, whether the |
| 63 | carlos edge routes them to one app or two, and whether one binary serving |
| 64 | both hostnames is the intended shape or two deployments are. This plan |
| 65 | assumes ONE binary routing on `Host`, because §4 says the directory |
| 66 | obtains its session by exchanging an assertion "so there is one assertion |
| 67 | path rather than two" — which only needs saying if the two halves might |
| 68 | otherwise have shared a process. That reading should be confirmed. |
| 69 | |
| 70 | ### 2. A fifth secret |
| 71 | |
| 72 | `HOME_ASSERTION_KEY` — the Ed25519 private key. §5.3: "Private key in |
| 73 | platform config, never in the database." |
| 74 | |
| 75 | **Unlike the other four, this one is not a one-way door**, and that is |
| 76 | worth stating because it changes who may mint it and what losing it |
| 77 | costs. Verification keys are published and cached by `kid`; a verifier |
| 78 | refetches once on an unknown `kid` and then fails closed. So a lost |
| 79 | signing key costs a rotation and a brief window of failed exchanges, not |
| 80 | unreadable data. Losing `HOME_DEK` makes every sealed link unreadable; |
| 81 | losing `HOME_PEPPER` orphans every person row. This one is recoverable. |
| 82 | |
| 83 | It is still platform config and still Paul's to provision, on the same |
| 84 | argument as the rest: an executor that mints production secrets is an |
| 85 | executor that has decided something it should not. |
| 86 | |
| 87 | --- |
| 88 | |
| 89 | ## Three things to settle before Task 2 — not the executor's to decide |
| 90 | |
| 91 | ### Two things to adopt from the SSO plugin work, not re-derive |
| 92 | |
| 93 | From the rastrillo SSO session, 2026-09-07, and verified here: |
| 94 | |
| 95 | **Never key an enforcement decision on `sessions.Session.Method`.** |
| 96 | `passkey` rewrites it: `SignInFinish` writes `"<first factor>+passkey"`, |
| 97 | `SignInRecovery` writes `"<first factor>+recovery"`, and `StepUpFinish` |
| 98 | writes bare `"passkey"` (verified at the v0.22.0 pin). So a check like |
| 99 | "was this a magic-link session?" silently changes answer after a step |
| 100 | up. Their spec adds `sessions.Session.Provider`, written |
| 101 | `"<method>:<tenant>"` — an assertion plugin would write |
| 102 | `"assertion:<issuer>"` — precisely so enforcement has something stable |
| 103 | to read. |
| 104 | |
| 105 | **Home has no such bug today**: `grep` shows it only ever WRITES |
| 106 | `Method` and never reads it for a decision. Step 3 is exactly where one |
| 107 | would appear, since the design's §6 mints `Method: "assertion"` and the |
| 108 | obvious next thing anybody writes is "was this a strong sign-in?". |
| 109 | |
| 110 | Also worth importing rather than rebuilding: `sessions.Handle/Lookup/ |
| 111 | Revoke`, which is what lets a re-authentication revoke the session that |
| 112 | started it. Both land on their `sso-saml` branch first, so **this step |
| 113 | has a sequencing dependency on that branch**, not just on §9. |
| 114 | |
| 115 | ### Ruling A: SUPERSEDED — rastrillo already has a merged design |
| 116 | |
| 117 | **Read this before anything below it.** Checked 2026-09-07: |
| 118 | `rastrillo/rastrillo` discussion **#1**, "An instance needs to verify a |
| 119 | signed identity assertion", is open and was filed jointly by the Sheets |
| 120 | and Docs design sessions, with Paul ruling on the shape for both. The |
| 121 | design is **written and merged** — PR #128, 2026-08-31, |
| 122 | `docs/superpowers/specs/2026-08-31-identity-assertions-design.md` in |
| 123 | rastrillo. What does not exist is the code: there is no Ed25519 and no |
| 124 | assertion package anywhere in rastrillo's non-test Go. |
| 125 | |
| 126 | **It contradicts this plan, and on the evidence rastrillo is right.** |
| 127 | This section below recommends a hand-rolled compact JWS. rastrillo's |
| 128 | design says explicitly that the wire format **should not be a JWT even |
| 129 | in shape**, and builds on `crypto`'s fixed primitive instead — one curve |
| 130 | (ECDSA P-256), one hash, one encoding, and a domain-separating context |
| 131 | string pinned to `rastrillo/assertion/v1`. Its reasoning is that |
| 132 | `crypto` has no algorithm agility at all, so there is no field in which |
| 133 | to say "none" and no second algorithm to downgrade to: **algorithm |
| 134 | confusion is designed out rather than defended against.** That is a |
| 135 | better answer than this plan's on precisely the failure mode the second |
| 136 | opinion below listed first. |
| 137 | |
| 138 | Its wire format is `base64url(payload) "." base64url(signature)`, with |
| 139 | `kid` INSIDE the signed payload and a strict order — decode, check `v`, |
| 140 | select key by `kid`, verify the signature, and only then read any other |
| 141 | claim; nothing before the signature check may have a side effect. |
| 142 | |
| 143 | **So home must not hand-roll this.** Doing so would make it the third |
| 144 | app to hand-roll assertion verification differently, which is the exact |
| 145 | outcome discussion #1 exists to prevent. |
| 146 | |
| 147 | **A GAP IN THE MERGED DESIGN, found by the rastrillo SSO session and |
| 148 | verified here.** The design has **no browser binding at all** — searched |
| 149 | for browser, cookie, bound, transaction and CSRF, and its only replay |
| 150 | controls are `jti` single use plus short expiry (§4). Neither stops this: |
| 151 | an attacker completes their own handoff, does not use the assertion, and |
| 152 | hands it to a victim, who is signed in as the attacker. Single use |
| 153 | prevents a SECOND use, not a first use by the wrong person; short expiry |
| 154 | only shortens the window. |
| 155 | |
| 156 | Home §5.6 already requires "a browser-bound state nonce" and gives this |
| 157 | exact reason. rastrillo's design does not, and rastrillo's is the one |
| 158 | that would be implemented — so every consumer would inherit the gap. |
| 159 | Their fix on the SSO side is a transaction cookie set on the outbound |
| 160 | leg with its hash on the request row; the shape home already uses for |
| 161 | the keymail pending cookie. |
| 162 | |
| 163 | This is the third time this one attack has surfaced on this project: |
| 164 | the GET-redeemed magic link, the keymail callback, and codex naming it |
| 165 | unprompted as the most commonly forgotten thing when asked about |
| 166 | assertions. |
| 167 | |
| 168 | **Two conflicts for Paul, both between specs rather than within one:** |
| 169 | |
| 170 | 1. Home §5.3 says "Signature: Ed25519". rastrillo's design says ECDSA |
| 171 | P-256 via `crypto`. Home's choice carries no stated reasoning; |
| 172 | rastrillo's carries the argument above. One of the two specs has to |
| 173 | change. |
| 174 | 2. Home §5.3 publishes verification keys on the credential origin |
| 175 | (fetched). rastrillo's §9 Q2 recommends **pinned** keys on the |
| 176 | never-speak doctrine, and says the rotation counter-argument "is not |
| 177 | weak". Home is the signer, so this one is home's problem more than |
| 178 | any consumer's. |
| 179 | |
| 180 | **What actually unblocks this step** is not a new issue — it is Paul |
| 181 | answering rastrillo's §9, especially Q1 ("both halves, or verify only?", |
| 182 | which that design says "is the first thing to rule on, because it sizes |
| 183 | everything else") and Q2. Then rastrillo implements, and home consumes |
| 184 | it. |
| 185 | |
| 186 | **Meanwhile Tasks 4, 5 and 6 below are independent of the signature |
| 187 | format** — the redirect protocol, the `jti` store and gate 3 do not care |
| 188 | how the bytes are signed. That is where to work while #1 is unblocked. |
| 189 | |
| 190 | --- |
| 191 | |
| 192 | ### Ruling A (superseded — kept for the reasoning): how the assertion is signed and encoded |
| 193 | |
| 194 | **§5.3 says Ed25519. rastrillo cannot supply it.** Verified at the |
| 195 | v0.22.0 pin: `rastrillo/crypto` is **ECDSA P-256**, signing over |
| 196 | `SHA-256(context ‖ 0x00 ‖ msg)` and returning a raw 64-byte r‖s — a |
| 197 | different curve and a bespoke scheme, not a JWS. `rastrillo/keyring` is |
| 198 | an E2EE key-wrapping package and not this. There is no JWT anywhere in |
| 199 | the module. |
| 200 | |
| 201 | So the choices are: |
| 202 | |
| 203 | 1. **Hand-rolled compact JWS** over stdlib `crypto/ed25519`: base64url |
| 204 | header, base64url claims, `EdDSA` alg, one signature. The minter is |
| 205 | genuinely small. The verifier is not — see the checklist below, which |
| 206 | is what "safe" actually costs. |
| 207 | 2. **A third-party JWT library**, and a new dependency in the home and in |
| 208 | every consumer. |
| 209 | |
| 210 | The tradeoff is not "how hard is it to write" — option 1's minter is |
| 211 | easy. It is that **every consumer app has to VERIFY**, and a verifier is |
| 212 | where the dangerous mistakes live: accepting `alg: none`, not checking |
| 213 | `aud`, not checking `exp`, accepting an unknown `kid`. Writing that once |
| 214 | in a shared package the suite imports is option 1 done properly; writing |
| 215 | it three times in three apps is option 1 done the way it usually goes. |
| 216 | |
| 217 | **Recommendation, for Paul to accept or reject:** option 1, with the |
| 218 | verifier shipped as a package the suite imports — so there is one |
| 219 | verifier, not one per app — and with `alg` allowlisted to `EdDSA` by |
| 220 | whitelist rather than by rejecting `none`. This is a §1 row. |
| 221 | |
| 222 | **Second opinion, 2026-09-07, `gpt-6-astra` via codex** (the account's |
| 223 | default; named models are refused, and the rollout was checked rather |
| 224 | than assumed). It agreed with option 1 under these constraints, and |
| 225 | corrected the framing above: an earlier draft of this section put the |
| 226 | verifier at "60 lines", and its response was that parsing, replay |
| 227 | protection and key handling are what decide safety, so a line-count |
| 228 | target is the wrong goal. It also named a third option this plan had not |
| 229 | considered, and the strongest argument against the recommendation. Both |
| 230 | are recorded below rather than filtered out. |
| 231 | |
| 232 | **The verifier must do all of this, in this order.** This is the list |
| 233 | that makes option 1 defensible; without it, option 1 is the bad version |
| 234 | of itself. |
| 235 | |
| 236 | 1. **Bound and parse.** Cap the token size. Require exactly three |
| 237 | non-empty segments and strict unpadded base64url. Require JSON |
| 238 | objects with no duplicate keys, no trailing data, no case-insensitive |
| 239 | field aliases. |
| 240 | 2. **Constrain the header.** Require exactly `alg: "EdDSA"`, a bounded |
| 241 | non-empty `kid`, and a dedicated `typ` for this assertion. Reject |
| 242 | `crit`, `b64`, and any token-supplied key or key URL. |
| 243 | 3. **Resolve a trusted key.** Only from the configured home's HTTPS key |
| 244 | endpoint, never from the token. Require an Ed25519 public key of the |
| 245 | right length. Refetch once on an unknown `kid`, then reject; coalesce |
| 246 | and rate-limit those refetches so an unknown `kid` is not a way to |
| 247 | make every verifier hammer the home. |
| 248 | 4. **Verify over the ORIGINAL bytes.** `ed25519.Verify` against the |
| 249 | encoded `header.payload` exactly as received. Never decode and |
| 250 | re-serialise before verifying — that is how a signature ends up |
| 251 | covering something other than what was read. |
| 252 | 5. **Validate the claims.** Correct types for `sub`, `iss`, `aud`, |
| 253 | `iat`, `exp`, `jti`. Exact match on issuer and on this instance's own |
| 254 | app+host audience. Integer timestamps, `0 < exp-iat <= 120`, `exp` in |
| 255 | the future, `iat` not beyond a small explicit skew. An unknown enum |
| 256 | value becomes absent; a malformed integer hint is ignored and never |
| 257 | grants anything. |
| 258 | 6. **Consume before minting.** Atomically insert `(iss, jti)` and reject |
| 259 | on conflict. Retain through `exp` **plus the accepted skew**, not |
| 260 | just `exp`. Survive restarts. A storage failure is a rejection, never |
| 261 | a pass. |
| 262 | |
| 263 | **And the thing it says is most commonly forgotten: login CSRF.** A |
| 264 | valid, single-use assertion does not by itself stop an attacker fixing |
| 265 | their own assertion onto a victim's browser. The receiving endpoint must |
| 266 | bind the exchange to the browser's own login transaction. §5.6 already |
| 267 | requires exactly that ("a browser-bound state nonce"), so this is |
| 268 | independent confirmation of the spec rather than a new requirement — |
| 269 | and it is the third time this same attack has come up in this project, |
| 270 | after the magic link and the keymail callback. |
| 271 | |
| 272 | **The third option, recorded because it was not considered:** a shared |
| 273 | policy package backed by a JWT library. Every app imports one package |
| 274 | that exposes only this protocol, so configuration stays in one place; |
| 275 | the dependency is still in every binary, but it is configured once |
| 276 | rather than four times. Strictly better than option 2 as stated. |
| 277 | |
| 278 | **The strongest argument against the recommendation, in its own words:** |
| 279 | maintenance concentration. One person plus agents owns every parsing |
| 280 | edge case and every future protocol change, and a single shared verifier |
| 281 | distributes one mistake across the whole suite. A maintained library |
| 282 | brings outside scrutiny. Its condition was explicit — **if independent |
| 283 | security review and adversarial testing will not happen, take the |
| 284 | library-backed shared package instead.** On this project review does |
| 285 | happen and has repeatedly caught exactly this class of defect, which is |
| 286 | the argument for option 1 rather than against it; but the condition is |
| 287 | the thing to check, not the conclusion. |
| 288 | |
| 289 | ### Ruling B: what a "consumer" is, concretely |
| 290 | |
| 291 | §5.6 requires return hosts validated against a consumers allowlist, |
| 292 | "otherwise the credential origin is an open redirector". §10 lists a |
| 293 | `consumers` table. Nothing yet says how a row gets into it. |
| 294 | |
| 295 | Two shapes, and the choice decides how provisioning works in step 5: |
| 296 | the allowlist is **derived** from the team/app/host records the home |
| 297 | already holds (a host is a valid return target if it is an instance the |
| 298 | home provisioned), or it is **its own record** written at provisioning. |
| 299 | |
| 300 | The derived shape has no second thing to keep in sync and no way to |
| 301 | forget a row. The explicit shape can express "this host may receive |
| 302 | assertions" separately from "this host exists", which matters the day an |
| 303 | instance is decommissioned but its hostname still resolves. **§8.3 and |
| 304 | the decommission path in step 8 probably decide this**, so it should be |
| 305 | read before ruling. |
| 306 | |
| 307 | ### Ruling C: does the directory exchange an assertion against itself? |
| 308 | |
| 309 | §4: "The directory obtains its session by exchanging an assertion, the |
| 310 | same mechanism instances use (§5.3), so there is one assertion path |
| 311 | rather than two." |
| 312 | |
| 313 | If both origins are one process, the directory half of that process is a |
| 314 | consumer of the credential half. That is implementable and probably |
| 315 | right — it means the directory's session is a directory-origin cookie |
| 316 | minted from a verified assertion, and an XSS there reaches a session that |
| 317 | opens the directory and nothing else. But it means the home holds its own |
| 318 | `aud`, verifies its own signature, and stores `jti` for itself, which |
| 319 | reads oddly and should be deliberate rather than incidental. §10 already |
| 320 | says `assertion_jti` is "the directory origin's own store", which is |
| 321 | evidence the answer is yes. |
| 322 | |
| 323 | --- |
| 324 | |
| 325 | ## Tasks |
| 326 | |
| 327 | Task boundaries follow step 2's discipline: each one lands green, with |
| 328 | its gate, and nothing routes a handler before the store beneath it |
| 329 | exists. |
| 330 | |
| 331 | ### Task 1: The origin split, in the app |
| 332 | |
| 333 | **Files:** `internal/home/origins.go`, `internal/home/origins_test.go`, |
| 334 | modify `internal/home/app.go`. |
| 335 | |
| 336 | One binary, two hostnames, and a router that knows which is which. The |
| 337 | credential routes (`/signin*`, `/passkey/*`) answer only on |
| 338 | `id.oficina.works`; the directory routes answer only on the apex. |
| 339 | |
| 340 | - [ ] Add `directoryOrigin` beside `Origin`, both constants, both derived |
| 341 | from one `suiteDomain` so they cannot drift. |
| 342 | - [ ] A `Host`-routing middleware. A request for a hostname the app does |
| 343 | not serve gets one answer, not a stack trace. |
| 344 | - [ ] **The guard, which is the point of the task:** a test that walks |
| 345 | the router and asserts every credential route refuses on the |
| 346 | directory host and every directory route refuses on the credential |
| 347 | host. Written now, while there is exactly one directory route to |
| 348 | forget. |
| 349 | - [ ] Extend `TestRPIDIsExactlyTheCredentialHost` to assert the two |
| 350 | origins are different hostnames, and that neither is a suffix of |
| 351 | the other in a way that would let a cookie or an RP ID span both. |
| 352 | |
| 353 | **Do not** move sign-in behind a host check without checking the test |
| 354 | helpers: step 2's `signIn`, `redeem` and `postJSON` set `Origin` but |
| 355 | build requests with `httptest.NewRequest`, whose `Host` is |
| 356 | `example.com`. Every one of them will need the credential host, and they |
| 357 | will fail in a way that looks like a routing bug rather than a test-setup |
| 358 | bug. |
| 359 | |
| 360 | ### Task 2: The signing key, the key set, and `kid` |
| 361 | |
| 362 | **Files:** `internal/home/assertion.go`, `internal/home/assertion_test.go`, |
| 363 | modify `internal/home/keys.go`. |
| 364 | |
| 365 | - [ ] `HOME_ASSERTION_KEY` joins `LoadSecrets`, base64, exactly 32 bytes |
| 366 | (an Ed25519 seed), refused when all-zero like the rest. |
| 367 | - [ ] `kid` is derived from the public key, not configured — a |
| 368 | configured `kid` is a second thing to get wrong, and §5.3 only |
| 369 | needs it to name a key, not to be chosen. |
| 370 | - [ ] `GET /.well-known/...` on the credential origin publishes the |
| 371 | verification key. **The exact path and document shape are Ruling |
| 372 | A's to settle**; do not invent one. |
| 373 | - [ ] §5.3 says two keys are active during rotation. Build the key set as |
| 374 | a slice from the start, even with one member: a publisher that can |
| 375 | only express one key is a rotation that requires a deploy. |
| 376 | |
| 377 | ### Task 3: Minting |
| 378 | |
| 379 | **Files:** modify `internal/home/assertion.go`. |
| 380 | |
| 381 | Every claim in §5.3's table, and nothing else. `sub` is the person ref. |
| 382 | `exp` is **120 seconds** — sized for a cold instance wake, and the |
| 383 | paragraph in §5.3 explains why tightening it makes the first sign-in to |
| 384 | a cold instance fail and loop. Single-use `jti` is what stops replay, not |
| 385 | brevity; do not "harden" this by shortening it. |
| 386 | |
| 387 | - [ ] Mint, with a test per claim. |
| 388 | - [ ] A test that the claim set is CLOSED: an assertion carries exactly |
| 389 | the claims §5.4 admits and no others. This is the test that stops |
| 390 | somebody adding a display name "just for the header". |
| 391 | - [ ] `prefs` carries theme only, validated against a closed enumerated |
| 392 | set, a failed match treated as absent. |
| 393 | |
| 394 | ### Task 4: The redirect protocol (§5.6) |
| 395 | |
| 396 | **Files:** `internal/home/exchange.go`, `internal/home/exchange_test.go`. |
| 397 | |
| 398 | All three of §5.6's requirements are in gate 3, so they land together: |
| 399 | |
| 400 | - [ ] Return hosts validated against the consumers allowlist (Ruling B). |
| 401 | - [ ] A browser-bound state nonce. **This is the same attack step 2's |
| 402 | POST-redeems design closed one hop earlier** — without it an |
| 403 | attacker fixes their own signed assertion onto a victim's browser |
| 404 | and the victim works inside the attacker's account. Step 2's |
| 405 | `__Host-` pending cookie is the shape to copy. |
| 406 | - [ ] The return URL preserves its query string, validated host and all. |
| 407 | Ruling 22 puts the team in `?team=<sqid>`, so dropping it returns a |
| 408 | person to a different team than the one they were sent. |
| 409 | - [ ] Delivery is an **auto-POST**, not a query string, so assertions |
| 410 | stay out of history and referrers. It is a form that submits |
| 411 | itself — which needs script, and §11's carve-out does not cover |
| 412 | it. **Check ruling 21's wording before building this**: a |
| 413 | scriptless auto-POST is a form with a submit button and a sentence |
| 414 | telling the person to press it, which is worse UX and needs no |
| 415 | exemption. That choice is not settled here. |
| 416 | |
| 417 | ### Task 5: `jti`, single use |
| 418 | |
| 419 | **Files:** `internal/home/migrations/0003_assertion_jti.sql`, modify |
| 420 | `internal/home/exchange.go`. |
| 421 | |
| 422 | Self-pruning, sized by assertion lifetime rather than user count. §10's |
| 423 | `assertion_jti` is the directory origin's own store, not a shared one. |
| 424 | |
| 425 | - [ ] The consume is one statement, the way `TakeLink` is — an |
| 426 | `INSERT ... ON CONFLICT DO NOTHING RETURNING` or a `DELETE ... |
| 427 | RETURNING`, so two concurrent exchanges of one assertion cannot |
| 428 | both succeed. Step 2's `TestConcurrentTakeLinkYieldsOneWinner` is |
| 429 | the test to copy, and the mutation that proves it (a |
| 430 | SELECT-then-INSERT) is the one to run. |
| 431 | - [ ] Pruning on write plus at boot, the shape step 2 settled on. |
| 432 | |
| 433 | ### Task 6: Gate 3 |
| 434 | |
| 435 | Gate 3 is §5.6's three requirements asserted together. Following step 2's |
| 436 | hard-won rule: **write the break that proves each one bites**, and record |
| 437 | the output in the commit. A gate never seen to fail is a gate written to |
| 438 | pass — that finding came back three times in step 2 and was right every |
| 439 | time. |
| 440 | |
| 441 | --- |
| 442 | |
| 443 | ## What this plan does NOT cover |
| 444 | |
| 445 | The directory's own pages, calls 2 and 3, and anything that renders a |
| 446 | team name: those are step 4. This step builds the mechanism that lets an |
| 447 | instance trust a person, and a stub directory is explicitly enough for |
| 448 | Sheets and Docs to start. |
| 449 | |
| 450 | ## Before executing this |
| 451 | |
| 452 | 1. Get Rulings A, B and C from Paul. |
| 453 | 2. Have this plan reviewed adversarially, at least twice. Every claim it |
| 454 | makes about rastrillo was checked at the v0.22.0 pin on 2026-09-07; |
| 455 | every claim it makes about the platform was not. |
| 456 | 3. Confirm the two prerequisites above are real and unblocked. |
| 457 | |