# 🤖 Movement — design

**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, 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. 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.

---

## 0. The one rule

> **An address joins the list only by clicking a link in its own inbox, and
> the public surface never reveals which addresses have.**

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 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, 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 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. 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.

---

## 1. Settled decisions (don't relitigate casually)

| # | Ruling | Rejected | Who, when |
|---|---|---|---|
| 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 | 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 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 | **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 `/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. 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 |

---

## 2. Shape

Five public routes, seven admin, two platform.

```
GET  /                     headline, the link out to carlosframework.com, three choices
GET  /<stance-a>           tailored message + signup form   (slugs settled at copy review)
GET  /<stance-b>           "
GET  /<stance-c>           "
POST /signup               one handler for all three; stance arrives as a hidden field
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          "
POST /signout              "
GET  /admin                confirmed rows, newest first, plus a pending count
GET  /admin/export.csv     confirmed rows only

POST /tick/sweep           outside the CSRF router; carlos.Tick guards it
GET  /healthz              from rastrillo.Serve — never hand-rolled
GET  /api/version          "
```

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
blanking is not optional; `Serve` re-opens the database if `DBPath` survives,
and a second connection to a single-writer SQLite file is a deadlock waiting
for traffic.

---

## 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 (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, 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 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 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
   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. 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
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.

---

## 5. Proof of work

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.

**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.

**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.

**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; 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; 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 module removes `disabled` once loaded.** JavaScript cannot enable a
control inside `<noscript>`, so an earlier phrasing that put the live control
there was unbuildable. This ordering also fails safe — a module that throws
leaves the button disabled rather than live and inert.

**And the page does not say why.** An earlier draft of this section required
an explanation in `<noscript>`, on the grounds that a visitor who cannot join
should at least hear it from us. Paul removed it on 2026-09-02 after copy
review — proof of work is our problem, not theirs, and a paragraph about bots
and arithmetic is noise on a page whose whole job is asking someone a question
about themselves. The honest consequence: a no-JS visitor meets a disabled
button and no reason. It is a dead end rather than a deception, which is the
line that matters, and it is why the button must never render enabled.

---

## 6. Keys and seals

`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
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.

---

## 7. Data

```go
type Signup struct {
    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 SpentNonce struct {                      // ruling 13; written on acceptance only
    Nonce     string `gorm:"primaryKey"`
    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
}
```

`Stance` stores a stable internal key, never the label a visitor read. Labels
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. 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.

---

## 8. Input bounds

Unbounded free text reaches three dangerous places: an SMTP header, a CSV cell,
and the database.

- **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.

---

## 9. Mail budgets

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: 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.

**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. 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 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.

---

## 10. Errors and logging

Every rejected submission renders the same page with the same status. The
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. 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.

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
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 `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.

---

## 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.

**`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 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.

**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
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.

---

## 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. 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.

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 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.

---

## 12. Testing gates

`make test` and `.amadan/ci.d/*` are one definition, never two lists that
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
   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 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. Plus: a stance-page GET writes no row.
5. **A confirmed row is immutable from `/signup`.** Re-submission with a
   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.
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 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.
12. **The sweep spares the confirmed** and anything with a live token.
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`. 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.

---

## 13. Deliberately not built yet

| 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 beyond a pending count | Asked for twice |
| i18n | — |

A thing deferred without a trigger is a thing forgotten.

---

## 14. Sequencing

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.
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.

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, 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.**

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.

---

## Open

- **The third stance label.** "Love AI, never thought about its ethical
  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 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.**~~ **Settled by Paul,
  2026-09-01, during `PRODUCT.md` capture:** the page is about both CARLOS and
  the ethics, and says so — the tension between them is the message, not a
  problem to resolve in favour of one. The remaining constraint is factual
  rather than strategic: carlosframework.com leads with cost, security,
  resilience, privacy and sovereignty, so nothing here may describe CARLOS in
  terms that page would contradict. `PRODUCT.md` §Positioning is now the
  authority; copy review writes to it rather than deciding it.
- **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.
