| index 807747a..e60dbaa 100644 |
| --- a/docs/superpowers/specs/2026-09-01-movement-design.md |
| +++ b/docs/superpowers/specs/2026-09-01-movement-design.md |
| @@ -1,13 +1,15 @@ |
| # 🤖 Movement — design |
| |
| -**Status:** design, revised 2026-09-01 after review. Approved in brainstorming |
| -by Paul on 2026-09-01; revised the same day against a review dispatched to |
| -**Fable** (22 findings, all accepted or answered). Every ruling is dated and |
| -attributed, with the rejected alternative beside it. |
| -**Reviewing model, per §11:** dispatched to Fable, 2026-09-01. *(Recorded as |
| -what was dispatched, not what an agent self-reported — models are unreliable |
| -about their own identity.)* Rulings 12–22 are material changes made after that |
| -pass and require a second review before implementation begins. |
| +**Status:** design, revised twice on 2026-09-01. Approved in brainstorming by |
| +Paul on 2026-09-01; revised the same day against two review passes dispatched |
| +to **Fable** (22 findings, then 14 against the revision — the second pass |
| +returned "not ready", and rulings 13, 14, 19 and 20 changed mechanism as a |
| +result). Every ruling is dated and attributed, with the rejected alternative |
| +beside it. |
| +**Reviewing model, per §11:** dispatched to Fable, 2026-09-01, twice. |
| +*(Recorded as what was dispatched, not what an agent self-reported — models |
| +are unreliable about their own identity.)* Rulings 12–26 postdate Paul's |
| +approval and carry only the review passes, not his. |
| **Scope:** the Movement recruitment app: a public signup surface, a confirmed |
| email list, and an admin view over it. It does not define a mailing-list |
| sender; §13 names the trigger for that. |
| @@ -40,9 +42,9 @@ enumerate who did. |
| challenges. This is not an encrypted-at-rest app and the README will not |
| imply it is. |
| - **Timing is not covered.** Responses are identical in content; they are not |
| - constant-time. The honeypot path does no work and is measurably faster, and |
| - a mail-send failure is slower. Ruling 19 states the invariant that is |
| - actually tested, and this is the limit of the claim. |
| + constant-time. Rejecting paths do no writes and are measurably faster than |
| + the accepting one. Ruling 19 states the invariant that is actually tested, |
| + and this is the limit of the claim. |
| |
| --- |
| |
| @@ -62,14 +64,18 @@ enumerate who did. |
| | 10 | **Unconfirmed rows are swept by a `carlos schedule` tick** | A `time.Ticker` (meaningless in a hibernating process); never sweeping | This session, 2026-09-01 | |
| | 11 | **All user-facing copy passes the copy-review gate before it enters a template** | Drafting final copy inside this spec, where it would ship unreviewed | This session, 2026-09-01 | |
| | 12 | **The instance key and origin arrive as `MOVEMENT_INSTANCE_KEY` and `MOVEMENT_ORIGIN`; the app refuses to boot without either** | Ruling 6's mint-into-the-database (its stated justification was false — `carlos secrets`/`env` do work through a logged-in console — and it would put the token-forging key into the Litestream replica); baking a key in via ldflags | This session, 2026-09-01, on corrected facts | |
| -| 13 | **The challenge nonce is single-use, consumed by `DELETE … RETURNING` against a `challenges` table** | An unconsumed nonce — one seal replayed for two hours, every replay mailing the same victim again | This session, 2026-09-01 | |
| -| 14 | **`/signup` never modifies a confirmed row** | Blanket upsert, which lets a stranger un-confirm any address or re-file a named person under a different stance | This session, 2026-09-01 | |
| +| 13 | **A nonce is recorded as *spent* on acceptance — `INSERT … ON CONFLICT DO NOTHING` into `spent_nonces`, where zero rows affected means replay. Nothing is written when a challenge is minted** | An unconsumed nonce (one seal replayed for two hours, every replay mailing the same victim again); **and** the first revision's mint-time `challenges` table, which made every stance-page GET a serialised write against the single SQLite writer — a crawler at 50 req/s is 4.3M rows a day and a denial of service on the one thing the app cannot do without | This session, 2026-09-01, revised same day after the second review | |
| +| 14 | **`/signup` never writes `Confirmed`, `ConfirmedAt`, `Stance`, `Background`, `TokenHash` or `TokenExpiry` on a confirmed row. A confirmed address gets no write, no send, and the same page** | Blanket upsert (lets a stranger un-confirm any address or re-file a named person under a different stance); **and** "leave the row untouched" stated without naming columns, which left the confirmed path undefined and, on the likeliest reading, unbounded mail to precisely the addresses already on the list | This session, 2026-09-01, revised same day after the second review | |
| | 15 | **Confirmation is a POST from a page the GET renders** | Confirm-on-GET, which mail-scanning gateways trigger on arrival — confirming people who never clicked, and burning the link before the human reaches it | This session, 2026-09-01 | |
| | 16 | **The app refuses to boot on the `mail.Logged` fallback unless the origin is loopback** | `mail.FromEnv`'s silent default, which writes magic links, confirmation tokens and addresses into `carlos logs` — the exact thing §7 forbids | This session, 2026-09-01 | |
| | 17 | **The admin allowlist is re-checked on every admin request, and admin sessions last 8 hours** | `Authorize` at sign-in only, with the 30-day default TTL — a removed admin keeps access for a month | This session, 2026-09-01 | |
| | 18 | **The scheduled-tick route is mounted outside the CSRF-protected router and guarded by `carlos.Tick` alone** | Mounting it inside, where `csrf.Protect` refuses a POST carrying no browser headers — a permanent 403 the platform reads as "do not retry" | This session, 2026-09-01 | |
| -| 19 | **Every non-honeypot `/signup` outcome is one write and at most one synchronous send, rendering the same page** | Leaving "identical response" as an assertion with no stated mechanism | This session, 2026-09-01 | |
| -| 20 | **Outbound mail is budgeted per address and globally, both persisted** | Relying on the in-memory per-address limiter, which resets on every hibernation, restart and deploy | This session, 2026-09-01 | |
| +| 19 | **Every `/signup` outcome renders the same bytes. The accepting path is one write transaction and at most one send; every rejecting path writes nothing** | The first revision's "every non-honeypot outcome is one write", which was false — it put the honeypot check *after* a write, and contradicted three other passages saying the honeypot writes nothing | This session, 2026-09-01, revised same day after the second review | |
| +| 20 | **Outbound mail is budgeted: 3 per address per 24h, 200 per hour globally, both persisted, and auth's sign-in mail draws on the same global budget** | The in-memory limiter alone (resets on every hibernation, restart and deploy); **and** the first revision's unnumbered budgets, whose counters sat on columns that the pending overwrite and the sweep both reset — an attacker just waits | This session, 2026-09-01, revised same day after the second review | |
| +| 23 | **`POST /signin` is app-owned: it checks `MOVEMENT_ADMINS` before delegating to `auth.Begin`, and answers the same redirect either way** | Mounting `auth.Begin` directly, which sends a magic link *before* `Authorize` ever runs — making the app a mailer for any address on the internet, from our verified identity. `sheets` hit this in production | This session, 2026-09-01 | |
| +| 24 | **The send happens after the write transaction commits, in a goroutine bounded by a deadline** | A send inside the transaction. `mail.Send` discards its context and `smtp.SendMail` dials with no deadline, so a hung SES connection would hold the single writer and stall every other signup and the tick | This session, 2026-09-01 | |
| +| 25 | **The `mail.Logged` fallback is detected by checking `CARLOS_SMTP_HOST`/`CARLOS_SMTP_FROM` before calling `FromEnv`, and `Mailer` is always passed explicitly** | Detecting the fallback by type — `loggedSender` is unexported, so it cannot be. `auth.New` also substitutes `Logged` for a nil `Mailer`, which is the same leak by another door | This session, 2026-09-01 | |
| +| 26 | **Every user-facing string is drawn from an `internal/copy` table keyed by ID; templates contain no literal copy** | Placeholder text written into templates and cleaned up later — which AGENTS.md forbids, and which would put unreviewed copy on a public canary during plan 1's branch review | This session, 2026-09-01 | |
| | 21 | **`/admin` lists confirmed rows; pending appears only as a count** | Listing pending rows — an address nobody has proven owns it, joined to a stance, which is exactly the pair §7 protects | This session, 2026-09-01 | |
| | 22 | **Pinned: Go 1.25, rastrillo v0.23.0; the app is scaffolded with `rastrillo new movement`** | Hand-writing the app shape, which redoes the scaffold's work and skips its pin test, CI files and browser harness | This session, 2026-09-01 | |
| |
| @@ -137,44 +143,72 @@ would have the canary mailing links into production. |
| message at the wiring call. Refusing at boot, naming the variable an operator |
| must set, is the difference between a five-minute fix and an afternoon. |
| |
| -**The mail fallback is refused too (ruling 16).** `mail.FromEnv` returns |
| -`mail.Logged` when SMTP is unconfigured, and `Logged` writes recipient, |
| -subject and body — the magic link, the confirmation token, the address — |
| -into platform-held storage §7 says cannot be grepped clean. The app detects |
| -that fallback itself and refuses, unless the origin is loopback, where it |
| -warns loudly instead. |
| +**The mail fallback is refused too (rulings 16 and 25).** `mail.FromEnv` |
| +returns `mail.Logged` when SMTP is unconfigured, and `Logged` writes recipient, |
| +subject and body — the magic link, the confirmation token, the address — into |
| +platform-held storage §7 says cannot be grepped clean. |
| + |
| +The check is on the **environment, before calling `FromEnv`**: if |
| +`CARLOS_SMTP_HOST` or `CARLOS_SMTP_FROM` is empty, refuse to boot unless the |
| +origin is loopback, where it warns loudly instead. Detecting the fallback by |
| +inspecting the returned value is not possible — `loggedSender` is unexported — |
| +so an earlier draft's "the app detects that fallback itself" was a sentence |
| +with no mechanism behind it. `auth.New` also substitutes `Logged` for a nil |
| +`Mailer`, which is the same leak through another door, so `Mailer` is always |
| +passed explicitly. |
| |
| --- |
| |
| ## 4. The signup path |
| |
| -1. `GET /<stance>` mints a challenge: a random nonce **persisted to |
| - `challenges`**, an issued-at, a difficulty, and an HMAC over the three |
| - (§6). All four travel as hidden fields. |
| +1. `GET /<stance>` mints a challenge: a random nonce, an issued-at, a |
| + difficulty, and an HMAC over the three (§6). All four travel as hidden |
| + fields. **Nothing is written.** The seal already proves the server issued |
| + the nonce, so a row at mint time buys nothing and costs everything (ruling |
| + 13). |
| 2. The visitor types an address and a note. On submit the module solves the |
| proof of work (§5) in a Worker and posts the counter. |
| -3. The server checks, in order, answering identically to every failure: seal |
| - valid → age within bounds → **nonce consumed by `DELETE … RETURNING`** → |
| - PoW satisfied → honeypot empty → field bounds (§8). |
| -4. The row is written per ruling 14: a confirmed row is **left untouched**; a |
| - pending row is overwritten wholesale, new token replacing old; an unknown |
| - address is inserted pending. |
| -5. A token is minted, its SHA-256 stored, the plaintext mailed — subject to |
| - the budgets in §9. |
| -6. The response is the same "check your inbox" page in every case. |
| - |
| -**Ruling 13 is what makes step 3 worth anything.** Without a consumed nonce, |
| -one solved challenge is replayable for two hours, and each replay mails the |
| -same address again — a mail-bombing primitive aimed at a stranger's inbox, |
| -sent from your own verified identity, with SES reputation as the collateral. |
| -The nonce table is swept by the same tick as everything else; an in-memory set |
| -would not survive hibernation, which is the normal state of this process. |
| +3. The server checks, in this order, answering identically to every failure: |
| + **honeypot empty** → seal valid → age within bounds → field bounds (§8) → |
| + PoW satisfied. The honeypot is first because it is free, needs no seal, and |
| + must not sit behind anything that writes. |
| +4. One transaction on the writer: `INSERT` the nonce into `spent_nonces` |
| + `ON CONFLICT DO NOTHING` — **zero rows affected means replay, and the |
| + transaction rolls back** — then the signup write, then the budget bump. |
| +5. The signup write, per ruling 14: |
| + - **unknown** → insert pending, mint a token, store its SHA-256; |
| + - **pending** → overwrite `Stance`, `Background`, `TokenHash`, |
| + `TokenExpiry` through an explicit `.Select` allowlist that **excludes the |
| + budget columns and `CreatedAt`**, so an attacker cannot reset a budget by |
| + re-submitting; |
| + - **confirmed** → no write and no send at all. |
| +6. After the transaction commits (ruling 24), and subject to the budgets in |
| + §9, the confirmation mail is sent. |
| +7. The response is the same "check your inbox" page in every case — including |
| + the confirmed case, the budget-suppressed case, and every rejection. |
| + |
| +**Ruling 13 is what makes replay cost something, and its shape matters.** The |
| +first revision persisted the nonce when the challenge was minted. That made |
| +every stance-page GET a serialised write against a single-writer SQLite file — |
| +and a crawler, a link scanner, an uptime monitor or a prefetcher would each |
| +produce millions of rows a day and contend for the one connection every |
| +signup needs. Recording nonces as *spent*, on acceptance, bounds the table by |
| +completed proof-of-work solves: rows now cost the attacker rather than us. |
| + |
| +Without it, one solved challenge is replayable for two hours and each replay |
| +mails the same address again — a mail-bombing primitive aimed at a stranger's |
| +inbox, sent from your verified identity, with SES reputation as the collateral. |
| +An in-memory set would not survive hibernation, which is this process's normal |
| +state. |
| |
| **Ruling 14 is what protects the signal.** A blanket upsert lets anyone who |
| knows an address knock it off the list, or — worse for an app that exists to |
| sort people by stance — re-file a named person under a stance they never |
| -chose. A confirmed row is immutable from the public surface. Changing one's |
| -stance after confirming is not built (§13). |
| +chose. The rule now names the columns rather than saying "untouched", because |
| +"untouched" left the confirmed path undefined: it neither said a mail was sent |
| +nor that one wasn't, and the reading that sends one is unbounded mail to every |
| +address already on the list. Confirmed means no write, no send, same page. |
| +Changing one's stance after confirming is not built (§13). |
| |
| `GET /confirm` renders a page with a single Confirm button; `POST /confirm` |
| hashes the token, marks the row, and clears the hash. Ruling 15 exists because |
| @@ -236,11 +270,21 @@ that does nothing. |
| |
| ## 6. Keys and seals |
| |
| -`MOVEMENT_INSTANCE_KEY` is hex-encoded 32 bytes (`auth.Config.InstanceKey` is |
| -a `string`). It seals auth's pending blob and the challenge. It does **not** |
| -derive confirmation tokens — those are 32 random bytes, stored only as a |
| -SHA-256, following the `auth_links` pattern. The earlier draft said both, and |
| -key-derived tokens would die on any key rotation. |
| +`MOVEMENT_INSTANCE_KEY` is 32 random bytes hex-encoded — `openssl rand -hex 32`. |
| +`auth.Config.InstanceKey` is a `string` and accepts any non-empty value, which |
| +it hashes with its own label; hex is this app's convention, not a framework |
| +requirement. The challenge HMAC key is **derived from it with a distinct |
| +label** rather than used raw, mirroring auth's own domain separation, so that |
| +two subsystems sharing one secret cannot be made to verify each other's |
| +tokens. |
| + |
| +It does **not** derive confirmation tokens — those are 32 random bytes, stored |
| +only as a SHA-256, following the `auth_links` pattern. An earlier draft said |
| +both, and key-derived tokens die on any key rotation. |
| + |
| +**The PoW preimage lowercases ASCII only.** JavaScript's `toLowerCase` and |
| +Go's `strings.ToLower` disagree on some Unicode, and a disagreement here is a |
| +silent `pow_short` that no visitor can diagnose and no log explains. |
| |
| **The seal** is `HMAC-SHA256(key, nonce ‖ issuedAt ‖ difficulty)`, a hidden |
| field. Rejected on mismatch, on an age under **3 seconds**, or over **2 |
| @@ -272,9 +316,14 @@ type Signup struct { |
| UpdatedAt time.Time |
| } |
| |
| -type Challenge struct { // ruling 13; consumed, never read twice |
| +type SpentNonce struct { // ruling 13; written on acceptance only |
| Nonce string `gorm:"primaryKey"` |
| - ExpiresAt time.Time |
| + ExpiresAt time.Time // issuedAt + 2h, from the sealed timestamp |
| +} |
| + |
| +type MailCounter struct { // the global budget, §9 |
| + HourBucket string `gorm:"primaryKey"` // UTC, "2006-01-02T15" |
| + Count int |
| } |
| ``` |
| |
| @@ -320,13 +369,23 @@ Two budgets, both **persisted**, because the in-memory limiter resets on every |
| hibernation, restart and deploy — and this process hibernates as its normal |
| state. |
| |
| -- **Per address**: `LastMailedAt` and `MailCount` on the row. Past the budget, |
| - the send is suppressed and the same page renders. A replayed submission |
| - therefore sends no second mail, which is ruling 13's belt-and-braces. |
| -- **Global**: an hourly outbound ceiling. Past it, the same page renders and |
| - the app logs `mail_budget`. This exists because the real damage from a bulk |
| - attack is not junk rows, it is thousands of mails to strangers from a |
| - verified identity, followed by SES suspension. |
| +- **Per address: 3 mails per 24 hours**, counted in `LastMailedAt` and |
| + `MailCount` on the row. Past it, the send is suppressed and the same page |
| + renders. |
| +- **Globally: 200 mails per hour**, in `MailCounter`. Past it, the same page |
| + renders and the app logs `mail_budget`. This exists because the real damage |
| + from a bulk attack is not junk rows, it is thousands of mails to strangers |
| + from a verified identity, followed by SES suspension. |
| + |
| +**Two ways a budget silently resets, both closed.** The pending overwrite |
| +excludes the budget columns from its `.Select` allowlist (§4 step 5), and the |
| +sweep age is longer than the per-address window (§10) — otherwise an attacker |
| +re-submits, or simply waits for the sweep, and the counter starts again. |
| + |
| +**Admin sign-in mail draws on the same global budget** (ruling 23). It is sent |
| +by `rastrillo/auth` rather than by our own handler, so the `mail.Sender` handed |
| +to `auth.New` is wrapped to pass through the counter. A budget that counts only |
| +the mail we remember sending is not a budget. |
| |
| The in-memory per-address limiter from `password/limit.go`'s shape stays as a |
| cheap first filter, but §5 and this section both state that it is not the |
| @@ -342,21 +401,49 @@ server logs a reason code — `seal_invalid`, `nonce_spent`, `too_fast`, |
| **with no address attached**. |
| |
| **A mail-send failure renders the same page.** The pending row survives and |
| -re-submitting is the recovery path. SMTP runs with a context timeout inside the |
| -request. This is not hypothetical: §11 notes that freshly minted SES |
| -credentials fail for the first minute or two after `carlos email enable`, and |
| -those sends land exactly here. |
| +re-submitting is the recovery path. This is not hypothetical: §11 notes that |
| +freshly minted SES credentials fail for the first minute or two after |
| +`carlos email enable`, and those sends land exactly here. |
| + |
| +**The send is bounded by us, because the framework does not bound it** |
| +(ruling 24). `mail.Sender.Send` takes a context and discards it, and |
| +`smtp.SendMail` underneath dials with no deadline; `rastrillo.Serve` sets no |
| +write timeout by default. So a hung SES connection would hold the handler |
| +open indefinitely — and, if the send sat inside the write transaction, would |
| +hold the single writer connection with it and stall every other signup and |
| +the scheduled tick. The send therefore happens **after commit**, in a |
| +goroutine with its own deadline. A sender that never returns must not be able |
| +to block a second submission, and that is a test. |
| + |
| +**One malformed-address exception.** A syntactically invalid address is |
| +rejected visibly, with an error naming the field. That leaks nothing: it is a |
| +property of the string the visitor just typed, not of any address's state on |
| +the list. Everything else — an expired tab, a sub-3s submit, a spent nonce — |
| +renders the same page, so an honest visitor with a typo is the one case that |
| +gets told what went wrong rather than being quietly dropped. |
| |
| The honeypot answers **success** (ruling 9) and writes nothing. A crawler that |
| gets an error learns which field to leave alone; one that gets a confirmation |
| page learns nothing. |
| |
| **All app logging goes through one internal package** that accepts typed reason |
| -codes and no strings drawn from request data. The guard test forbids any other |
| -`slog` import in the app. "No call site takes both an address and a stance" is |
| -not mechanically checkable over arbitrary Go — wrappers, structs, a `%v` of a |
| -`Signup` — so the boundary is enforced by making the sensitive pair |
| -unreachable from a log call rather than by pattern-matching for it. |
| +codes and **no `error` values and no strings drawn from request data**. The |
| +guard test forbids any other `slog` import in the app. "No call site takes both |
| +an address and a stance" is not mechanically checkable over arbitrary Go — |
| +wrappers, structs, a `%v` of a `Signup` — so the boundary is enforced by making |
| +the sensitive pair unreachable from a log call rather than by pattern-matching |
| +for it. |
| + |
| +Excluding `error` is not fussiness: `rastrillo/mail` formats its failures as |
| +`send to <address>`, so logging a send error verbatim would print the address |
| +beside a request path that carries the stance — the sensitive pair, assembled |
| +by accident. SMTP failures are classified to a reason code and the error |
| +dropped. A test drives a failing sender with a known address and asserts it |
| +never appears in captured output. |
| + |
| +One accepted exception: `rastrillo/auth` logs through its own logger, and the |
| +addresses it sees are admins signing in. Those are never paired with a stance, |
| +so they stay. |
| |
| Templates render into a buffer before anything is written, so a template error |
| becomes a clean 500 rather than a half-painted page. |
| @@ -370,14 +457,33 @@ the same interface `carlos email enable` feeds through `CARLOS_SMTP_*` and |
| `mail.FromEnv("CARLOS", logger)`. No adapter, no third-party provider, no AWS |
| key in the app. |
| |
| +**`POST /signin` is ours, not the framework's (ruling 23).** `auth.Begin` |
| +classifies the address and sends the mail *before* `admit` ever consults |
| +`Authorize` — so mounting it directly makes this app a mailer that anyone can |
| +point at any address, from our verified sending identity, at roughly twenty |
| +messages per fifteen minutes. The IP half of the framework's own budget |
| +collapses to the edge's address behind the platform, so it is not the brake it |
| +looks like. `sheets` hit this in production and fixed it the same way: an |
| +app-owned handler that checks `MOVEMENT_ADMINS` first and answers the identical |
| +`?sent=1` redirect whether or not the address is on it. |
| + |
| **The allowlist is re-checked on every admin request (ruling 17).** |
| `auth.Config.Authorize` is consulted only when a magic link is redeemed; |
| `RequireSession` thereafter checks only that a session exists, and the default |
| TTL is 30 days. Without a per-request middleware, removing someone from |
| -`MOVEMENT_ADMINS` does nothing for a month. Admin sessions are capped at |
| -**8 hours** — a surface listing named people's political positions does not |
| +`MOVEMENT_ADMINS` does nothing for a month. Admin sessions are capped at 8 |
| +hours via `auth.Config.SessionTTL` — not `RequireFreshSession`, which is a |
| +step-up mechanism for sensitive routes layered on a longer session, not a way |
| +to shorten one. A surface listing named people's political positions does not |
| want a month-long cookie. |
| |
| +Removal takes effect when the middleware next runs against a reloaded |
| +allowlist. `MOVEMENT_ADMINS` is read at boot, and platform config converges in |
| +seconds but does not restart the process, so **the middleware re-reads the |
| +environment** rather than closing over a boot-time slice. Otherwise "loses |
| +access on the next request" is only true after something happens to cycle the |
| +instance. |
| + |
| `/admin` lists **confirmed rows only**, newest first, with a count of pending |
| (ruling 21). A pending row is an address nobody has proven owns it joined to a |
| stance — the pair §7 exists to protect — and showing it to satisfy curiosity |
| @@ -392,10 +498,41 @@ reads as a different domain. DKIM signing is never turned off. |
| |
| --- |
| |
| +## 11a. The sweep |
| + |
| +One tick, `POST /tick/sweep`, mounted on the outer `http.ServeMux` beside |
| +`mux.Handle("/", r)` so it sits outside `csrf.Protect` (ruling 18), guarded by |
| +`carlos.Tick` alone. Declared with |
| +`carlos schedule set -app movement -name sweep -every 1h -path /tick/sweep`. |
| + |
| +It runs **synchronously inside the request** — a 202 and a goroutine risks the |
| +instance hibernating mid-job — and does three things: |
| + |
| +1. Deletes unconfirmed rows older than **30 days**. Longer than the 24-hour |
| + per-address mail budget on purpose (§9): a sweep shorter than the budget |
| + window is a way to reset the budget by waiting. |
| +2. Deletes `spent_nonces` past `ExpiresAt`. |
| +3. Calls `auth.Sweep(now)` for expired magic links and sessions, which nothing |
| + else in the app does. |
| + |
| +All three are idempotent, so at-least-once delivery needs no dedupe and |
| +`carlos.TickOccurrence` is not used. Confirmed rows are never touched, and |
| +neither is a pending row with a live token. |
| + |
| +A tick that 403s is not silent — `carlos schedule ls` records "404 (app |
| +refused)" — but nothing alerts on it, and the platform reads 4xx as "do not |
| +retry". Gate 11 stands in front of that. |
| + |
| +--- |
| + |
| ## 12. Testing gates |
| |
| `make test` and `.amadan/ci.d/*` are one definition, never two lists that |
| -drift. Cheapest first: vet, fmt, build, `rastrillo migration check`, test, race. |
| +drift — the scaffold's own targets, plus `version-check`, plus a |
| +**`test-browser`** step. Gates 3 and 7 need a real browser (`-tags browser`, |
| +chromedp), and a gate that no CI definition runs is not a gate. If the runner |
| +cannot get Chromium, they stop being called gates and become a documented |
| +manual step before each deploy. |
| |
| 1. **No enumeration.** `POST /signup` against unknown, pending and confirmed |
| addresses returns identical status, headers and body — and so do the |
| @@ -409,9 +546,15 @@ drift. Cheapest first: vet, fmt, build, `rastrillo migration check`, test, race. |
| scheme. |
| 4. **The nonce is single-use.** A byte-identical replay of a valid, accepted |
| POST is rejected and **sends no second mail**. This is ruling 13's gate and |
| - the most important one in the list. |
| + the most important one in the list. Plus: a stance-page GET writes no row. |
| 5. **A confirmed row is immutable from `/signup`.** Re-submission with a |
| - different stance changes nothing and does not un-confirm. |
| + different stance changes nothing, does not un-confirm, and **sends no mail**. |
| +5a. **Budgets survive.** Re-submitting a pending row does not reset its mail |
| + count, and neither does anything the sweep does. |
| +5b. **A non-allowlisted address posted to `/signin` sends no mail** and gets |
| + the same redirect as one that does. |
| +5c. **A hung sender does not block a second submission**, and the send happens |
| + after the transaction commits. |
| 6. **Seal bounds.** Forged seal, under 3s, over 2h — rejected identically. |
| 7. **Honeypot** returns the success page and writes no row. Plus a browser-rig |
| assertion that Chrome's autofill leaves the field empty. |
| @@ -426,9 +569,13 @@ drift. Cheapest first: vet, fmt, build, `rastrillo migration check`, test, race. |
| bearer must not 403 — the CSRF trap in ruling 18, which fails silently and |
| permanently. |
| 12. **The sweep spares the confirmed** and anything with a live token. |
| -13. **Boot refusals.** Missing origin, missing instance key, and the |
| - `mail.Logged` fallback on a non-loopback origin each refuse to start. |
| -14. **Logging containment.** No `slog` import outside the logging package. |
| +13. **Boot refusals.** Missing `MOVEMENT_ORIGIN`, missing |
| + `MOVEMENT_INSTANCE_KEY`, and an empty `CARLOS_SMTP_HOST`/`CARLOS_SMTP_FROM` |
| + on a non-loopback origin each refuse to start. |
| +14. **Logging containment.** No `slog` import outside the logging package, and |
| + a failing sender's address never reaches captured output. |
| +14a. **Templates carry no literal user-facing text** — every string resolves |
| + through `internal/copy`, and no placeholder survives into a release build. |
| 15. **Difficulty is calibrated**, p95 and p99 recorded on real hardware. |
| |
| --- |
| @@ -451,29 +598,41 @@ A thing deferred without a trigger is a thing forgotten. |
| |
| ## 14. Sequencing |
| |
| -Two plans, split at the copy gate. Everything in the first is |
| -copy-independent, so waiting on Paul costs nothing. |
| +Two plans, split at the copy gate. |
| + |
| +**Plan 1 is copy-independent only because of ruling 26.** Every user-facing |
| +string resolves through an `internal/copy` table keyed by ID, so plan 1 fills |
| +that table with marked placeholders and never writes copy into a template. A |
| +release build refuses while any placeholder remains, and routes are mounted by |
| +internal stance key until copy review settles the slugs. Without that table, |
| +"placeholder copy throughout" would mean unreviewed words in templates — which |
| +AGENTS.md forbids, and which the shipping cadence would then put on a public |
| +canary during plan 1's own branch review. |
| |
| -**Plan 1 — the engine.** Placeholder copy throughout; every gate in §12 except |
| -15 is green at its end. |
| +**Plan 1 — the engine.** Every gate in §12 except 15 green at its end. |
| |
| 1. `rastrillo new movement`, pins, `BuildVersion` stamping, the platform |
| contract. `/healthz` and `/api/version` come from `rastrillo.Serve` and are |
| never hand-rolled. |
| -2. Config and the three boot refusals (§3). |
| -3. The seal, the `challenges` table, and single-use consumption. |
| -4. The PoW verifier in Go, the Worker solver, and the browser-crossing test. |
| -5. Models, migrations, and the signup handler with every check and bound. |
| -6. Confirmation: GET renders, POST burns. |
| -7. `carlos email enable`, the mailer, the budgets, the two mails. |
| -8. Admin: allowlist, per-request re-check, list, escaped CSV export. |
| -9. The sweep tick, mounted outside the CSRF router. |
| +2. Config, the three boot refusals (§3), and the logging package with its |
| + containment test. |
| +3. `internal/copy` and its placeholder discipline (ruling 26). |
| +4. The seal, label-derived keys, and `spent_nonces` consumed on acceptance. |
| +5. The PoW verifier in Go, the Worker solver, and the browser-crossing test. |
| +6. Models, migrations, and the signup handler: every check, bound, and the one |
| + transaction. |
| +7. Confirmation: GET renders, POST burns. |
| +8. `carlos email enable`, the wrapped mailer, both budgets, send-after-commit. |
| +9. Admin: app-owned `/signin`, allowlist, per-request re-check, list, escaped |
| + CSV export. |
| +10. The sweep tick, mounted outside the CSRF router. |
| |
| **Plan 2 — the surface.** |
| |
| -10. Copy review, then templates and static assets. |
| -11. Deploy to a canary, calibrate difficulty at p95/p99, record it. |
| -12. Promote, and verify `X-Carlos-Version` on the canonical host. |
| +11. Copy review, then the real strings into `internal/copy`, the stance slugs, |
| + and the templates and static assets around them. |
| +12. Deploy to a canary, calibrate difficulty at p95/p99, record it. |
| +13. Promote, and verify `X-Carlos-Version` on the canonical host. |
| |
| --- |
| |