| 1 | # π€ Movement β design |
| 2 | |
| 3 | **Status:** design, revised three times on 2026-09-01, and ready to implement |
| 4 | against. Approved in brainstorming by Paul on 2026-09-01; revised the same day |
| 5 | against three review passes dispatched to **Fable** β 22 findings, then 14 |
| 6 | against the first revision ("not ready"; rulings 13, 14, 19 and 20 changed |
| 7 | mechanism), then one blocker and two corrections against the second, all |
| 8 | applied. Every ruling is dated and attributed, with the rejected alternative |
| 9 | beside 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 |
| 12 | are unreliable about their own identity.)* |
| 13 | **Rulings 12β26 postdate Paul's approval** and carry only the review passes, |
| 14 | not his. Several reverse or materially change what he approved β most of all |
| 15 | ruling 12 (the instance key moved out of the database and into the |
| 16 | environment) and ruling 15 (confirmation moved from GET to POST). They need his |
| 17 | eyes before plan 2 puts anything in front of a visitor. |
| 18 | **Scope:** the Movement recruitment app: a public signup surface, a confirmed |
| 19 | email list, and an admin view over it. It does not define a mailing-list |
| 20 | sender; Β§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 | |
| 29 | Both halves are load-bearing and both are tested from the first commit. |
| 30 | |
| 31 | The first half is what makes the list worth having. Proof of work and a |
| 32 | honeypot raise the cost of submitting an address; neither says anything about |
| 33 | whether the address exists, receives mail, or belongs to the person who typed |
| 34 | it. Only a deliberate action from inside that inbox does. So a row is inert |
| 35 | until confirmed: never exported, never counted, never mailed beyond its own |
| 36 | confirmation. |
| 37 | |
| 38 | The second half is what stops the signup form doubling as a membership |
| 39 | oracle. Every public response β status, headers, body β is identical whether |
| 40 | an address is unknown, pending, or long confirmed. An app whose subject is who |
| 41 | will associate themselves with a position on AI must not let a stranger |
| 42 | enumerate 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 | |
| 91 | Five public routes, seven admin, two platform. |
| 92 | |
| 93 | ``` |
| 94 | GET / headline, the link out to carlosframework.com, three choices |
| 95 | GET /<stance-a> tailored message + signup form (slugs settled at copy review) |
| 96 | GET /<stance-b> " |
| 97 | GET /<stance-c> " |
| 98 | POST /signup one handler for all three; stance arrives as a hidden field |
| 99 | GET /confirm?token=β¦ renders a page with one Confirm button β burns nothing |
| 100 | POST /confirm burns the token, marks the row confirmed |
| 101 | |
| 102 | GET /signin the form, and where auth redirects every outcome |
| 103 | POST /signin magic link begin (rastrillo/auth) |
| 104 | GET /auth/callback " |
| 105 | GET /auth/verify " |
| 106 | POST /signout " |
| 107 | GET /admin confirmed rows, newest first, plus a pending count |
| 108 | GET /admin/export.csv confirmed rows only |
| 109 | |
| 110 | POST /tick/sweep outside the CSRF router; carlos.Tick guards it |
| 111 | GET /healthz from rastrillo.Serve β never hand-rolled |
| 112 | GET /api/version " |
| 113 | ``` |
| 114 | |
| 115 | All seven admin routes answer **404** when `MOVEMENT_ADMINS` is empty β |
| 116 | including `GET`/`POST /signin`. Leaving sign-in live under an empty allowlist |
| 117 | would let anyone make the app send magic-link mail from its verified identity |
| 118 | to any address, and `/auth/verify` would answer 403 rather than the promised |
| 119 | 404, 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 |
| 123 | blanking is not optional; `Serve` re-opens the database if `DBPath` survives, |
| 124 | and a second connection to a single-writer SQLite file is a deadlock waiting |
| 125 | for traffic. |
| 126 | |
| 127 | --- |
| 128 | |
| 129 | ## 3. Configuration, and refusing to boot |
| 130 | |
| 131 | Three 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 |
| 133 | were 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 | |
| 141 | A missing origin refuses at boot; a *wrong* one boots happily and mails links |
| 142 | to the wrong host, so it is worth getting right once. This matters directly |
| 143 | for canary review, which AGENTS.md requires: a canary runs at |
| 144 | `<canary>.movement.bdj.oncarlos.com`, and a hard-coded production origin |
| 145 | would have the canary mailing links into production. |
| 146 | |
| 147 | `auth.New` refuses an empty `InstanceKey` on its own, but with a framework |
| 148 | message at the wiring call. Refusing at boot, naming the variable an operator |
| 149 | must 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` |
| 152 | returns `mail.Logged` when SMTP is unconfigured, and `Logged` writes recipient, |
| 153 | subject and body β the magic link, the confirmation token, the address β into |
| 154 | platform-held storage Β§7 says cannot be grepped clean. |
| 155 | |
| 156 | The check is on the **environment, before calling `FromEnv`**: if |
| 157 | `CARLOS_SMTP_HOST` or `CARLOS_SMTP_FROM` is empty, refuse to boot unless the |
| 158 | origin is loopback, where it warns loudly instead. Detecting the fallback by |
| 159 | inspecting the returned value is not possible β `loggedSender` is unexported β |
| 160 | so an earlier draft's "the app detects that fallback itself" was a sentence |
| 161 | with 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 |
| 163 | passed explicitly. |
| 164 | |
| 165 | --- |
| 166 | |
| 167 | ## 4. The signup path |
| 168 | |
| 169 | 1. `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). |
| 174 | 2. 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. |
| 176 | 3. 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. |
| 180 | 4. 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. |
| 183 | 5. 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. |
| 191 | 6. After the transaction commits (ruling 24), and subject to the budgets in |
| 192 | Β§9, the confirmation mail is sent. |
| 193 | 7. 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 |
| 197 | first revision persisted the nonce when the challenge was minted. That made |
| 198 | every stance-page GET a serialised write against a single-writer SQLite file β |
| 199 | and a crawler, a link scanner, an uptime monitor or a prefetcher would each |
| 200 | produce millions of rows a day and contend for the one connection every |
| 201 | signup needs. Recording nonces as *spent*, on acceptance, bounds the table by |
| 202 | completed proof-of-work solves: rows now cost the attacker rather than us. |
| 203 | |
| 204 | Without it, one solved challenge is replayable for two hours and each replay |
| 205 | mails the same address again β a mail-bombing primitive aimed at a stranger's |
| 206 | inbox, sent from your verified identity, with SES reputation as the collateral. |
| 207 | An in-memory set would not survive hibernation, which is this process's normal |
| 208 | state. |
| 209 | |
| 210 | **Ruling 14 is what protects the signal.** A blanket upsert lets anyone who |
| 211 | knows an address knock it off the list, or β worse for an app that exists to |
| 212 | sort people by stance β re-file a named person under a stance they never |
| 213 | chose. 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 |
| 215 | nor that one wasn't, and the reading that sends one is unbounded mail to every |
| 216 | address already on the list. Confirmed means no write, no send, same page. |
| 217 | Changing one's stance after confirming is not built (Β§13). |
| 218 | |
| 219 | `GET /confirm` renders a page with a single Confirm button; `POST /confirm` |
| 220 | hashes the token, marks the row, and clears the hash. Ruling 15 exists because |
| 221 | link-scanning gateways fetch every URL in an arriving mail: confirm-on-GET |
| 222 | would let Outlook Safe Links confirm a row no human ever clicked, and then |
| 223 | show the human "this link is no longer valid." An expired, unknown, or burned |
| 224 | token renders one page, identical for all three. |
| 225 | |
| 226 | --- |
| 227 | |
| 228 | ## 5. Proof of work |
| 229 | |
| 230 | The client must find a counter such that |
| 231 | |
| 232 | ``` |
| 233 | SHA-256(nonce β ":" β lowercased-email β ":" β counter) |
| 234 | ``` |
| 235 | |
| 236 | has 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 |
| 239 | solved once and reused against every address in a list. Bound, the cost is one |
| 240 | solve 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 |
| 243 | commodity GPU; a determined attacker solves thousands of bound challenges a |
| 244 | second. PoW prices out casual and scripted abuse, and nothing more. The |
| 245 | defence against a serious bulk attack is the persisted mail budget in Β§9, not |
| 246 | this. Β§9 says so plainly so that nobody later mistakes the speed bump for the |
| 247 | wall. |
| 248 | |
| 249 | **Difficulty starts at 18 bits and is a tunable constant**, carried in the |
| 250 | seal. Β§12 requires it be re-set against real mid-range hardware before launch, |
| 251 | recording **p95 and p99, not the mean** β solve time is geometric, and p99 is |
| 252 | about 4.6Γ the mean, so calibrating on the average ships a form that hangs for |
| 253 | one visitor in a hundred. A raised difficulty does not apply to seals already |
| 254 | issued; that two-hour tail is accepted. |
| 255 | |
| 256 | **The solver runs in a Worker**, not on the main thread β 262k hashes in plain |
| 257 | JS 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 |
| 259 | disabled 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 |
| 264 | hand-written-`.mjs` convention rastrillo already uses. |
| 265 | |
| 266 | **No-JS visitors cannot sign up (ruling 2).** This session recommended a |
| 267 | fallback; Paul chose otherwise with the trade-off stated. The mechanism must |
| 268 | be the workable one: **the form renders with its submit button `disabled`, and |
| 269 | the module removes `disabled` once loaded.** JavaScript cannot enable a |
| 270 | control inside `<noscript>`, so an earlier phrasing that put the live control |
| 271 | there was unbuildable. This ordering also fails safe β a module that throws |
| 272 | leaves the button disabled rather than live and inert. |
| 273 | |
| 274 | **And the page does not say why.** An earlier draft of this section required |
| 275 | an explanation in `<noscript>`, on the grounds that a visitor who cannot join |
| 276 | should at least hear it from us. Paul removed it on 2026-09-02 after copy |
| 277 | review β proof of work is our problem, not theirs, and a paragraph about bots |
| 278 | and arithmetic is noise on a page whose whole job is asking someone a question |
| 279 | about themselves. The honest consequence: a no-JS visitor meets a disabled |
| 280 | button and no reason. It is a dead end rather than a deception, which is the |
| 281 | line 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 |
| 289 | it hashes with its own label; hex is this app's convention, not a framework |
| 290 | requirement. The challenge HMAC key is **derived from it with a distinct |
| 291 | label** rather than used raw, mirroring auth's own domain separation, so that |
| 292 | two subsystems sharing one secret cannot be made to verify each other's |
| 293 | tokens. |
| 294 | |
| 295 | It does **not** derive confirmation tokens β those are 32 random bytes, stored |
| 296 | only as a SHA-256, following the `auth_links` pattern. An earlier draft said |
| 297 | both, and key-derived tokens die on any key rotation. |
| 298 | |
| 299 | **The PoW preimage lowercases ASCII only.** JavaScript's `toLowerCase` and |
| 300 | Go's `strings.ToLower` disagree on some Unicode, and a disagreement here is a |
| 301 | silent `pow_short` that no visitor can diagnose and no log explains. |
| 302 | |
| 303 | **The seal** is `HMAC-SHA256(key, nonce β issuedAt β difficulty)`, a hidden |
| 304 | field. Rejected on mismatch, on an age under **3 seconds**, or over **2 |
| 305 | hours**. The floor is a claim that nobody reads three positions, picks one and |
| 306 | writes about themselves in less time. It is only meaningful because ruling 13 |
| 307 | makes 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 |
| 310 | issues a new token and a new expiry; the old hash is replaced and the old link |
| 311 | is dead. |
| 312 | |
| 313 | --- |
| 314 | |
| 315 | ## 7. Data |
| 316 | |
| 317 | ```go |
| 318 | type 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 | |
| 333 | type 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 | |
| 338 | type 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 |
| 345 | are copy and copy changes; a column that changes with them is a migration per |
| 346 | wording tweak and a corrupted history for rows already stored. |
| 347 | |
| 348 | There is no `User` model and no ownership column, because there is no owned |
| 349 | resource. Worth naming: rastrillo's most expensive trap is `sessions.UserID` |
| 350 | returning `(0, false)` under the `auth` plugin, where the session subject is a |
| 351 | verified address rather than a numeric id. This app never calls it. The admin |
| 352 | view is gated on the way in and reads the whole table β correct here, a severe |
| 353 | bug in an app with tenants. |
| 354 | |
| 355 | Migrations are additive-only, generated with `rastrillo migration generate`; |
| 356 | `BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)`, in that |
| 357 | order, because auth's backfill reads the sessions table. |
| 358 | |
| 359 | --- |
| 360 | |
| 361 | ## 8. Input bounds |
| 362 | |
| 363 | Unbounded free text reaches three dangerous places: an SMTP header, a CSV cell, |
| 364 | and 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 | |
| 382 | Two budgets, both **persisted**, because the in-memory limiter resets on every |
| 383 | hibernation, restart and deploy β and this process hibernates as its normal |
| 384 | state. |
| 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 |
| 395 | excludes the budget columns from its `.Select` allowlist (Β§4 step 5), and the |
| 396 | sweep age is longer than the per-address window (Β§10) β otherwise an attacker |
| 397 | re-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 |
| 401 | the signup transaction. A read-then-write across two statements is a lost |
| 402 | update waiting for concurrency, even behind a single writer. Fixed hour buckets |
| 403 | let a burst straddle a boundary and send up to twice the ceiling in one rolling |
| 404 | hour; at these numbers that is acceptable and it is written down rather than |
| 405 | discovered. |
| 406 | |
| 407 | **Admin sign-in mail draws on the same global budget** (ruling 23). It is sent |
| 408 | by `rastrillo/auth` rather than by our own handler, so the `mail.Sender` handed |
| 409 | to `auth.New` is wrapped to pass through the counter. A budget that counts only |
| 410 | the mail we remember sending is not a budget. Three things the wrapper must |
| 411 | get right, because `auth.Begin` calls `Send` synchronously and with no deadline |
| 412 | of 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 | |
| 421 | One residual, accepted: signin's own per-address limiter can answer `?err=rate`, |
| 422 | and only an allowlisted address ever reaches it. That is a narrow oracle for |
| 423 | admin 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 |
| 425 | it. |
| 426 | |
| 427 | **The budget counts attempts, not successes.** Three failed sends during SES's |
| 428 | post-`enable` propagation window will lock an honest address out for 24 hours, |
| 429 | with re-submission (Β§10) unavailable until it clears. Conservative on purpose, |
| 430 | and stated so it is a choice rather than a surprise. |
| 431 | |
| 432 | The in-memory per-address limiter from `password/limit.go`'s shape stays as a |
| 433 | cheap 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 |
| 435 | consulted first, it would let a stranger trip a victim's budget for free and |
| 436 | lock them out of signing up; behind the solve, tripping someone else's costs |
| 437 | real work. |
| 438 | |
| 439 | --- |
| 440 | |
| 441 | ## 10. Errors and logging |
| 442 | |
| 443 | Every rejected submission renders the same page with the same status. The |
| 444 | server 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 |
| 449 | re-submitting is the recovery path. This is not hypothetical: Β§11 notes that |
| 450 | freshly 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 |
| 456 | write timeout by default. So a hung SES connection would hold the handler |
| 457 | open indefinitely β and, if the send sat inside the write transaction, would |
| 458 | hold the single writer connection with it and stall every other signup and |
| 459 | the scheduled tick. The send therefore happens **after commit**, in a |
| 460 | goroutine with its own deadline. A sender that never returns must not be able |
| 461 | to block a second submission, and that is a test. |
| 462 | |
| 463 | That deadline must be **well under the platform's idle window** β seconds |
| 464 | against minutes β because a goroutine outliving the response is exactly the |
| 465 | shape Β§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 |
| 467 | re-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 |
| 470 | rejected visibly, with an error naming the field. That leaks nothing: it is a |
| 471 | property of the string the visitor just typed, not of any address's state on |
| 472 | the list. Everything else β an expired tab, a sub-3s submit, a spent nonce β |
| 473 | renders the same page, so an honest visitor with a typo is the one case that |
| 474 | gets told what went wrong rather than being quietly dropped. |
| 475 | |
| 476 | The honeypot answers **success** (ruling 9) and writes nothing. A crawler that |
| 477 | gets an error learns which field to leave alone; one that gets a confirmation |
| 478 | page learns nothing. |
| 479 | |
| 480 | **All app logging goes through one internal package** that accepts typed reason |
| 481 | codes and **no `error` values and no strings drawn from request data**. The |
| 482 | guard test forbids any other `slog` import in the app. "No call site takes both |
| 483 | an address and a stance" is not mechanically checkable over arbitrary Go β |
| 484 | wrappers, structs, a `%v` of a `Signup` β so the boundary is enforced by making |
| 485 | the sensitive pair unreachable from a log call rather than by pattern-matching |
| 486 | for it. |
| 487 | |
| 488 | Excluding `error` is not fussiness: `rastrillo/mail` formats its failures as |
| 489 | `send to <address>`, so logging a send error verbatim would print the address |
| 490 | beside a request path that carries the stance β the sensitive pair, assembled |
| 491 | by accident. SMTP failures are classified to a reason code and the error |
| 492 | dropped. A test drives a failing sender with a known address and asserts it |
| 493 | never appears in captured output. |
| 494 | |
| 495 | One accepted exception: `rastrillo/auth` logs through its own logger, and the |
| 496 | addresses it sees are admins signing in. Those are never paired with a stance, |
| 497 | so they stay. |
| 498 | |
| 499 | Templates render into a buffer before anything is written, so a template error |
| 500 | becomes 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`, |
| 507 | the same interface `carlos email enable` feeds through `CARLOS_SMTP_*` and |
| 508 | `mail.FromEnv("CARLOS", logger)`. No adapter, no third-party provider, no AWS |
| 509 | key in the app. |
| 510 | |
| 511 | **`POST /signin` is ours, not the framework's (ruling 23).** `auth.Begin` |
| 512 | classifies the address and sends the mail *before* `admit` ever consults |
| 513 | `Authorize` β so mounting it directly makes this app a mailer that anyone can |
| 514 | point at any address, from our verified sending identity, at roughly twenty |
| 515 | messages per fifteen minutes. The IP half of the framework's own budget |
| 516 | collapses to the edge's address behind the platform, so it is not the brake it |
| 517 | looks like. `sheets` hit this in production and fixed it the same way: an |
| 518 | app-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 |
| 524 | TTL 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 |
| 526 | hours via `auth.Config.SessionTTL` β not `RequireFreshSession`, which is a |
| 527 | step-up mechanism for sensitive routes layered on a longer session, not a way |
| 528 | to shorten one. A surface listing named people's political positions does not |
| 529 | want a month-long cookie. |
| 530 | |
| 531 | **How removal actually takes effect.** `MOVEMENT_ADMINS` is read once at boot. |
| 532 | A process's environment is fixed at exec β re-reading `os.Getenv` per request |
| 533 | sees nothing new, and an earlier draft of this section claimed otherwise. What |
| 534 | makes removal fast is the platform: `carlos env set` convergence re-renders and |
| 535 | **bounces** socket-exec instances within a few seconds, and hibernation |
| 536 | respawns the process on the next wake anyway. The per-request allowlist check |
| 537 | is what then invalidates an existing session, rather than leaving it live until |
| 538 | its 8 hours are up. |
| 539 | |
| 540 | Gate 10 must therefore model a restart. A test that calls `t.Setenv` mid-run |
| 541 | would 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 |
| 545 | stance β the pair Β§7 exists to protect β and showing it to satisfy curiosity |
| 546 | about the funnel is the wrong trade. `/admin/export.csv` is confirmed rows, |
| 547 | escaped per Β§8, and that restriction is a test, not a convention. |
| 548 | |
| 549 | Two platform notes, both of which have bitten people: freshly minted SMTP |
| 550 | credentials fail for the first minute or two while SES propagates, and a `535` |
| 551 | in that window is not a real failure; and DKIM is the only working alignment |
| 552 | rail, since SPF's envelope sender is a region subdomain that strict alignment |
| 553 | reads as a different domain. DKIM signing is never turned off. |
| 554 | |
| 555 | --- |
| 556 | |
| 557 | ## 11a. The sweep |
| 558 | |
| 559 | One 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 | |
| 564 | It runs **synchronously inside the request** β a 202 and a goroutine risks the |
| 565 | instance hibernating mid-job β and does three things: |
| 566 | |
| 567 | 1. 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. |
| 574 | 2. Deletes `spent_nonces` past `ExpiresAt`. |
| 575 | 3. Calls `auth.Sweep(now)` for expired magic links and sessions, which nothing |
| 576 | else in the app does. |
| 577 | |
| 578 | All three are idempotent, so at-least-once delivery needs no dedupe and |
| 579 | `carlos.TickOccurrence` is not used. Confirmed rows are never touched, and |
| 580 | neither is a pending row with a live token. |
| 581 | |
| 582 | A tick that 403s records `app refused` in the schedule's last status, but |
| 583 | nothing alerts on it and `next` still advances, so `carlos schedule ls` reads |
| 584 | much like success at a glance. The platform treats 4xx as "do not retry". Gate |
| 585 | 11 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 |
| 592 | drift β the scaffold's own targets, plus `version-check`, plus a |
| 593 | **`test-browser`** step. Gates 3 and 7 need a real browser (`-tags browser`, |
| 594 | chromedp), and a gate that no CI definition runs is not a gate. If the runner |
| 595 | cannot get Chromium, they stop being called gates and become a documented |
| 596 | manual step before each deploy. |
| 597 | |
| 598 | 1. **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. |
| 602 | 2. **Export excludes the unconfirmed**, and every cell is formula-escaped. |
| 603 | 3. **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. |
| 608 | 4. **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. |
| 611 | 5. **A confirmed row is immutable from `/signup`.** Re-submission with a |
| 612 | different stance changes nothing, does not un-confirm, and **sends no mail**. |
| 613 | 5a. **Budgets survive.** Re-submitting a pending row does not reset its mail |
| 614 | count, and neither does anything the sweep does. |
| 615 | 5b. **A non-allowlisted address posted to `/signin` sends no mail** and gets |
| 616 | the same redirect as one that does. |
| 617 | 5c. **A hung sender does not block a second submission**, and the send happens |
| 618 | after the transaction commits. |
| 619 | 6. **Seal bounds.** Forged seal, under 3s, over 2h β rejected identically. |
| 620 | 7. **Honeypot** returns the success page and writes no row. Plus a browser-rig |
| 621 | assertion that Chrome's autofill leaves the field empty. |
| 622 | 8. **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. |
| 625 | 9. **Admin fails closed.** With `MOVEMENT_ADMINS` empty, all seven admin |
| 626 | routes answer 404 β iterated, not spot-checked. |
| 627 | 10. **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. |
| 631 | 11. **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. |
| 634 | 12. **The sweep spares the confirmed** and anything with a live token. |
| 635 | 13. **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. |
| 638 | 14. **Logging containment.** No `slog` import outside the logging package, and |
| 639 | a failing sender's address never reaches captured output. |
| 640 | 14a. **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. |
| 643 | 15. **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 | |
| 659 | A thing deferred without a trigger is a thing forgotten. |
| 660 | |
| 661 | --- |
| 662 | |
| 663 | ## 14. Sequencing |
| 664 | |
| 665 | Two plans, split at the copy gate. |
| 666 | |
| 667 | **Plan 1 is copy-independent only because of ruling 26.** Every user-facing |
| 668 | string resolves through an `internal/copy` table keyed by ID, so plan 1 fills |
| 669 | that table with marked placeholders and never writes copy into a template. |
| 670 | Routes 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 |
| 674 | that same binary. A build-time refusal would make plan 1 impossible to review |
| 675 | on a canary, which the shipping cadence requires. So the check is at **boot, |
| 676 | against the origin**: with placeholders remaining, the app refuses to start |
| 677 | when `MOVEMENT_ORIGIN` is the canonical host, and starts with a visible banner |
| 678 | on a canary or loopback origin. Unreviewed words can be reviewed; they cannot |
| 679 | reach production. |
| 680 | |
| 681 | **Plan 1 β the engine.** Every gate in Β§12 except 15 green at its end. |
| 682 | |
| 683 | 1. `rastrillo new movement`, pins, `BuildVersion` stamping, the platform |
| 684 | contract. `/healthz` and `/api/version` come from `rastrillo.Serve` and are |
| 685 | never hand-rolled. |
| 686 | 2. Config, the three boot refusals (Β§3), and the logging package with its |
| 687 | containment test. |
| 688 | 3. `internal/copy` and its placeholder discipline (ruling 26). |
| 689 | 4. The seal, label-derived keys, and `spent_nonces` consumed on acceptance. |
| 690 | 5. The PoW verifier in Go, the Worker solver, and the browser-crossing test. |
| 691 | 6. Models, migrations, and the signup handler: every check, bound, and the one |
| 692 | transaction. |
| 693 | 7. Confirmation: GET renders, POST burns. |
| 694 | 8. `carlos email enable`, the wrapped mailer, both budgets, send-after-commit. |
| 695 | 9. Admin: app-owned `/signin`, allowlist, per-request re-check, list, escaped |
| 696 | CSV export. |
| 697 | 10. The sweep tick, mounted outside the CSRF router. |
| 698 | |
| 699 | **Plan 2 β the surface.** |
| 700 | |
| 701 | 11. Copy review, then the real strings into `internal/copy`, the stance slugs, |
| 702 | and the templates and static assets around them. |
| 703 | 12. Deploy to a canary, calibrate difficulty at p95/p99, record it. |
| 704 | 13. 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 | |