| index e60dbaa..d00bd35 100644 |
| --- a/docs/superpowers/specs/2026-09-01-movement-design.md |
| +++ b/docs/superpowers/specs/2026-09-01-movement-design.md |
| @@ -1,15 +1,20 @@ |
| # 🤖 Movement — design |
| |
| -**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 |
| +**Status:** design, revised three times on 2026-09-01, and ready to implement |
| +against. Approved in brainstorming by Paul on 2026-09-01; revised the same day |
| +against three review passes dispatched to **Fable** — 22 findings, then 14 |
| +against the first revision ("not ready"; rulings 13, 14, 19 and 20 changed |
| +mechanism), then one blocker and two corrections against the second, all |
| +applied. Every ruling is dated and attributed, with the rejected alternative |
| beside it. |
| -**Reviewing model, per §11:** dispatched to Fable, 2026-09-01, twice. |
| +**Reviewing model, per §11:** dispatched to Fable, 2026-09-01, three times. |
| *(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. |
| +are unreliable about their own identity.)* |
| +**Rulings 12–26 postdate Paul's approval** and carry only the review passes, |
| +not his. Several reverse or materially change what he approved — most of all |
| +ruling 12 (the instance key moved out of the database and into the |
| +environment) and ruling 15 (confirmation moved from GET to POST). They need his |
| +eyes before plan 2 puts anything in front of a visitor. |
| **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. |
| @@ -75,7 +80,7 @@ enumerate who did. |
| | 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 | |
| +| 26 | **Every user-facing string is drawn from an `internal/copy` table keyed by ID; templates contain no literal copy. Unreviewed entries are marked placeholders, and the app refuses to boot with any placeholder remaining when `MOVEMENT_ORIGIN` is the canonical host — canaries and loopback boot, showing a banner** | Placeholder text written into templates and cleaned up later (AGENTS.md forbids it); **and** "a release build refuses while placeholders remain", which was unbuildable — there is one build, `carlos ship` publishes it, and the canary serves it, so refusing at build time would make plan 1 impossible to review on a canary as the cadence requires | This session, 2026-09-01, extended after the third review | |
| | 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 | |
| |
| @@ -181,7 +186,8 @@ passed explicitly. |
| `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. |
| + - **confirmed** → no write to the row and no send at all. The nonce is |
| + still spent, as it is for every accepted submission. |
| 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 |
| @@ -382,14 +388,45 @@ 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. |
| |
| +**The counter is bumped in one statement** — |
| +`INSERT … ON CONFLICT(hour_bucket) DO UPDATE SET count = count + 1` — inside |
| +the signup transaction. A read-then-write across two statements is a lost |
| +update waiting for concurrency, even behind a single writer. Fixed hour buckets |
| +let a burst straddle a boundary and send up to twice the ceiling in one rolling |
| +hour; at these numbers that is acceptable and it is written down rather than |
| +discovered. |
| + |
| **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 mail we remember sending is not a budget. Three things the wrapper must |
| +get right, because `auth.Begin` calls `Send` synchronously and with no deadline |
| +of its own: |
| + |
| +- commit the count **before** attempting the send; |
| +- apply its own deadline, since nothing upstream will; |
| +- **return `nil` when it suppresses for budget.** Returning an error makes |
| + `Begin` redirect to `?err=` instead of `?sent=1` and log the failure with the |
| + address attached — a different response, which is the thing ruling 23 exists |
| + to avoid. |
| + |
| +One residual, accepted: signin's own per-address limiter can answer `?err=rate`, |
| +and only an allowlisted address ever reaches it. That is a narrow oracle for |
| +admin membership, not list membership, so it sits outside the one rule — but |
| +"the same redirect either way" is true of our check, not of every path beneath |
| +it. |
| + |
| +**The budget counts attempts, not successes.** Three failed sends during SES's |
| +post-`enable` propagation window will lock an honest address out for 24 hours, |
| +with re-submission (§10) unavailable until it clears. Conservative on purpose, |
| +and stated so it is a choice rather than a surprise. |
| |
| 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 |
| -defence. |
| +cheap filter, but §5 and this section both state that it is not the defence. |
| +**It is checked after proof of work, not before.** Keyed by address and |
| +consulted first, it would let a stranger trip a victim's budget for free and |
| +lock them out of signing up; behind the solve, tripping someone else's costs |
| +real work. |
| |
| --- |
| |
| @@ -415,6 +452,12 @@ 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. |
| |
| +That deadline must be **well under the platform's idle window** — seconds |
| +against minutes — because a goroutine outliving the response is exactly the |
| +shape §11a rejects for the tick. It is safe here only because it is short. A |
| +`SIGTERM` landing mid-send loses that one mail after its budget bump; §10's |
| +re-submit path is the recovery, and it is not worth more machinery than that. |
| + |
| **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 |
| @@ -477,12 +520,17 @@ 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. |
| +**How removal actually takes effect.** `MOVEMENT_ADMINS` is read once at boot. |
| +A process's environment is fixed at exec — re-reading `os.Getenv` per request |
| +sees nothing new, and an earlier draft of this section claimed otherwise. What |
| +makes removal fast is the platform: `carlos env set` convergence re-renders and |
| +**bounces** socket-exec instances within a few seconds, and hibernation |
| +respawns the process on the next wake anyway. The per-request allowlist check |
| +is what then invalidates an existing session, rather than leaving it live until |
| +its 8 hours are up. |
| + |
| +Gate 10 must therefore model a restart. A test that calls `t.Setenv` mid-run |
| +would pass while proving nothing, which is worse than no test. |
| |
| `/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 |
| @@ -510,7 +558,11 @@ 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. |
| + window is a way to reset the budget by waiting. The cost is that a pending |
| + row outlives its own 24-hour token by 29 days, purely to hold a counter — |
| + junk rows and Litestream replica size are the price of ruling 20. If that |
| + ever matters, moving the per-address budget to its own table keyed by an |
| + address hash lets this drop back to about 48 hours. |
| 2. Deletes `spent_nonces` past `ExpiresAt`. |
| 3. Calls `auth.Sweep(now)` for expired magic links and sessions, which nothing |
| else in the app does. |
| @@ -519,9 +571,10 @@ 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. |
| +A tick that 403s records `app refused` in the schedule's last status, but |
| +nothing alerts on it and `next` still advances, so `carlos schedule ls` reads |
| +much like success at a glance. The platform treats 4xx as "do not retry". Gate |
| +11 stands in front of that. |
| |
| --- |
| |
| @@ -563,8 +616,10 @@ manual step before each deploy. |
| unknown one. |
| 9. **Admin fails closed.** With `MOVEMENT_ADMINS` empty, all seven admin |
| routes answer 404 — iterated, not spot-checked. |
| -10. **A removed admin loses access on the next request**, not on session |
| - expiry. |
| +10. **A removed admin loses access on the next request after the instance |
| + cycles**, not on session expiry. The test restarts the app with a changed |
| + allowlist and re-uses the old session cookie — `t.Setenv` mid-run would |
| + pass without proving anything, since the environment is fixed at exec. |
| 11. **The tick reaches the sweep.** A POST with no browser headers and a valid |
| bearer must not 403 — the CSRF trap in ruling 18, which fails silently and |
| permanently. |
| @@ -575,7 +630,8 @@ manual step before each deploy. |
| 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. |
| + through `internal/copy`. With a placeholder remaining, the app refuses to |
| + boot on the canonical origin and boots with a banner on a canary. |
| 15. **Difficulty is calibrated**, p95 and p99 recorded on real hardware. |
| |
| --- |
| @@ -602,12 +658,17 @@ 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. |
| +that table with marked placeholders and never writes copy into a template. |
| +Routes are mounted by internal stance key until copy review settles the slugs. |
| + |
| +**Where the placeholder check runs matters**, because there is only one build: |
| +`make release` produces it, `carlos ship` publishes it, and the canary serves |
| +that same binary. A build-time refusal would make plan 1 impossible to review |
| +on a canary, which the shipping cadence requires. So the check is at **boot, |
| +against the origin**: with placeholders remaining, the app refuses to start |
| +when `MOVEMENT_ORIGIN` is the canonical host, and starts with a visible banner |
| +on a canary or loopback origin. Unreviewed words can be reviewed; they cannot |
| +reach production. |
| |
| **Plan 1 — the engine.** Every gate in §12 except 15 green at its end. |
| |