carlos / movement Public

Design: the one rule, and why the third stance label won't work

The spec settles eleven rulings, of which three are this session's rather
than Paul's: the proof-of-work challenge binds to the email address, the
instance key is minted on first boot into the database rather than plumbed
through carlos secrets, and a filled honeypot answers success.

Two open threads are recorded rather than guessed at, both for copy review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175aLTHqLrYkGe1skMV1rvq
Paul Campbell 74fcf1f17371f38eecbe008913b56b8142e2a426 parent 65feaea
3 files changed, +559 −0
  • .gitignore +3 −0
  • AGENTS.md +161 −0
  • docs/superpowers/specs/2026-09-01-movement-design.md +395 −0
diff --git a/.gitignore b/.gitignore
index d0730e3..b3f933c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,6 @@
.vscode
.zprofile
.zshrc
+
+# Harness state (device files; never committed here)
+.claude/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..02a36fe
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,161 @@
+# πŸ€– Movement
+
+A CARLOS recruitment app. One public signup surface, one confirmed list.
+
+This file is binding. Where it disagrees with a general skill, this file
+wins. Where it disagrees with the design spec, the spec wins and this file
+is wrong β€” fix it in the same commit.
+
+**The design:** `docs/superpowers/specs/2026-09-01-movement-design.md`. Its
+Β§1 is the settled-decisions table: dated, attributed, with the rejected
+alternative beside each. Don't relitigate one in a drive-by.
+
+---
+
+## 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.**
+
+A row is inert until confirmed β€” never exported, never counted, never mailed
+again beyond its own confirmation. Proof of work and honeypots raise the cost
+of *submitting* an address; only a click from inside an inbox says the address
+is real and the person consented.
+
+The second half is the one that gets broken by accident. Every public response
+is identical whether an address is unknown, pending, or long confirmed. The
+moment someone adds a friendly "you're already signed up!" message, this app
+becomes a service for checking who has taken a public position on AI. Two
+tests stand in front of that, and a copy change that breaks them is the copy
+being wrong, not the tests.
+
+**We never claim the list is private from us.** The server reads every address
+and holds its own key. The README says so and the UI never implies otherwise.
+
+---
+
+## Review β€” RULED 2026-09-01 by Paul
+
+**Every spec and every branch is reviewed by the highest-capability model
+available before it is approved or merged.**
+
+Not the model that happened to write it, and not whichever model the session
+started on. Cheaper models draft; the most capable one available reviews. A
+review by a weaker model than the author is not a review.
+
+Mechanically:
+
+- **Specs** β€” a review pass before the spec is presented for human approval,
+ and again after any material change to a settled decision.
+- **Branches** β€” a review pass before the PR is opened, and again after any
+ force-push that changes more than a rebase.
+- **Record which model reviewed**, in the spec's status line or the branch
+ discussion. A review nobody can attribute is a review nobody can check.
+
+**The highest-capability model is discovered, not hardcoded.** Naming one here
+without saying how to re-derive it is how this rule goes stale silently. The
+check is: what is the most capable model this session can actually reach right
+now. If a session cannot reach one at least as capable as the author's, it says
+so and waits rather than passing a weaker review off as done.
+
+This is a floor, not a ceiling.
+
+---
+
+## Shipping cadence β€” RULED 2026-09-01 by Paul
+
+**Everything happens on a worktree, on a branch.** Never directly on `main`,
+and never in the primary checkout.
+
+1. **Worktree per session.** One isolated workspace per piece of work.
+2. **Branch per piece of work**, cut from current `main`.
+3. **Commit regularly and push to the branch.** Small commits that tell the
+ story, pushed as they land β€” not one heroic commit at the end.
+4. **Squash-merge to `main`** when, and only when, all three hold:
+ - the work is **done** β€” not "done except",
+ - it is **approved** β€” including the Β§Review pass,
+ - **CI is green** β€” actually green, checked, not assumed.
+
+Any one of the three missing means it does not merge.
+
+Review happens on a **deployed canary, never localhost**. A 200 is not a
+deploy verification β€” only `X-Carlos-Version` on the canonical host, after the
+watch, is proof. And a promote is not a deploy: until the process cycles, the
+old binary is still serving.
+
+---
+
+## Copy is reviewed before it ships
+
+**No user-facing string enters a template without passing the copy-review
+gate.** Headlines, the three stance messages, button labels, error text, both
+emails. This app *is* its copy β€” the words are the mechanism that sorts people
+into buckets, not decoration on top of one.
+
+Draft copy lives in the review tool, never committed straight into a template
+and cleaned up later.
+
+---
+
+## Mechanical rules
+
+**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.
+- `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
+ copy and copy changes.
+- Never bind a request body onto a GORM model. Named fields via
+ `r.PostFormValue`, writes through an explicit `.Select` allowlist.
+
+**Logs**
+
+- Email and stance **together** are the sensitive pair. Neither reaches a log
+ line, error string, job status, or metric label. `carlos logs` is
+ platform-held storage that cannot be grepped clean afterwards.
+- Rejected submissions log a reason code and no address.
+
+**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.
+
+**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.
+
+**Deploys**
+
+- `CGO_ENABLED=0`, cgo-free SQLite. A cgo driver sneaking in is a bug.
+- Never import `github.com/glebarez/*` or `gorm.io/driver/sqlite` β€” both
+ collide with `rastrillo/gormlite`.
+- Build `linux/arm64`. The wrong architecture is adopted silently and then
+ refused forever.
+- 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
+ second connection to a single-writer SQLite file deadlocks under traffic.
+
+**Rules are tests, not memory**
+
+If you find yourself relying on remembering one of these, make it a test
+instead.
+
+---
+
+## Authorship
+
+πŸ€– / πŸ‘¨ markers, `Co-Authored-By:` trailers, published prompt and carbon
+ledgers. AI is a guest with a name tag: opt-in, disclosed, and every path
+works with it switched off.
diff --git a/docs/superpowers/specs/2026-09-01-movement-design.md b/docs/superpowers/specs/2026-09-01-movement-design.md
new file mode 100644
index 0000000..636ebba
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-01-movement-design.md
@@ -0,0 +1,395 @@
+# πŸ€– 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
+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.)*
+**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 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.
+
+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.
+
+**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.
+
+---
+
+## 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, 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 |
+| 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 |
+| 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 |
+
+---
+
+## 2. Shape
+
+Six public routes, four 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=… burns the token, marks the row confirmed
+
+POST /signin magic link begin (rastrillo/auth)
+GET /auth/callback "
+GET /auth/verify "
+GET /admin the list (allowlisted addresses only)
+GET /admin/export.csv confirmed rows only
+POST /signout
+
+GET /healthz required by the platform contract
+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.
+
+`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. 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.
+
+---
+
+## 4. 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
+and compares β€” O(1) to check, ~2^difficulty to find.
+
+**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.** 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 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.
+
+**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.
+
+---
+
+## 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. 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
+}
+
+type Setting struct { // one row today: the instance key
+ Key string `gorm:"primaryKey"`
+ Value []byte
+}
+```
+
+`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.
+
+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`;
+`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
+
+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.
+
+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.
+
+---
+
+## 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.
+
+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.
+
+---
+
+## 9. Admin
+
+`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.
+
+---
+
+## 10. Errors
+
+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.
+
+---
+
+## 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.
+
+---
+
+## 12. Testing gates
+
+`make test` and `.amadan/ci.d/*` are one definition, never two lists that
+drift. Cheapest first: vet, fmt, build, `rastrillo migration check`, test, race.
+
+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.
+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.
+
+---
+
+## 13. Deliberately not built yet
+
+| Thing | Trigger |
+|---|---|
+| 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 |
+| i18n | β€” |
+
+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.
+
+---
+
+## 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 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.
+- **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.