carlos / movement Public

Review 2: the nonce table was a denial of service, inverted

Fable's second pass returned "not ready" with 14 findings, four needing a
mechanism change rather than better prose.

Writing a nonce row on every stance-page GET made a crawler into a
serialised-write attack on the single SQLite writer. Inverted: nonces are
recorded as spent on acceptance, so rows cost the attacker a proof-of-work
solve and a GET writes nothing.

POST /signin mounted auth.Begin directly, which sends before Authorize
runs — a mailer anyone could point at any address from our verified
identity. Sheets hit this in production; ruling 23 copies its fix.

Ruling 14 said "leave the row untouched" without naming columns, which
left the confirmed path undefined and, read the obvious way, unbounded
mail to the addresses already on the list. It now names them.

And mail.Send discards its context, so "SMTP runs with a context timeout"
was untrue: the send now happens after commit, on its own deadline,
outside the transaction that holds the writer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175aLTHqLrYkGe1skMV1rvq
Paul Campbell d9e1383376c1565ac3f25e15b693002b7df08ee3 parent 1587c35
2 files changed, +271 −96
  • AGENTS.md +24 −8
  • docs/superpowers/specs/2026-09-01-movement-design.md +247 −88
diff --git a/AGENTS.md b/AGENTS.md
index 0571e9f..84117c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -92,8 +92,11 @@ gate.** Headlines, the three stance messages, button labels, error text, both
emails. This app *is* its copy — the words are the mechanism that sorts people
into buckets, not decoration on top of one.
-Draft copy lives in the review tool, never committed straight into a template
-and cleaned up later.
+Templates carry **no literal user-facing text**. Every string resolves through
+`internal/copy`, keyed by ID, and unreviewed entries are marked as
+placeholders that a release build refuses to ship. That is what makes it
+possible to build the whole engine before the words exist, without ever
+putting unreviewed copy on a canary.
---
@@ -103,15 +106,20 @@ and cleaned up later.
- Migrations are additive-only. New code over an old database is always safe.
Never delete data to update it, and never edit a shipped migration. The
- 14-day sweep is a runtime deletion of rows nobody consented to keep, not a
+ 30-day sweep is a runtime deletion of rows nobody consented to keep, not a
migration — the two are different things and the rule above is about
migrations.
- **`/signup` never modifies a confirmed row.** A blanket upsert lets a
stranger un-confirm any address, or re-file a named person under a stance
they never chose.
-- The challenge nonce is single-use, consumed with `DELETE … RETURNING`. An
- unconsumed nonce turns one solved challenge into two hours of repeat mail to
- a victim's inbox, from our own verified sending identity.
+- The challenge nonce is single-use — recorded as **spent on acceptance**,
+ never written when the challenge is minted. An unconsumed nonce turns one
+ solved challenge into two hours of repeat mail to a victim's inbox from our
+ own verified identity; a nonce written on every page view turns a crawler
+ into a denial of service against the single SQLite writer. Both are real and
+ the second is the one that looks harmless.
+- The budget columns are excluded from the pending-row `.Select` allowlist. A
+ counter an attacker can reset by re-submitting is not a budget.
- Confirmation burns on **POST**, never on GET. Mail-scanning gateways fetch
every link in an arriving message; confirm-on-GET confirms people who never
clicked and leaves the human a dead link.
@@ -148,8 +156,16 @@ and cleaned up later.
`/signin` included. Fail closed, and indistinguishable from an app with no
admin at all.
- `Authorize` runs once, when a magic link is redeemed. Re-check the allowlist
- on **every** admin request, or a removed admin keeps access until their
- session expires.
+ on **every** admin request, reading the environment rather than a boot-time
+ slice, or a removed admin keeps access until something cycles the instance.
+- **Never mount `auth.Begin` directly at `POST /signin`.** It sends the mail
+ before `Authorize` ever runs, which makes this app a mailer anyone can point
+ at any address from our verified identity. Check the allowlist first and
+ answer the same redirect either way.
+- Mail is sent **after** the write transaction commits, bounded by our own
+ deadline. `mail.Send` discards its context and `smtp.SendMail` dials with no
+ timeout, so a hung connection inside the transaction holds the single writer
+ and stalls every other signup.
**CSRF**
diff --git a/docs/superpowers/specs/2026-09-01-movement-design.md b/docs/superpowers/specs/2026-09-01-movement-design.md
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.
---