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