carlos / movement Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Movement β€” design
2
3**Status:** design, revised three times on 2026-09-01, and ready to implement
4against. Approved in brainstorming by Paul on 2026-09-01; revised the same day
5against three review passes dispatched to **Fable** β€” 22 findings, then 14
6against the first revision ("not ready"; rulings 13, 14, 19 and 20 changed
7mechanism), then one blocker and two corrections against the second, all
8applied. Every ruling is dated and attributed, with the rejected alternative
9beside it.
10**Reviewing model, per Β§11:** dispatched to Fable, 2026-09-01, three times.
11*(Recorded as what was dispatched, not what an agent self-reported β€” models
12are unreliable about their own identity.)*
13**Rulings 12–26 postdate Paul's approval** and carry only the review passes,
14not his. Several reverse or materially change what he approved β€” most of all
15ruling 12 (the instance key moved out of the database and into the
16environment) and ruling 15 (confirmation moved from GET to POST). They need his
17eyes before plan 2 puts anything in front of a visitor.
18**Scope:** the Movement recruitment app: a public signup surface, a confirmed
19email list, and an admin view over it. It does not define a mailing-list
20sender; Β§13 names the trigger for that.
21
22---
23
24## 0. The one rule
25
26> **An address joins the list only by clicking a link in its own inbox, and
27> the public surface never reveals which addresses have.**
28
29Both halves are load-bearing and both are tested from the first commit.
30
31The first half is what makes the list worth having. Proof of work and a
32honeypot raise the cost of submitting an address; neither says anything about
33whether the address exists, receives mail, or belongs to the person who typed
34it. Only a deliberate action from inside that inbox does. So a row is inert
35until confirmed: never exported, never counted, never mailed beyond its own
36confirmation.
37
38The second half is what stops the signup form doubling as a membership
39oracle. Every public response β€” status, headers, body β€” is identical whether
40an address is unknown, pending, or long confirmed. An app whose subject is who
41will associate themselves with a position on AI must not let a stranger
42enumerate who did.
43
44**What the rule does not claim.**
45
46- The server reads every address in plaintext and holds the key that seals its
47 challenges. This is not an encrypted-at-rest app and the README will not
48 imply it is.
49- **Timing is not covered.** Responses are identical in content; they are not
50 constant-time. Rejecting paths do no writes and are measurably faster than
51 the accepting one. Ruling 19 states the invariant that is actually tested,
52 and this is the limit of the claim.
53
54---
55
56## 1. Settled decisions (don't relitigate casually)
57
58| # | Ruling | Rejected | Who, when |
59|---|---|---|---|
60| 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 |
61| 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 |
62| 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 |
63| 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 |
64| 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 |
65| 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 |
66| 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 |
67| 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 |
68| 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 |
69| 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 |
70| 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 |
71| 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 |
72| 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 |
73| 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 |
74| 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 |
75| 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 |
76| 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 |
77| 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 |
78| 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 |
79| 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 |
80| 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 |
81| 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 |
82| 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 |
83| 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 |
84| 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 |
85| 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 |
86
87---
88
89## 2. Shape
90
91Five public routes, seven admin, two platform.
92
93```
94GET / headline, the link out to carlosframework.com, three choices
95GET /<stance-a> tailored message + signup form (slugs settled at copy review)
96GET /<stance-b> "
97GET /<stance-c> "
98POST /signup one handler for all three; stance arrives as a hidden field
99GET /confirm?token=… renders a page with one Confirm button β€” burns nothing
100POST /confirm burns the token, marks the row confirmed
101
102GET /signin the form, and where auth redirects every outcome
103POST /signin magic link begin (rastrillo/auth)
104GET /auth/callback "
105GET /auth/verify "
106POST /signout "
107GET /admin confirmed rows, newest first, plus a pending count
108GET /admin/export.csv confirmed rows only
109
110POST /tick/sweep outside the CSRF router; carlos.Tick guards it
111GET /healthz from rastrillo.Serve β€” never hand-rolled
112GET /api/version "
113```
114
115All seven admin routes answer **404** when `MOVEMENT_ADMINS` is empty β€”
116including `GET`/`POST /signin`. Leaving sign-in live under an empty allowlist
117would let anyone make the app send magic-link mail from its verified identity
118to any address, and `/auth/verify` would answer 403 rather than the promised
119404, which is itself a signal.
120
121`cmd/movement/main.go` stays a thin dispatcher: `rastrillo.Resolve` β†’
122`db.Open` β†’ `movement.App` β†’ blank `opts.DBPath` β†’ `rastrillo.Serve`. The
123blanking is not optional; `Serve` re-opens the database if `DBPath` survives,
124and a second connection to a single-writer SQLite file is a deadlock waiting
125for traffic.
126
127---
128
129## 3. Configuration, and refusing to boot
130
131Three variables, read once at boot, delivered by `carlos env`/`carlos secrets`
132(both of which work through a logged-in console β€” the earlier claim that they
133were direct-bucket-only was wrong):
134
135| Variable | Purpose | Missing |
136|---|---|---|
137| `MOVEMENT_ORIGIN` | absolute origin; base of every emailed link, and the argument to `csrf.Protect` and `auth.Config.Origin` | refuse to boot |
138| `MOVEMENT_INSTANCE_KEY` | hex-encoded 32 bytes; seals auth's pending blob and the challenge | refuse to boot |
139| `MOVEMENT_ADMINS` | comma-separated admin addresses | boot, all admin routes 404 |
140
141A missing origin refuses at boot; a *wrong* one boots happily and mails links
142to the wrong host, so it is worth getting right once. This matters directly
143for canary review, which AGENTS.md requires: a canary runs at
144`<canary>.movement.bdj.oncarlos.com`, and a hard-coded production origin
145would have the canary mailing links into production.
146
147`auth.New` refuses an empty `InstanceKey` on its own, but with a framework
148message at the wiring call. Refusing at boot, naming the variable an operator
149must set, is the difference between a five-minute fix and an afternoon.
150
151**The mail fallback is refused too (rulings 16 and 25).** `mail.FromEnv`
152returns `mail.Logged` when SMTP is unconfigured, and `Logged` writes recipient,
153subject and body β€” the magic link, the confirmation token, the address β€” into
154platform-held storage Β§7 says cannot be grepped clean.
155
156The check is on the **environment, before calling `FromEnv`**: if
157`CARLOS_SMTP_HOST` or `CARLOS_SMTP_FROM` is empty, refuse to boot unless the
158origin is loopback, where it warns loudly instead. Detecting the fallback by
159inspecting the returned value is not possible β€” `loggedSender` is unexported β€”
160so an earlier draft's "the app detects that fallback itself" was a sentence
161with no mechanism behind it. `auth.New` also substitutes `Logged` for a nil
162`Mailer`, which is the same leak through another door, so `Mailer` is always
163passed explicitly.
164
165---
166
167## 4. The signup path
168
1691. `GET /<stance>` mints a challenge: a random nonce, an issued-at, a
170 difficulty, and an HMAC over the three (Β§6). All four travel as hidden
171 fields. **Nothing is written.** The seal already proves the server issued
172 the nonce, so a row at mint time buys nothing and costs everything (ruling
173 13).
1742. The visitor types an address and a note. On submit the module solves the
175 proof of work (Β§5) in a Worker and posts the counter.
1763. The server checks, in this order, answering identically to every failure:
177 **honeypot empty** β†’ seal valid β†’ age within bounds β†’ field bounds (Β§8) β†’
178 PoW satisfied. The honeypot is first because it is free, needs no seal, and
179 must not sit behind anything that writes.
1804. One transaction on the writer: `INSERT` the nonce into `spent_nonces`
181 `ON CONFLICT DO NOTHING` β€” **zero rows affected means replay, and the
182 transaction rolls back** β€” then the signup write, then the budget bump.
1835. The signup write, per ruling 14:
184 - **unknown** β†’ insert pending, mint a token, store its SHA-256;
185 - **pending** β†’ overwrite `Stance`, `Background`, `TokenHash`,
186 `TokenExpiry` through an explicit `.Select` allowlist that **excludes the
187 budget columns and `CreatedAt`**, so an attacker cannot reset a budget by
188 re-submitting;
189 - **confirmed** β†’ no write to the row and no send at all. The nonce is
190 still spent, as it is for every accepted submission.
1916. After the transaction commits (ruling 24), and subject to the budgets in
192 Β§9, the confirmation mail is sent.
1937. The response is the same "check your inbox" page in every case β€” including
194 the confirmed case, the budget-suppressed case, and every rejection.
195
196**Ruling 13 is what makes replay cost something, and its shape matters.** The
197first revision persisted the nonce when the challenge was minted. That made
198every stance-page GET a serialised write against a single-writer SQLite file β€”
199and a crawler, a link scanner, an uptime monitor or a prefetcher would each
200produce millions of rows a day and contend for the one connection every
201signup needs. Recording nonces as *spent*, on acceptance, bounds the table by
202completed proof-of-work solves: rows now cost the attacker rather than us.
203
204Without it, one solved challenge is replayable for two hours and each replay
205mails the same address again β€” a mail-bombing primitive aimed at a stranger's
206inbox, sent from your verified identity, with SES reputation as the collateral.
207An in-memory set would not survive hibernation, which is this process's normal
208state.
209
210**Ruling 14 is what protects the signal.** A blanket upsert lets anyone who
211knows an address knock it off the list, or β€” worse for an app that exists to
212sort people by stance β€” re-file a named person under a stance they never
213chose. The rule now names the columns rather than saying "untouched", because
214"untouched" left the confirmed path undefined: it neither said a mail was sent
215nor that one wasn't, and the reading that sends one is unbounded mail to every
216address already on the list. Confirmed means no write, no send, same page.
217Changing one's stance after confirming is not built (Β§13).
218
219`GET /confirm` renders a page with a single Confirm button; `POST /confirm`
220hashes the token, marks the row, and clears the hash. Ruling 15 exists because
221link-scanning gateways fetch every URL in an arriving mail: confirm-on-GET
222would let Outlook Safe Links confirm a row no human ever clicked, and then
223show the human "this link is no longer valid." An expired, unknown, or burned
224token renders one page, identical for all three.
225
226---
227
228## 5. Proof of work
229
230The client must find a counter such that
231
232```
233SHA-256(nonce β€– ":" β€– lowercased-email β€– ":" β€– counter)
234```
235
236has at least `difficulty` leading zero bits. The server re-runs that one hash.
237
238**Binding the address into the preimage is ruling 3.** Unbound, a challenge is
239solved once and reused against every address in a list. Bound, the cost is one
240solve per address β€” the axis a bulk signup attack actually scales along.
241
242**What proof of work does not do.** 2^18 SHA-256 is under a millisecond on a
243commodity GPU; a determined attacker solves thousands of bound challenges a
244second. PoW prices out casual and scripted abuse, and nothing more. The
245defence against a serious bulk attack is the persisted mail budget in Β§9, not
246this. Β§9 says so plainly so that nobody later mistakes the speed bump for the
247wall.
248
249**Difficulty starts at 18 bits and is a tunable constant**, carried in the
250seal. Β§12 requires it be re-set against real mid-range hardware before launch,
251recording **p95 and p99, not the mean** β€” solve time is geometric, and p99 is
252about 4.6Γ— the mean, so calibrating on the average ships a form that hangs for
253one visitor in a hundred. A raised difficulty does not apply to seals already
254issued; that two-hour tail is accepted.
255
256**The solver runs in a Worker**, not on the main thread β€” 262k hashes in plain
257JS freezes a phone for seconds. Same-origin workers are allowed by the default
258`default-src 'self'` policy. It reports progress, and the submit button stays
259disabled until it finishes.
260
261**The hash is a small synchronous SHA-256 inside the module.** WebCrypto's
262`subtle.digest` is async per call; a worker could manage it, but a hand-rolled
263~50 lines with no promise round-trip per attempt is simpler and matches the
264hand-written-`.mjs` convention rastrillo already uses.
265
266**No-JS visitors cannot sign up (ruling 2).** This session recommended a
267fallback; Paul chose otherwise with the trade-off stated. The mechanism must
268be the workable one: **the form renders with its submit button `disabled`, and
269the module removes `disabled` once loaded.** JavaScript cannot enable a
270control inside `<noscript>`, so an earlier phrasing that put the live control
271there was unbuildable. This ordering also fails safe β€” a module that throws
272leaves the button disabled rather than live and inert.
273
274**And the page does not say why.** An earlier draft of this section required
275an explanation in `<noscript>`, on the grounds that a visitor who cannot join
276should at least hear it from us. Paul removed it on 2026-09-02 after copy
277review β€” proof of work is our problem, not theirs, and a paragraph about bots
278and arithmetic is noise on a page whose whole job is asking someone a question
279about themselves. The honest consequence: a no-JS visitor meets a disabled
280button and no reason. It is a dead end rather than a deception, which is the
281line that matters, and it is why the button must never render enabled.
282
283---
284
285## 6. Keys and seals
286
287`MOVEMENT_INSTANCE_KEY` is 32 random bytes hex-encoded β€” `openssl rand -hex 32`.
288`auth.Config.InstanceKey` is a `string` and accepts any non-empty value, which
289it hashes with its own label; hex is this app's convention, not a framework
290requirement. The challenge HMAC key is **derived from it with a distinct
291label** rather than used raw, mirroring auth's own domain separation, so that
292two subsystems sharing one secret cannot be made to verify each other's
293tokens.
294
295It does **not** derive confirmation tokens β€” those are 32 random bytes, stored
296only as a SHA-256, following the `auth_links` pattern. An earlier draft said
297both, and key-derived tokens die on any key rotation.
298
299**The PoW preimage lowercases ASCII only.** JavaScript's `toLowerCase` and
300Go's `strings.ToLower` disagree on some Unicode, and a disagreement here is a
301silent `pow_short` that no visitor can diagnose and no log explains.
302
303**The seal** is `HMAC-SHA256(key, nonce β€– issuedAt β€– difficulty)`, a hidden
304field. Rejected on mismatch, on an age under **3 seconds**, or over **2
305hours**. The floor is a claim that nobody reads three positions, picks one and
306writes about themselves in less time. It is only meaningful because ruling 13
307makes the nonce single-use β€” per seal, a floor is trivially waited out once.
308
309**Confirmation tokens expire in 24 hours.** A re-submission of a pending row
310issues a new token and a new expiry; the old hash is replaced and the old link
311is dead.
312
313---
314
315## 7. Data
316
317```go
318type Signup struct {
319 ID int64
320 Email string `gorm:"uniqueIndex"` // lowercased; the index is the dedupe
321 Stance string // internal key, never a visitor-facing label
322 Background string // capped at 2000 runes, C0 controls stripped
323 Confirmed bool
324 ConfirmedAt *time.Time
325 TokenHash []byte // SHA-256 of a random token; cleared on burn
326 TokenExpiry time.Time
327 LastMailedAt *time.Time // the per-address budget in Β§9
328 MailCount int
329 CreatedAt time.Time
330 UpdatedAt time.Time
331}
332
333type SpentNonce struct { // ruling 13; written on acceptance only
334 Nonce string `gorm:"primaryKey"`
335 ExpiresAt time.Time // issuedAt + 2h, from the sealed timestamp
336}
337
338type MailCounter struct { // the global budget, Β§9
339 HourBucket string `gorm:"primaryKey"` // UTC, "2006-01-02T15"
340 Count int
341}
342```
343
344`Stance` stores a stable internal key, never the label a visitor read. Labels
345are copy and copy changes; a column that changes with them is a migration per
346wording tweak and a corrupted history for rows already stored.
347
348There is no `User` model and no ownership column, because there is no owned
349resource. Worth naming: rastrillo's most expensive trap is `sessions.UserID`
350returning `(0, false)` under the `auth` plugin, where the session subject is a
351verified address rather than a numeric id. This app never calls it. The admin
352view is gated on the way in and reads the whole table β€” correct here, a severe
353bug in an app with tenants.
354
355Migrations are additive-only, generated with `rastrillo migration generate`;
356`BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)`, in that
357order, because auth's backfill reads the sessions table.
358
359---
360
361## 8. Input bounds
362
363Unbounded free text reaches three dangerous places: an SMTP header, a CSV cell,
364and the database.
365
366- **Email** β€” rejected if it contains CR or LF (header injection, before it
367 ever reaches `Send`), over 254 bytes, or fails `net/mail.ParseAddress`.
368 Stored lowercased.
369- **Background** β€” capped at 2000 runes, C0 control characters stripped.
370- **The request** β€” wrapped in `http.MaxBytesReader`.
371- **Stance** β€” must be one of the three known keys; anything else renders the
372 same page as every other rejection.
373- **The CSV** β€” every cell is escaped against formula injection. A value
374 beginning `=`, `+`, `-`, `@`, tab or CR executes when the export is opened in
375 Excel, and this export exists to be opened in Excel. Prefixed and quoted per
376 OWASP, and tested.
377
378---
379
380## 9. Mail budgets
381
382Two budgets, both **persisted**, because the in-memory limiter resets on every
383hibernation, restart and deploy β€” and this process hibernates as its normal
384state.
385
386- **Per address: 3 mails per 24 hours**, counted in `LastMailedAt` and
387 `MailCount` on the row. Past it, the send is suppressed and the same page
388 renders.
389- **Globally: 200 mails per hour**, in `MailCounter`. Past it, the same page
390 renders and the app logs `mail_budget`. This exists because the real damage
391 from a bulk attack is not junk rows, it is thousands of mails to strangers
392 from a verified identity, followed by SES suspension.
393
394**Two ways a budget silently resets, both closed.** The pending overwrite
395excludes the budget columns from its `.Select` allowlist (Β§4 step 5), and the
396sweep age is longer than the per-address window (Β§10) β€” otherwise an attacker
397re-submits, or simply waits for the sweep, and the counter starts again.
398
399**The counter is bumped in one statement** β€”
400`INSERT … ON CONFLICT(hour_bucket) DO UPDATE SET count = count + 1` β€” inside
401the signup transaction. A read-then-write across two statements is a lost
402update waiting for concurrency, even behind a single writer. Fixed hour buckets
403let a burst straddle a boundary and send up to twice the ceiling in one rolling
404hour; at these numbers that is acceptable and it is written down rather than
405discovered.
406
407**Admin sign-in mail draws on the same global budget** (ruling 23). It is sent
408by `rastrillo/auth` rather than by our own handler, so the `mail.Sender` handed
409to `auth.New` is wrapped to pass through the counter. A budget that counts only
410the mail we remember sending is not a budget. Three things the wrapper must
411get right, because `auth.Begin` calls `Send` synchronously and with no deadline
412of its own:
413
414- commit the count **before** attempting the send;
415- apply its own deadline, since nothing upstream will;
416- **return `nil` when it suppresses for budget.** Returning an error makes
417 `Begin` redirect to `?err=` instead of `?sent=1` and log the failure with the
418 address attached β€” a different response, which is the thing ruling 23 exists
419 to avoid.
420
421One residual, accepted: signin's own per-address limiter can answer `?err=rate`,
422and only an allowlisted address ever reaches it. That is a narrow oracle for
423admin membership, not list membership, so it sits outside the one rule β€” but
424"the same redirect either way" is true of our check, not of every path beneath
425it.
426
427**The budget counts attempts, not successes.** Three failed sends during SES's
428post-`enable` propagation window will lock an honest address out for 24 hours,
429with re-submission (Β§10) unavailable until it clears. Conservative on purpose,
430and stated so it is a choice rather than a surprise.
431
432The in-memory per-address limiter from `password/limit.go`'s shape stays as a
433cheap filter, but Β§5 and this section both state that it is not the defence.
434**It is checked after proof of work, not before.** Keyed by address and
435consulted first, it would let a stranger trip a victim's budget for free and
436lock them out of signing up; behind the solve, tripping someone else's costs
437real work.
438
439---
440
441## 10. Errors and logging
442
443Every rejected submission renders the same page with the same status. The
444server logs a reason code β€” `seal_invalid`, `nonce_spent`, `too_fast`,
445`too_old`, `pow_short`, `honeypot`, `bounds`, `mail_failed`, `mail_budget` β€”
446**with no address attached**.
447
448**A mail-send failure renders the same page.** The pending row survives and
449re-submitting is the recovery path. This is not hypothetical: Β§11 notes that
450freshly minted SES credentials fail for the first minute or two after
451`carlos email enable`, and those sends land exactly here.
452
453**The send is bounded by us, because the framework does not bound it**
454(ruling 24). `mail.Sender.Send` takes a context and discards it, and
455`smtp.SendMail` underneath dials with no deadline; `rastrillo.Serve` sets no
456write timeout by default. So a hung SES connection would hold the handler
457open indefinitely β€” and, if the send sat inside the write transaction, would
458hold the single writer connection with it and stall every other signup and
459the scheduled tick. The send therefore happens **after commit**, in a
460goroutine with its own deadline. A sender that never returns must not be able
461to block a second submission, and that is a test.
462
463That deadline must be **well under the platform's idle window** β€” seconds
464against minutes β€” because a goroutine outliving the response is exactly the
465shape Β§11a rejects for the tick. It is safe here only because it is short. A
466`SIGTERM` landing mid-send loses that one mail after its budget bump; Β§10's
467re-submit path is the recovery, and it is not worth more machinery than that.
468
469**One malformed-address exception.** A syntactically invalid address is
470rejected visibly, with an error naming the field. That leaks nothing: it is a
471property of the string the visitor just typed, not of any address's state on
472the list. Everything else β€” an expired tab, a sub-3s submit, a spent nonce β€”
473renders the same page, so an honest visitor with a typo is the one case that
474gets told what went wrong rather than being quietly dropped.
475
476The honeypot answers **success** (ruling 9) and writes nothing. A crawler that
477gets an error learns which field to leave alone; one that gets a confirmation
478page learns nothing.
479
480**All app logging goes through one internal package** that accepts typed reason
481codes and **no `error` values and no strings drawn from request data**. The
482guard test forbids any other `slog` import in the app. "No call site takes both
483an address and a stance" is not mechanically checkable over arbitrary Go β€”
484wrappers, structs, a `%v` of a `Signup` β€” so the boundary is enforced by making
485the sensitive pair unreachable from a log call rather than by pattern-matching
486for it.
487
488Excluding `error` is not fussiness: `rastrillo/mail` formats its failures as
489`send to <address>`, so logging a send error verbatim would print the address
490beside a request path that carries the stance β€” the sensitive pair, assembled
491by accident. SMTP failures are classified to a reason code and the error
492dropped. A test drives a failing sender with a known address and asserts it
493never appears in captured output.
494
495One accepted exception: `rastrillo/auth` logs through its own logger, and the
496addresses it sees are admins signing in. Those are never paired with a stance,
497so they stay.
498
499Templates render into a buffer before anything is written, so a template error
500becomes a clean 500 rather than a half-painted page.
501
502---
503
504## 11. Admin
505
506`rastrillo/auth` is the magic-link plugin; its `Mailer` is a `mail.Sender`,
507the same interface `carlos email enable` feeds through `CARLOS_SMTP_*` and
508`mail.FromEnv("CARLOS", logger)`. No adapter, no third-party provider, no AWS
509key in the app.
510
511**`POST /signin` is ours, not the framework's (ruling 23).** `auth.Begin`
512classifies the address and sends the mail *before* `admit` ever consults
513`Authorize` β€” so mounting it directly makes this app a mailer that anyone can
514point at any address, from our verified sending identity, at roughly twenty
515messages per fifteen minutes. The IP half of the framework's own budget
516collapses to the edge's address behind the platform, so it is not the brake it
517looks like. `sheets` hit this in production and fixed it the same way: an
518app-owned handler that checks `MOVEMENT_ADMINS` first and answers the identical
519`?sent=1` redirect whether or not the address is on it.
520
521**The allowlist is re-checked on every admin request (ruling 17).**
522`auth.Config.Authorize` is consulted only when a magic link is redeemed;
523`RequireSession` thereafter checks only that a session exists, and the default
524TTL is 30 days. Without a per-request middleware, removing someone from
525`MOVEMENT_ADMINS` does nothing for a month. Admin sessions are capped at 8
526hours via `auth.Config.SessionTTL` β€” not `RequireFreshSession`, which is a
527step-up mechanism for sensitive routes layered on a longer session, not a way
528to shorten one. A surface listing named people's political positions does not
529want a month-long cookie.
530
531**How removal actually takes effect.** `MOVEMENT_ADMINS` is read once at boot.
532A process's environment is fixed at exec β€” re-reading `os.Getenv` per request
533sees nothing new, and an earlier draft of this section claimed otherwise. What
534makes removal fast is the platform: `carlos env set` convergence re-renders and
535**bounces** socket-exec instances within a few seconds, and hibernation
536respawns the process on the next wake anyway. The per-request allowlist check
537is what then invalidates an existing session, rather than leaving it live until
538its 8 hours are up.
539
540Gate 10 must therefore model a restart. A test that calls `t.Setenv` mid-run
541would pass while proving nothing, which is worse than no test.
542
543`/admin` lists **confirmed rows only**, newest first, with a count of pending
544(ruling 21). A pending row is an address nobody has proven owns it joined to a
545stance β€” the pair Β§7 exists to protect β€” and showing it to satisfy curiosity
546about the funnel is the wrong trade. `/admin/export.csv` is confirmed rows,
547escaped per Β§8, and that restriction is a test, not a convention.
548
549Two platform notes, both of which have bitten people: freshly minted SMTP
550credentials fail for the first minute or two while SES propagates, and a `535`
551in that window is not a real failure; and DKIM is the only working alignment
552rail, since SPF's envelope sender is a region subdomain that strict alignment
553reads as a different domain. DKIM signing is never turned off.
554
555---
556
557## 11a. The sweep
558
559One tick, `POST /tick/sweep`, mounted on the outer `http.ServeMux` beside
560`mux.Handle("/", r)` so it sits outside `csrf.Protect` (ruling 18), guarded by
561`carlos.Tick` alone. Declared with
562`carlos schedule set -app movement -name sweep -every 1h -path /tick/sweep`.
563
564It runs **synchronously inside the request** β€” a 202 and a goroutine risks the
565instance hibernating mid-job β€” and does three things:
566
5671. Deletes unconfirmed rows older than **30 days**. Longer than the 24-hour
568 per-address mail budget on purpose (Β§9): a sweep shorter than the budget
569 window is a way to reset the budget by waiting. The cost is that a pending
570 row outlives its own 24-hour token by 29 days, purely to hold a counter β€”
571 junk rows and Litestream replica size are the price of ruling 20. If that
572 ever matters, moving the per-address budget to its own table keyed by an
573 address hash lets this drop back to about 48 hours.
5742. Deletes `spent_nonces` past `ExpiresAt`.
5753. Calls `auth.Sweep(now)` for expired magic links and sessions, which nothing
576 else in the app does.
577
578All three are idempotent, so at-least-once delivery needs no dedupe and
579`carlos.TickOccurrence` is not used. Confirmed rows are never touched, and
580neither is a pending row with a live token.
581
582A tick that 403s records `app refused` in the schedule's last status, but
583nothing alerts on it and `next` still advances, so `carlos schedule ls` reads
584much like success at a glance. The platform treats 4xx as "do not retry". Gate
58511 stands in front of that.
586
587---
588
589## 12. Testing gates
590
591`make test` and `.amadan/ci.d/*` are one definition, never two lists that
592drift β€” the scaffold's own targets, plus `version-check`, plus a
593**`test-browser`** step. Gates 3 and 7 need a real browser (`-tags browser`,
594chromedp), and a gate that no CI definition runs is not a gate. If the runner
595cannot get Chromium, they stop being called gates and become a documented
596manual step before each deploy.
597
5981. **No enumeration.** `POST /signup` against unknown, pending and confirmed
599 addresses returns identical status, headers and body β€” and so do the
600 budget-suppressed, limiter-tripped, mail-failed and invalid-stance paths.
601 Β§0 names timing as outside the claim.
6022. **Export excludes the unconfirmed**, and every cell is formula-escaped.
6033. **PoW is enforced and bound.** A short solution is rejected; a solution
604 valid for one address is rejected for another. This gate must cross from
605 the browser solver to the Go verifier β€” the scaffold's chromedp harness
606 under `-tags browser` β€” because a Go-only test proves the check, not the
607 scheme.
6084. **The nonce is single-use.** A byte-identical replay of a valid, accepted
609 POST is rejected and **sends no second mail**. This is ruling 13's gate and
610 the most important one in the list. Plus: a stance-page GET writes no row.
6115. **A confirmed row is immutable from `/signup`.** Re-submission with a
612 different stance changes nothing, does not un-confirm, and **sends no mail**.
6135a. **Budgets survive.** Re-submitting a pending row does not reset its mail
614 count, and neither does anything the sweep does.
6155b. **A non-allowlisted address posted to `/signin` sends no mail** and gets
616 the same redirect as one that does.
6175c. **A hung sender does not block a second submission**, and the send happens
618 after the transaction commits.
6196. **Seal bounds.** Forged seal, under 3s, over 2h β€” rejected identically.
6207. **Honeypot** returns the success page and writes no row. Plus a browser-rig
621 assertion that Chrome's autofill leaves the field empty.
6228. **Confirmation is single-use and POST-only.** A `GET /confirm` confirms
623 nothing; a second POST on a burned token is indistinguishable from an
624 unknown one.
6259. **Admin fails closed.** With `MOVEMENT_ADMINS` empty, all seven admin
626 routes answer 404 β€” iterated, not spot-checked.
62710. **A removed admin loses access on the next request after the instance
628 cycles**, not on session expiry. The test restarts the app with a changed
629 allowlist and re-uses the old session cookie β€” `t.Setenv` mid-run would
630 pass without proving anything, since the environment is fixed at exec.
63111. **The tick reaches the sweep.** A POST with no browser headers and a valid
632 bearer must not 403 β€” the CSRF trap in ruling 18, which fails silently and
633 permanently.
63412. **The sweep spares the confirmed** and anything with a live token.
63513. **Boot refusals.** Missing `MOVEMENT_ORIGIN`, missing
636 `MOVEMENT_INSTANCE_KEY`, and an empty `CARLOS_SMTP_HOST`/`CARLOS_SMTP_FROM`
637 on a non-loopback origin each refuse to start.
63814. **Logging containment.** No `slog` import outside the logging package, and
639 a failing sender's address never reaches captured output.
64014a. **Templates carry no literal user-facing text** β€” every string resolves
641 through `internal/copy`. With a placeholder remaining, the app refuses to
642 boot on the canonical origin and boots with a banner on a canary.
64315. **Difficulty is calibrated**, p95 and p99 recorded on real hardware.
644
645---
646
647## 13. Deliberately not built yet
648
649| Thing | Trigger |
650|---|---|
651| Changing stance after confirming | Someone asks; today a confirmed row is immutable (ruling 14) |
652| Push into mailing-list software | A CARLOS-family mailing list app exists |
653| Self-service unsubscribe and deletion | Before the first bulk send |
654| Passkey second factor on admin | A third admin, or the first export leaving this machine |
655| A custom domain | Paul asks; the platform host is ruling 7 for v1 |
656| Funnel analytics beyond a pending count | Asked for twice |
657| i18n | β€” |
658
659A thing deferred without a trigger is a thing forgotten.
660
661---
662
663## 14. Sequencing
664
665Two plans, split at the copy gate.
666
667**Plan 1 is copy-independent only because of ruling 26.** Every user-facing
668string resolves through an `internal/copy` table keyed by ID, so plan 1 fills
669that table with marked placeholders and never writes copy into a template.
670Routes are mounted by internal stance key until copy review settles the slugs.
671
672**Where the placeholder check runs matters**, because there is only one build:
673`make release` produces it, `carlos ship` publishes it, and the canary serves
674that same binary. A build-time refusal would make plan 1 impossible to review
675on a canary, which the shipping cadence requires. So the check is at **boot,
676against the origin**: with placeholders remaining, the app refuses to start
677when `MOVEMENT_ORIGIN` is the canonical host, and starts with a visible banner
678on a canary or loopback origin. Unreviewed words can be reviewed; they cannot
679reach production.
680
681**Plan 1 β€” the engine.** Every gate in Β§12 except 15 green at its end.
682
6831. `rastrillo new movement`, pins, `BuildVersion` stamping, the platform
684 contract. `/healthz` and `/api/version` come from `rastrillo.Serve` and are
685 never hand-rolled.
6862. Config, the three boot refusals (Β§3), and the logging package with its
687 containment test.
6883. `internal/copy` and its placeholder discipline (ruling 26).
6894. The seal, label-derived keys, and `spent_nonces` consumed on acceptance.
6905. The PoW verifier in Go, the Worker solver, and the browser-crossing test.
6916. Models, migrations, and the signup handler: every check, bound, and the one
692 transaction.
6937. Confirmation: GET renders, POST burns.
6948. `carlos email enable`, the wrapped mailer, both budgets, send-after-commit.
6959. Admin: app-owned `/signin`, allowlist, per-request re-check, list, escaped
696 CSV export.
69710. The sweep tick, mounted outside the CSRF router.
698
699**Plan 2 β€” the surface.**
700
70111. Copy review, then the real strings into `internal/copy`, the stance slugs,
702 and the templates and static assets around them.
70312. Deploy to a canary, calibrate difficulty at p95/p99, record it.
70413. Promote, and verify `X-Carlos-Version` on the canonical host.
705
706---
707
708## Open
709
710- **The third stance label.** "Love AI, never thought about its ethical
711 issues" describes a real group accurately but will not be self-selected by
712 it β€” it asks someone to volunteer that they have not thought about
713 something. Those people will pick the middle option, polluting the one
714 signal this app exists to isolate. The label needs to be something that
715 group would own willingly, with the diagnosis living in Paul's reading of
716 the bucket rather than in the words on the button. Settled at copy review.
717- ~~**The headline against carlosframework.com.**~~ **Settled by Paul,
718 2026-09-01, during `PRODUCT.md` capture:** the page is about both CARLOS and
719 the ethics, and says so β€” the tension between them is the message, not a
720 problem to resolve in favour of one. The remaining constraint is factual
721 rather than strategic: carlosframework.com leads with cost, security,
722 resilience, privacy and sovereignty, so nothing here may describe CARLOS in
723 terms that page would contradict. `PRODUCT.md` Β§Positioning is now the
724 authority; copy review writes to it rather than deciding it.
725- **Ruling 21 costs Paul something.** Pending rows carry the free-text note,
726 and hiding them means not reading what someone wrote until they confirm. If
727 that is the wrong trade, ruling 21 is the row to revise β€” but it should be
728 revised deliberately, not discovered as a missing feature.
729- **Difficulty.** 18 bits is an estimate. Gate 15 closes it.
730