carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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

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.

  1. Copy review, then the real strings into internal/copy, the stance slugs, and the templates and static assets around them.
  2. Deploy to a canary, calibrate difficulty at p95/p99, record it.
  3. 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.