carlos / movement Public

Review: the seal was a mail-bombing primitive, and other corrections

Fable's pass returned 22 findings. The severe ones were real: an
unconsumed nonce made one solved challenge worth two hours of repeat mail
to a victim's inbox; a blanket upsert let a stranger un-confirm anyone or
re-file them under a stance they never chose; confirm-on-GET let mail
scanners confirm people who never clicked; and mail.FromEnv's silent
fallback would have written magic links and tokens into carlos logs.

Ruling 6 is superseded by 12. Its justification was false — carlos secrets
and env do work through a logged-in console — and minting the key into the
replicated database put the token-forging key in the bucket. The house
convention (env-delivered instance key) was right all along.

Rulings 12 through 22 are material changes and need a second review pass
before implementation starts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175aLTHqLrYkGe1skMV1rvq
Paul Campbell 1587c35ac88a270c4309a7747ada5ec23aada2d3 parent 74fcf1f
2 files changed, +392 −243
  • AGENTS.md +54 −8
  • docs/superpowers/specs/2026-09-01-movement-design.md +338 −235
diff --git a/AGENTS.md b/AGENTS.md
index 02a36fe..0571e9f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -102,7 +102,19 @@ and cleaned up later.
**Data**
- 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.
+ 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
+ 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.
+- 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.
- `BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)`, in that
order — auth's backfill reads the sessions table.
- `Stance` stores an internal key, never the label a visitor read. Labels are
@@ -117,23 +129,52 @@ and cleaned up later.
platform-held storage that cannot be grepped clean afterwards.
- Rejected submissions log a reason code and no address.
+**Boot**
+
+- `MOVEMENT_ORIGIN` and `MOVEMENT_INSTANCE_KEY` are required; the app refuses
+ to start without either. A missing origin refuses loudly, a *wrong* one
+ boots happily and mails links to the wrong host — which is how a canary ends
+ up mailing people into production.
+- Refuse to boot on `mail.FromEnv`'s `mail.Logged` fallback unless the origin
+ is loopback. `Logged` writes recipient, subject and body — magic links,
+ confirmation tokens, addresses — straight into `carlos logs`.
+
**Identity**
- Under the `auth` plugin the session subject is a verified **address**, not a
numeric id. `sessions.UserID` silently returns `(0, false)` — never call it.
Read the viewer with `auth.From(r)` or `sessions.Current(r)`.
-- With `MOVEMENT_ADMINS` empty, every admin route answers **404**. Fail closed,
- and indistinguishable from an app with no admin at all.
+- With `MOVEMENT_ADMINS` empty, all seven admin routes answer **404** —
+ `/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.
+
+**CSRF**
+
+- `csrf.Protect(origin)` is mounted app-wide on the chi router, above the
+ route groups.
+- **The scheduled-tick route is mounted outside it**, guarded by `carlos.Tick`
+ alone. `csrf.SameOrigin` refuses any POST carrying no `Sec-Fetch-Site`,
+ `Origin` or `Referer`, and the platform's scheduler sends none of the three.
+ Inside the protected router the tick 403s forever, and the platform reads
+ 4xx as "do not retry". It fails silently and permanently, so a test stands
+ in front of it.
**Frontend**
- Hand-written ES modules, no build step, no bundler, no CDN. Served through
`rastrillo.Assets` for content-hash fingerprinting.
-- Re-copy `static/tokens.css` on every rastrillo upgrade — it is frozen at
- scaffold time.
-- Signing up requires JavaScript (spec ruling 2). The `<noscript>` path must
- say so plainly. A form that looks live and silently does nothing is the one
- outcome that is not acceptable.
+- Run `rastrillo doctor --fix` after a framework upgrade; the scaffold's pin
+ test is the gate that catches a stale `static/tokens.css`.
+- Signing up requires JavaScript (spec ruling 2). Render the submit button
+ `disabled` with the explanation inside `<noscript>`, and let the module
+ remove `disabled` once it loads. JavaScript cannot enable a control *inside*
+ `<noscript>`, and this ordering fails safe: a module that throws leaves the
+ honest disabled state rather than a live-looking form that does nothing.
+- The proof-of-work solver runs in a Worker. On the main thread it freezes a
+ phone for seconds.
**Deploys**
@@ -142,6 +183,11 @@ and cleaned up later.
collide with `rastrillo/gormlite`.
- Build `linux/arm64`. The wrong architecture is adopted silently and then
refused forever.
+- Stamp `-ldflags -X github.com/carlosframework/rastrillo.BuildVersion=<sha>`,
+ or `/api/version` reports `dev` and the deploy watch is blind.
+- Pinned: Go 1.25, rastrillo v0.23.0. The app is scaffolded with
+ `rastrillo new movement` — hand-writing the shape redoes the scaffold's work
+ and skips its pin test, CI files and browser harness.
- The process contract is `<bin> --socket <path> --db <path>`. There is no
`$PORT`. `/healthz` and `/api/version` must both answer.
- Blank `opts.DBPath` before `Serve` — the app opens its own handle, and a
diff --git a/docs/superpowers/specs/2026-09-01-movement-design.md b/docs/superpowers/specs/2026-09-01-movement-design.md
index 636ebba..807747a 100644
--- a/docs/superpowers/specs/2026-09-01-movement-design.md
+++ b/docs/superpowers/specs/2026-09-01-movement-design.md
@@ -1,11 +1,13 @@
# 🤖 Movement — design
-**Status:** design, 2026-09-01. Approved in brainstorming by Paul on
-2026-09-01. Awaiting the §11 review pass. Every ruling is dated and
+**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.)*
+about their own identity.)* Rulings 12–22 are material changes made after that
+pass and require a second review before implementation begins.
**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.
@@ -22,20 +24,25 @@ Both halves are load-bearing and both are tested from the first commit.
The first half is what makes the list worth having. Proof of work and a
honeypot raise the cost of submitting an address; neither says anything about
whether the address exists, receives mail, or belongs to the person who typed
-it. Only a click from inside that inbox does. So a row is inert until
-confirmed: it is never exported, never counted, never mailed again beyond its
-own confirmation.
+it. Only a deliberate action from inside that inbox does. So a row is inert
+until confirmed: never exported, never counted, never mailed beyond its own
+confirmation.
The second half is what stops the signup form doubling as a membership
-oracle. Every public response — status, body, headers, timing class — is
-identical whether an address is unknown, pending, or long confirmed. An app
-whose whole subject is who is willing to associate themselves with a position
-on AI must not let a stranger enumerate who did.
+oracle. Every public response — status, headers, body — is identical whether
+an address is unknown, pending, or long confirmed. An app whose subject is who
+will associate themselves with a position on AI must not let a stranger
+enumerate who did.
-**What the rule does not claim.** The server reads every address in plaintext
-and holds its own HMAC key (§5). This is not an encrypted-at-rest app and the
-README will not imply it is. What is promised is narrower and actually true:
-nothing on the public surface distinguishes one address's state from another's.
+**What the rule does not claim.**
+
+- The server reads every address in plaintext and holds the key that seals its
+ 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.
---
@@ -45,21 +52,32 @@ nothing on the public surface distinguishes one address's state from another's.
|---|---|---|---|
| 1 | **Three stance routes, each server-rendered with its own template, all posting to one `/signup`** | Single page with JS-revealed stance (copy is the product; it wants three reviewable files); session-backed wizard (mints sessions for anonymous visitors to buy nothing) | Paul, 2026-09-01 |
| 2 | **Proof of work is required; JavaScript is mandatory to sign up** | PoW when JS is present, falling back to honeypot + seal + rate limit (recommended by this session); no PoW at all | Paul, 2026-09-01 |
-| 3 | **The PoW challenge is bound to the submitted email address** | An unbound nonce-only challenge — solved once, replayed across thousands of addresses, worth nothing | Proposed by this session, 2026-09-01 |
-| 4 | **Double opt-in: a row is pending until a link in its own inbox is clicked** | Store immediately; store immediately and send a welcome mail | Paul, 2026-09-01 |
-| 5 | **Admin is a magic link to an allowlist of addresses** | Shared password (needs `carlos secrets`, direct-bucket-only today, and one secret shared between people); no admin surface at all | Paul, 2026-09-01 |
-| 6 | **The instance key is generated on first boot and stored in the database** | `carlos secrets`/`carlos env` (both direct-bucket-only, no console path); baking a key in at build time via ldflags (a key in the binary is a key in every build log) | Proposed by this session, 2026-09-01 |
+| 3 | **The PoW challenge is bound to the submitted email address** | An unbound nonce-only challenge — solved once, replayed across thousands of addresses | Proposed by this session, 2026-09-01 |
+| 4 | **Double opt-in: a row is pending until confirmed from its own inbox** | Store immediately; store immediately and send a welcome mail | Paul, 2026-09-01 |
+| 5 | **Admin is a magic link to an allowlist of addresses** | A shared password — one secret between people, rotated only by redeploy, and revoked for nobody | Paul, 2026-09-01 |
+| 6 | ~~The instance key is generated on first boot and stored in the database~~ — **superseded by ruling 12** | — | Proposed by this session, 2026-09-01; superseded same day |
| 7 | **The app is reachable at `movement.bdj.oncarlos.com` and nowhere else for v1** | Attaching a custom domain now; attaching one before launch | Paul, 2026-09-01 |
-| 8 | **The anti-replay token is HMAC-sealed, not encrypted** | Encrypting it. Nothing in it needs to be secret from the person holding it; it needs to be unforgeable, which is a signature's job | This session, 2026-09-01 |
-| 9 | **A filled honeypot answers an ordinary success and stores nothing** | Answering an error, which teaches a crawler exactly what to avoid next time | This session, 2026-09-01 |
-| 10 | **Unconfirmed rows are swept at 14 days by a `carlos schedule` tick** | A `time.Ticker` (meaningless in a process that hibernates); never sweeping (an unbounded pile of addresses nobody consented to keep) | This session, 2026-09-01 |
+| 8 | **The anti-replay token is HMAC-sealed, not encrypted** | Encrypting it. Nothing in it needs to be secret from its holder; it needs to be unforgeable, which is a signature's job | This session, 2026-09-01 |
+| 9 | **A filled honeypot answers an ordinary success and stores nothing** | Answering an error, which teaches a crawler which field to avoid | This session, 2026-09-01 |
+| 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 |
+| 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 |
+| 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 |
---
## 2. Shape
-Six public routes, four admin, two platform.
+Five public routes, seven admin, two platform.
```
GET / headline, the link out to carlosframework.com, three choices
@@ -67,24 +85,27 @@ GET /<stance-a> tailored message + signup form (slugs settled at co
GET /<stance-b> "
GET /<stance-c> "
POST /signup one handler for all three; stance arrives as a hidden field
-GET /confirm?token=… burns the token, marks the row confirmed
+GET /confirm?token=… renders a page with one Confirm button — burns nothing
+POST /confirm burns the token, marks the row confirmed
+GET /signin the form, and where auth redirects every outcome
POST /signin magic link begin (rastrillo/auth)
GET /auth/callback "
GET /auth/verify "
-GET /admin the list (allowlisted addresses only)
+POST /signout "
+GET /admin confirmed rows, newest first, plus a pending count
GET /admin/export.csv confirmed rows only
-POST /signout
-GET /healthz required by the platform contract
+POST /tick/sweep outside the CSRF router; carlos.Tick guards it
+GET /healthz from rastrillo.Serve — never hand-rolled
GET /api/version "
```
-Ruling 1 puts the stance copy in three separate templates rather than three
-branches of one. The copy is the product here — it is the thing that sorts
-people — and it will be edited far more often than the code around it. Three
-files also means a copy change touches one template and one test, not a
-conditional that renders all three.
+All seven admin routes answer **404** when `MOVEMENT_ADMINS` is empty —
+including `GET`/`POST /signin`. Leaving sign-in live under an empty allowlist
+would let anyone make the app send magic-link mail from its verified identity
+to any address, and `/auth/verify` would answer 403 rather than the promised
+404, which is itself a signal.
`cmd/movement/main.go` stays a thin dispatcher: `rastrillo.Resolve` →
`db.Open` → `movement.App` → blank `opts.DBPath` → `rastrillo.Serve`. The
@@ -94,222 +115,280 @@ for traffic.
---
-## 3. The signup path
-
-1. `GET /<stance>` renders the form and, with it, a fresh challenge: a random
- nonce, an issued-at timestamp, a difficulty, and an HMAC over the three
- (§5). All four travel as hidden fields.
-2. The visitor types an address and a note about themselves. On submit the
- client solves the proof of work (§4) and posts the counter alongside.
-3. The server checks, in this order and with the same answer for every
- failure: seal valid → age within bounds → PoW satisfied → honeypot empty.
-4. The row is upserted **pending**. A confirmation token is minted, its
- SHA-256 stored, the plaintext mailed.
-5. The response is the same "check your inbox" page in every case.
-
-Step 5 is where the second half of the one rule lives, and it costs something
-real: a returning visitor who has already confirmed is told to check their
-inbox, and gets a mail saying they are already on the list. That is worse
-copy than "you're already signed up" and it is the price of not answering
-"is this address on the list?" to anyone who asks.
-
-`GET /confirm` hashes the presented token, looks for a live row with that
-hash, marks it confirmed, and clears the hash so the link is single-use. An
-expired, unknown, or already-burned token all render the same page: a note
-that the link is no longer valid and an invitation to sign up again.
+## 3. Configuration, and refusing to boot
+
+Three variables, read once at boot, delivered by `carlos env`/`carlos secrets`
+(both of which work through a logged-in console — the earlier claim that they
+were direct-bucket-only was wrong):
+
+| Variable | Purpose | Missing |
+|---|---|---|
+| `MOVEMENT_ORIGIN` | absolute origin; base of every emailed link, and the argument to `csrf.Protect` and `auth.Config.Origin` | refuse to boot |
+| `MOVEMENT_INSTANCE_KEY` | hex-encoded 32 bytes; seals auth's pending blob and the challenge | refuse to boot |
+| `MOVEMENT_ADMINS` | comma-separated admin addresses | boot, all admin routes 404 |
+
+A missing origin refuses at boot; a *wrong* one boots happily and mails links
+to the wrong host, so it is worth getting right once. This matters directly
+for canary review, which AGENTS.md requires: a canary runs at
+`<canary>.movement.bdj.oncarlos.com`, and a hard-coded production origin
+would have the canary mailing links into production.
+
+`auth.New` refuses an empty `InstanceKey` on its own, but with a framework
+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.
+
+---
+
+## 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.
+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.
+
+**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).
+
+`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
+link-scanning gateways fetch every URL in an arriving mail: confirm-on-GET
+would let Outlook Safe Links confirm a row no human ever clicked, and then
+show the human "this link is no longer valid." An expired, unknown, or burned
+token renders one page, identical for all three.
---
-## 4. Proof of work
+## 5. Proof of work
-The client must find a counter such that:
+The client must find a counter such that
```
SHA-256(nonce ‖ ":" ‖ lowercased-email ‖ ":" ‖ counter)
```
-has at least `difficulty` leading zero bits. The server re-runs that one hash
-and compares — O(1) to check, ~2^difficulty to find.
+has at least `difficulty` leading zero bits. The server re-runs that one hash.
+
+**Binding the address into the preimage is ruling 3.** Unbound, a challenge is
+solved once and reused against every address in a list. Bound, the cost is one
+solve per address — the axis a bulk signup attack actually scales along.
+
+**What proof of work does not do.** 2^18 SHA-256 is under a millisecond on a
+commodity GPU; a determined attacker solves thousands of bound challenges a
+second. PoW prices out casual and scripted abuse, and nothing more. The
+defence against a serious bulk attack is the persisted mail budget in §9, not
+this. §9 says so plainly so that nobody later mistakes the speed bump for the
+wall.
-**Binding the address into the preimage is the whole point of ruling 3.** An
-unbound challenge is solved once and reused against every address in a list;
-the cost to an attacker is one solve total. Bound, the cost is one solve per
-address, which is exactly the axis a bulk signup attack scales along. It also
-explains the JS requirement without hand-waving: the work cannot begin until
-the visitor has typed the address it is bound to.
+**Difficulty starts at 18 bits and is a tunable constant**, carried in the
+seal. §12 requires it be re-set against real mid-range hardware before launch,
+recording **p95 and p99, not the mean** — solve time is geometric, and p99 is
+about 4.6× the mean, so calibrating on the average ships a form that hangs for
+one visitor in a hundred. A raised difficulty does not apply to seals already
+issued; that two-hour tail is accepted.
-**Difficulty starts at 18 bits and is a tunable constant.** 18 is an estimate
-(~262k expected hashes), not a measurement; §12 requires it be re-set against
-a real mid-range phone before launch. Too high is a broken form for anyone on
-old hardware, and that failure is invisible to us.
+**The solver runs in a Worker**, not on the main thread — 262k hashes in plain
+JS freezes a phone for seconds. Same-origin workers are allowed by the default
+`default-src 'self'` policy. It reports progress, and the submit button stays
+disabled until it finishes.
**The hash is a small synchronous SHA-256 inside the module.** WebCrypto's
-`subtle.digest` is async per call, and hundreds of thousands of promise
-round-trips is not a viable inner loop. About fifty lines of plain JS, no
-build step, no dependency — the same hand-written-`.mjs` convention rastrillo
-uses for `webauthn.mjs`, `keyring.mjs` and the rest.
+`subtle.digest` is async per call; a worker could manage it, but a hand-rolled
+~50 lines with no promise round-trip per attempt is simpler and matches the
+hand-written-`.mjs` convention rastrillo already uses.
**No-JS visitors cannot sign up (ruling 2).** This session recommended a
-fallback and Paul chose otherwise, with the trade-off stated. The obligation
-that follows is honesty at the point of failure: the form renders disabled
-inside `<noscript>` with a plain explanation that signing up needs JavaScript
-and why, not a live-looking form that silently does nothing. A visitor who
-cannot join must at least learn that immediately, and from us.
+fallback; Paul chose otherwise with the trade-off stated. The mechanism must
+be the workable one: **the form renders with its submit button `disabled` and
+the explanation inside `<noscript>`; the module removes `disabled` once
+loaded.** JavaScript cannot enable a control inside `<noscript>`, so the
+earlier phrasing was unbuildable. This ordering also fails safe — a module
+that throws leaves the honest disabled state rather than a live-looking form
+that does nothing.
---
-## 5. Keys, seals, and the thing that isn't plumbed
-
-**One key, generated once, stored in the database.** On first boot the app
-reads its key row; if absent it generates 32 bytes from `crypto/rand` and
-writes it. That key serves three consumers: `auth.Config.InstanceKey` (which
-rastrillo refuses to start without), the HMAC on the challenge seal, and the
-confirmation-token derivation.
-
-This is ruling 6, and it exists to route around a real operational hole:
-`carlos env` and `carlos secrets` are both direct-bucket-mode only today —
-they want `CARLOS_DEPLOYMENT_BUCKET` and AWS credentials in the operator's
-shell, and there is no console path. Making the app's ability to boot depend
-on that is a bad trade when the app can mint its own key and let Litestream
-replicate it like every other row.
-
-The honest cost: the server can read its own key, so a compromised live host
-can forge a seal or a confirmation token. That is acceptable precisely because
-§0 makes no confidentiality claim. It would not be acceptable in an app that
-did.
-
-**The seal** is `HMAC-SHA256(key, nonce ‖ issuedAt ‖ difficulty)`, carried as
-a hidden field. On submit the server recomputes it; a mismatch, an age under
-**3 seconds**, or an age over **2 hours** is rejected. Three seconds is not a
-throughput limit, it is a claim that nobody reads three positions on AI
-ethics, picks one, and composes a note about themselves in less time.
-
-**The admin allowlist** is the one thing left needing configuration:
-`MOVEMENT_ADMINS`, comma-separated addresses, read at boot and handed to
-`auth.Config.Authorize`. **When it is empty every admin route answers 404** —
-not 403, and not an open admin page. Fail closed, and indistinguishable from
-an app that has no admin at all. Setting it needs direct-bucket mode once;
-that wrinkle is recorded here rather than dodged with a worse mechanism.
+## 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.
+
+**The seal** is `HMAC-SHA256(key, nonce ‖ issuedAt ‖ difficulty)`, a hidden
+field. Rejected on mismatch, on an age under **3 seconds**, or over **2
+hours**. The floor is a claim that nobody reads three positions, picks one and
+writes about themselves in less time. It is only meaningful because ruling 13
+makes the nonce single-use — per seal, a floor is trivially waited out once.
+
+**Confirmation tokens expire in 24 hours.** A re-submission of a pending row
+issues a new token and a new expiry; the old hash is replaced and the old link
+is dead.
---
-## 6. Data
+## 7. Data
```go
type Signup struct {
- ID int64
- Email string `gorm:"uniqueIndex"` // stored lowercased; the index is the dedupe
- Stance string // never | curious | enthusiast
- Background string // why they're interested, how they can help
- Confirmed bool
- ConfirmedAt *time.Time
- TokenHash []byte // SHA-256 of the confirmation token; cleared on burn
- TokenExpiry time.Time
- CreatedAt time.Time
+ ID int64
+ Email string `gorm:"uniqueIndex"` // lowercased; the index is the dedupe
+ Stance string // internal key, never a visitor-facing label
+ Background string // capped at 2000 runes, C0 controls stripped
+ Confirmed bool
+ ConfirmedAt *time.Time
+ TokenHash []byte // SHA-256 of a random token; cleared on burn
+ TokenExpiry time.Time
+ LastMailedAt *time.Time // the per-address budget in §9
+ MailCount int
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
-type Setting struct { // one row today: the instance key
- Key string `gorm:"primaryKey"`
- Value []byte
+type Challenge struct { // ruling 13; consumed, never read twice
+ Nonce string `gorm:"primaryKey"`
+ ExpiresAt time.Time
}
```
`Stance` stores a stable internal key, never the label a visitor read. Labels
-are copy and will change; a column that changes with them is a migration for
-every wording tweak and a corrupted history for the ones already stored.
+are copy and copy changes; a column that changes with them is a migration per
+wording tweak and a corrupted history for rows already stored.
There is no `User` model and no ownership column, because there is no owned
-resource. That absence is worth naming: rastrillo's most expensive trap is
-`sessions.UserID` silently returning `(0, false)` under the `auth` plugin,
-where the session subject is a verified address rather than a numeric id.
-This app never calls it. The admin view is gated by `Authorize` on the way in
-and reads the whole table, which is correct here and would be a severe bug in
-an app with tenants.
-
-Migrations are additive-only and generated with `rastrillo migration generate`;
+resource. Worth naming: rastrillo's most expensive trap is `sessions.UserID`
+returning `(0, false)` under the `auth` plugin, where the session subject is a
+verified address rather than a numeric id. This app never calls it. The admin
+view is gated on the way in and reads the whole table — correct here, a severe
+bug in an app with tenants.
+
+Migrations are additive-only, generated with `rastrillo migration generate`;
`BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)`, in that
order, because auth's backfill reads the sessions table.
---
-## 7. What must never reach a log
+## 8. Input bounds
-Email and stance **together** are the sensitive pair — an address alone is
-ordinary, a stance alone is anonymous, the two joined is a political position
-attached to a named person. Neither appears in any log line, error string,
-job status, or metric label. `carlos logs` is platform-held storage that
-cannot be grepped clean afterwards, so this is enforced by a guard test over
-the source, not by remembering.
+Unbounded free text reaches three dangerous places: an SMTP header, a CSV cell,
+and the database.
-Stance slugs appear in request paths, which the platform logs. That is
-acceptable and is not the sensitive pair: a path says someone read a page, not
-that anyone signed up, and it carries no address.
+- **Email** — rejected if it contains CR or LF (header injection, before it
+ ever reaches `Send`), over 254 bytes, or fails `net/mail.ParseAddress`.
+ Stored lowercased.
+- **Background** — capped at 2000 runes, C0 control characters stripped.
+- **The request** — wrapped in `http.MaxBytesReader`.
+- **Stance** — must be one of the three known keys; anything else renders the
+ same page as every other rejection.
+- **The CSV** — every cell is escaped against formula injection. A value
+ beginning `=`, `+`, `-`, `@`, tab or CR executes when the export is opened in
+ Excel, and this export exists to be opened in Excel. Prefixed and quoted per
+ OWASP, and tested.
---
-## 8. Rate limiting
-
-Rastrillo ships no IP-level limiter, by design — its own docs call that
-deployment's job — and the platform gives the app no hook into one. So the
-budget is per-address and in-memory, mirroring the shape already in
-`password/limit.go`: a bounded map keyed by lowercased address, a fixed
-window, a cap on tracked keys, pruned on write.
+## 9. Mail budgets
-This is deliberately modest, and proof of work is the real cost function for
-volume. A per-address budget cannot stop a spread-out attack across many
-addresses; PoW makes each of those addresses cost CPU, which is the axis that
-actually bites. Naming that division here so nobody later mistakes the limiter
-for the defence.
-
----
+Two budgets, both **persisted**, because the in-memory limiter resets on every
+hibernation, restart and deploy — and this process hibernates as its normal
+state.
-## 9. Admin
+- **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.
-`rastrillo/auth` is the magic-link plugin, and its `Mailer` is a
-`mail.Sender` — 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.
-
-`/admin` lists signups newest first with stance and confirmation state.
-`/admin/export.csv` emits **confirmed rows only** — that restriction is the
-one rule's first half made mechanical, and it is a test, not a convention.
-
-Signup mail and admin mail share one sending identity on
-`movement.bdj.oncarlos.com`, verified by `carlos email enable`. Two notes
-from the platform's own documentation, both of which have bitten people:
-freshly minted SMTP credentials fail for the first minute or two while SES
-propagates, and a `535` inside that window is not a real failure; and DKIM is
-the only working alignment rail, since SPF's envelope sender is a region
-subdomain that strict alignment reads as a different domain. DKIM signing is
-therefore never turned off.
+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.
---
-## 10. Errors
+## 10. Errors and logging
Every rejected submission renders the same page with the same status. The
-server logs a reason code — `seal_invalid`, `too_fast`, `too_old`, `pow_short`,
-`honeypot` — with no address attached, so a broken client can be diagnosed
-without building the oracle §0 forbids.
-
-The honeypot is the exception that proves the rule: it answers **success**
-(ruling 9) and writes nothing. A crawler that gets an error learns which field
-to leave alone; one that gets a cheerful confirmation page learns nothing and
-goes away satisfied.
-
-Template execution renders into a buffer before anything is written, so a
-template error becomes a clean 500 rather than a half-painted page — the
-pattern `examples/notes/render.go` already uses.
+server logs a reason code — `seal_invalid`, `nonce_spent`, `too_fast`,
+`too_old`, `pow_short`, `honeypot`, `bounds`, `mail_failed`, `mail_budget` —
+**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.
+
+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.
+
+Templates render into a buffer before anything is written, so a template error
+becomes a clean 500 rather than a half-painted page.
---
-## 11. Review
-
-Every spec and every branch is reviewed by the highest-capability model
-available before it is approved or merged. Cheaper models draft; the most
-capable one available reviews. A review by a weaker model than the author is
-not a review, and the reviewing model is recorded — in the status line above
-for this spec, in the branch discussion for each branch.
+## 11. Admin
+
+`rastrillo/auth` is the magic-link plugin; its `Mailer` is a `mail.Sender`,
+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.
+
+**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
+want a month-long cookie.
+
+`/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
+about the funnel is the wrong trade. `/admin/export.csv` is confirmed rows,
+escaped per §8, and that restriction is a test, not a convention.
+
+Two platform notes, both of which have bitten people: freshly minted SMTP
+credentials fail for the first minute or two while SES propagates, and a `535`
+in that window is not a real failure; and DKIM is the only working alignment
+rail, since SPF's envelope sender is a region subdomain that strict alignment
+reads as a different domain. DKIM signing is never turned off.
---
@@ -318,29 +397,39 @@ for this spec, in the branch discussion for each branch.
`make test` and `.amadan/ci.d/*` are one definition, never two lists that
drift. Cheapest first: vet, fmt, build, `rastrillo migration check`, test, race.
-1. **No enumeration.** Drive `POST /signup` against an unknown, a pending, and
- a confirmed address; assert status, headers and body are identical. This is
- §0's second half and it is the test most likely to be broken by a
- well-meaning copy change.
-2. **Export excludes the unconfirmed.** Pending rows never appear in the CSV.
+1. **No enumeration.** `POST /signup` against unknown, pending and confirmed
+ addresses returns identical status, headers and body — and so do the
+ budget-suppressed, limiter-tripped, mail-failed and invalid-stance paths.
+ §0 names timing as outside the claim.
+2. **Export excludes the unconfirmed**, and every cell is formula-escaped.
3. **PoW is enforced and bound.** A short solution is rejected; a solution
- valid for one address is rejected when replayed with another. The second
- assertion is ruling 3's only real defence.
-4. **Seal bounds.** Forged seal, submission under 3s, submission over 2h — all
- rejected, all identically.
-5. **Honeypot.** A filled honeypot returns the success page and writes no row.
-6. **Confirmation is single-use.** A second click on a burned token does not
- re-confirm and does not error differently from an unknown token.
-7. **Admin fails closed.** With `MOVEMENT_ADMINS` empty, every admin route
- answers 404.
-8. **No sensitive pair in logs.** A guard test over the source: no log call
- site takes both an address and a stance.
-9. **The difficulty is calibrated, not assumed.** A recorded measurement on
- real mid-range hardware before launch, per §4.
-
-Gate 3 is the one that must cross from the client's solver to the server's
-verifier — a test that only exercises the Go side proves the check, not the
-scheme.
+ valid for one address is rejected for another. This gate must cross from
+ the browser solver to the Go verifier — the scaffold's chromedp harness
+ under `-tags browser` — because a Go-only test proves the check, not the
+ 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.
+5. **A confirmed row is immutable from `/signup`.** Re-submission with a
+ different stance changes nothing and does not un-confirm.
+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.
+8. **Confirmation is single-use and POST-only.** A `GET /confirm` confirms
+ nothing; a second POST on a burned token is indistinguishable from an
+ 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.
+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.
+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.
+15. **Difficulty is calibrated**, p95 and p99 recorded on real hardware.
---
@@ -348,11 +437,12 @@ scheme.
| Thing | Trigger |
|---|---|
+| Changing stance after confirming | Someone asks; today a confirmed row is immutable (ruling 14) |
| Push into mailing-list software | A CARLOS-family mailing list app exists |
| Self-service unsubscribe and deletion | Before the first bulk send |
| Passkey second factor on admin | A third admin, or the first export leaving this machine |
| A custom domain | Paul asks; the platform host is ruling 7 for v1 |
-| Funnel analytics | Asked for twice |
+| Funnel analytics beyond a pending count | Asked for twice |
| i18n | — |
A thing deferred without a trigger is a thing forgotten.
@@ -361,20 +451,29 @@ A thing deferred without a trigger is a thing forgotten.
## 14. Sequencing
-1. Skeleton: the platform contract, `/healthz`, `/api/version`, one boot migration.
-2. The instance key, the seal, and their tests.
-3. The PoW verifier in Go, then the solver module, then the crossing test.
-4. Models, migrations, and the signup handler with all four checks.
-5. Confirmation tokens and the confirm route.
-6. `carlos email enable`, the mailer, and the two mails.
-7. Copy review, then the templates and static assets.
-8. Admin: auth allowlist, list, CSV export.
-9. The sweep tick.
-10. Deploy to `movement.bdj.oncarlos.com`, calibrate difficulty, record it.
-
-Copy review sits at 7 deliberately. The templates need real copy to be worth
-writing, and the copy needs Paul. Everything before it is copy-independent,
-so the wait costs nothing.
+Two plans, split at the copy gate. Everything in the first is
+copy-independent, so waiting on Paul costs nothing.
+
+**Plan 1 — the engine.** Placeholder copy throughout; every gate in §12 except
+15 is 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.
+
+**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.
---
@@ -384,12 +483,16 @@ so the wait costs nothing.
issues" describes a real group accurately but will not be self-selected by
it — it asks someone to volunteer that they have not thought about
something. Those people will pick the middle option, polluting the one
- signal this whole app exists to isolate. The label needs to be something
- that group would own willingly, with the diagnosis living in Paul's reading
- of the bucket rather than in the words on the button. Settled at copy review.
+ signal this app exists to isolate. The label needs to be something that
+ group would own willingly, with the diagnosis living in Paul's reading of
+ the bucket rather than in the words on the button. Settled at copy review.
- **The headline against carlosframework.com.** The proposed line frames
CARLOS as simplifying how we build connected software; the site sells cost,
security, resilience, privacy and sovereignty, with per-person encrypted
databases. A visitor who follows the link meets a different pitch. Settled
at copy review.
-- **Difficulty.** 18 bits is an estimate. §12 gate 9 closes this.
+- **Ruling 21 costs Paul something.** Pending rows carry the free-text note,
+ and hiding them means not reading what someone wrote until they confirm. If
+ that is the wrong trade, ruling 21 is the row to revise — but it should be
+ revised deliberately, not discovered as a missing feature.
+- **Difficulty.** 18 bits is an estimate. Gate 15 closes it.