| 1 | # π€ Movement |
| 2 | |
| 3 | A CARLOS recruitment app. One public signup surface, one confirmed list. |
| 4 | |
| 5 | This file is binding. Where it disagrees with a general skill, this file |
| 6 | wins. Where it disagrees with the design spec, the spec wins and this file |
| 7 | is wrong β fix it in the same commit. |
| 8 | |
| 9 | **The design:** `docs/superpowers/specs/2026-09-01-movement-design.md`. Its |
| 10 | Β§1 is the settled-decisions table: dated, attributed, with the rejected |
| 11 | alternative beside each. Don't relitigate one in a drive-by. |
| 12 | |
| 13 | --- |
| 14 | |
| 15 | ## The one rule |
| 16 | |
| 17 | > **An address joins the list only by clicking a link in its own inbox, and |
| 18 | > the public surface never reveals which addresses have.** |
| 19 | |
| 20 | A row is inert until confirmed β never exported, never counted, never mailed |
| 21 | again beyond its own confirmation. Proof of work and honeypots raise the cost |
| 22 | of *submitting* an address; only a click from inside an inbox says the address |
| 23 | is real and the person consented. |
| 24 | |
| 25 | The second half is the one that gets broken by accident. Every public response |
| 26 | is identical whether an address is unknown, pending, or long confirmed. The |
| 27 | moment someone adds a friendly "you're already signed up!" message, this app |
| 28 | becomes a service for checking who has taken a public position on AI. Two |
| 29 | tests stand in front of that, and a copy change that breaks them is the copy |
| 30 | being wrong, not the tests. |
| 31 | |
| 32 | **We never claim the list is private from us.** The server reads every address |
| 33 | and holds its own key. The README says so and the UI never implies otherwise. |
| 34 | |
| 35 | --- |
| 36 | |
| 37 | ## Review β RULED 2026-09-01 by Paul |
| 38 | |
| 39 | **Every spec and every branch is reviewed by the highest-capability model |
| 40 | available before it is approved or merged.** |
| 41 | |
| 42 | Not the model that happened to write it, and not whichever model the session |
| 43 | started on. Cheaper models draft; the most capable one available reviews. A |
| 44 | review by a weaker model than the author is not a review. |
| 45 | |
| 46 | Mechanically: |
| 47 | |
| 48 | - **Specs** β a review pass before the spec is presented for human approval, |
| 49 | and again after any material change to a settled decision. |
| 50 | - **Branches** β a review pass before the PR is opened, and again after any |
| 51 | force-push that changes more than a rebase. |
| 52 | - **Record which model reviewed**, in the spec's status line or the branch |
| 53 | discussion. A review nobody can attribute is a review nobody can check. |
| 54 | |
| 55 | **The highest-capability model is discovered, not hardcoded.** Naming one here |
| 56 | without saying how to re-derive it is how this rule goes stale silently. The |
| 57 | check is: what is the most capable model this session can actually reach right |
| 58 | now. If a session cannot reach one at least as capable as the author's, it says |
| 59 | so and waits rather than passing a weaker review off as done. |
| 60 | |
| 61 | This is a floor, not a ceiling. |
| 62 | |
| 63 | --- |
| 64 | |
| 65 | ## Shipping cadence β RULED 2026-09-01 by Paul |
| 66 | |
| 67 | **Everything happens on a worktree, on a branch.** Never directly on `main`, |
| 68 | and never in the primary checkout. |
| 69 | |
| 70 | 1. **Worktree per session.** One isolated workspace per piece of work. |
| 71 | 2. **Branch per piece of work**, cut from current `main`. |
| 72 | 3. **Commit regularly and push to the branch.** Small commits that tell the |
| 73 | story, pushed as they land β not one heroic commit at the end. |
| 74 | 4. **Squash-merge to `main`** when, and only when, all three hold: |
| 75 | - the work is **done** β not "done except", |
| 76 | - it is **approved** β including the Β§Review pass, |
| 77 | - **CI is green** β actually green, checked, not assumed. |
| 78 | |
| 79 | Any one of the three missing means it does not merge. |
| 80 | |
| 81 | Review happens on a **deployed canary, never localhost**. A 200 is not a |
| 82 | deploy verification. And a promote is not a deploy: until the process cycles, |
| 83 | the old binary is still serving. |
| 84 | |
| 85 | **`X-Carlos-Version` is not proof either β `GET /api/version` is.** Learned |
| 86 | here on 2026-09-02. The header is injected by the edge from the version it |
| 87 | believes it promoted; `/api/version` is answered by the process actually |
| 88 | running. They disagree exactly when it matters: after a promote the header |
| 89 | flips immediately while the old binary keeps serving, and `carlos deploy` |
| 90 | reports "live" on the strength of the header. The deploy that taught us this |
| 91 | looked green and was serving code from the previous commit. |
| 92 | |
| 93 | So: verify with `curl -s https://<host>/api/version` and compare it to the sha |
| 94 | you built. If it lags, `carlos restart --app <a>` and poll that endpoint until |
| 95 | it flips. Never take the header, and never take `carlos deploy`'s own word. |
| 96 | |
| 97 | --- |
| 98 | |
| 99 | ## Copy is reviewed before it ships |
| 100 | |
| 101 | **No user-facing string enters a template without passing the copy-review |
| 102 | gate.** Headlines, the three stance messages, button labels, error text, both |
| 103 | emails. This app *is* its copy β the words are the mechanism that sorts people |
| 104 | into buckets, not decoration on top of one. |
| 105 | |
| 106 | Templates carry **no literal user-facing text**. Every string resolves through |
| 107 | `internal/copy`, keyed by ID, and unreviewed entries are marked placeholders. |
| 108 | With any placeholder remaining the app **refuses to boot on the canonical |
| 109 | origin**, and boots behind a banner on a canary or loopback. There is only one |
| 110 | build β the canary serves the same binary production would β so the check is at |
| 111 | boot against the origin, not at build time. That is what lets the whole engine |
| 112 | be built and canary-reviewed before the words exist, while keeping unreviewed |
| 113 | copy off production. |
| 114 | |
| 115 | --- |
| 116 | |
| 117 | ## Mechanical rules |
| 118 | |
| 119 | **Data** |
| 120 | |
| 121 | - Migrations are additive-only. New code over an old database is always safe. |
| 122 | Never delete data to update it, and never edit a shipped migration. The |
| 123 | 30-day sweep is a runtime deletion of rows nobody consented to keep, not a |
| 124 | migration β the two are different things and the rule above is about |
| 125 | migrations. |
| 126 | - **`/signup` never modifies a confirmed row.** A blanket upsert lets a |
| 127 | stranger un-confirm any address, or re-file a named person under a stance |
| 128 | they never chose. |
| 129 | - The challenge nonce is single-use β recorded as **spent on acceptance**, |
| 130 | never written when the challenge is minted. An unconsumed nonce turns one |
| 131 | solved challenge into two hours of repeat mail to a victim's inbox from our |
| 132 | own verified identity; a nonce written on every page view turns a crawler |
| 133 | into a denial of service against the single SQLite writer. Both are real and |
| 134 | the second is the one that looks harmless. |
| 135 | - The budget columns are excluded from the pending-row `.Select` allowlist. A |
| 136 | counter an attacker can reset by re-submitting is not a budget. |
| 137 | - Confirmation burns on **POST**, never on GET. Mail-scanning gateways fetch |
| 138 | every link in an arriving message; confirm-on-GET confirms people who never |
| 139 | clicked and leaves the human a dead link. |
| 140 | - `BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)`, in that |
| 141 | order β auth's backfill reads the sessions table. |
| 142 | - `Stance` stores an internal key, never the label a visitor read. Labels are |
| 143 | copy and copy changes. |
| 144 | - Never bind a request body onto a GORM model. Named fields via |
| 145 | `r.PostFormValue`, writes through an explicit `.Select` allowlist. |
| 146 | |
| 147 | **Logs** |
| 148 | |
| 149 | - Email and stance **together** are the sensitive pair. Neither reaches a log |
| 150 | line, error string, job status, or metric label. `carlos logs` is |
| 151 | platform-held storage that cannot be grepped clean afterwards. |
| 152 | - Rejected submissions log a reason code and no address. |
| 153 | |
| 154 | **Boot** |
| 155 | |
| 156 | - `MOVEMENT_ORIGIN` and `MOVEMENT_INSTANCE_KEY` are required; the app refuses |
| 157 | to start without either. A missing origin refuses loudly, a *wrong* one |
| 158 | boots happily and mails links to the wrong host β which is how a canary ends |
| 159 | up mailing people into production. |
| 160 | - Refuse to boot on `mail.FromEnv`'s `mail.Logged` fallback unless the origin |
| 161 | is loopback. `Logged` writes recipient, subject and body β magic links, |
| 162 | confirmation tokens, addresses β straight into `carlos logs`. |
| 163 | |
| 164 | **Identity** |
| 165 | |
| 166 | - Under the `auth` plugin the session subject is a verified **address**, not a |
| 167 | numeric id. `sessions.UserID` silently returns `(0, false)` β never call it. |
| 168 | Read the viewer with `auth.From(r)` or `sessions.Current(r)`. |
| 169 | - With `MOVEMENT_ADMINS` empty, all seven admin routes answer **404** β |
| 170 | `/signin` included. Fail closed, and indistinguishable from an app with no |
| 171 | admin at all. |
| 172 | - `Authorize` runs once, when a magic link is redeemed. Re-check the allowlist |
| 173 | on **every** admin request, or a removed admin keeps access for the life of |
| 174 | their session. The list itself is read at boot β a process's environment is |
| 175 | fixed at exec, so nothing re-reads it; `carlos env set` bounces the instance |
| 176 | within seconds, and the per-request check is what then kills the session. |
| 177 | - **Never mount `auth.Begin` directly at `POST /signin`.** It sends the mail |
| 178 | before `Authorize` ever runs, which makes this app a mailer anyone can point |
| 179 | at any address from our verified identity. Check the allowlist first and |
| 180 | answer the same redirect either way. |
| 181 | - Mail is sent **after** the write transaction commits, bounded by our own |
| 182 | deadline. `mail.Send` discards its context and `smtp.SendMail` dials with no |
| 183 | timeout, so a hung connection inside the transaction holds the single writer |
| 184 | and stalls every other signup. |
| 185 | |
| 186 | **CSRF** |
| 187 | |
| 188 | - `csrf.Protect(origin)` is mounted app-wide on the chi router, above the |
| 189 | route groups. |
| 190 | - **The scheduled-tick route is mounted outside it**, guarded by `carlos.Tick` |
| 191 | alone. `csrf.SameOrigin` refuses any POST carrying no `Sec-Fetch-Site`, |
| 192 | `Origin` or `Referer`, and the platform's scheduler sends none of the three. |
| 193 | Inside the protected router the tick 403s forever, and the platform reads |
| 194 | 4xx as "do not retry". It fails silently and permanently, so a test stands |
| 195 | in front of it. |
| 196 | |
| 197 | **Frontend** |
| 198 | |
| 199 | - Hand-written ES modules, no build step, no bundler, no CDN. Served through |
| 200 | `rastrillo.Assets` for content-hash fingerprinting. |
| 201 | - Run `rastrillo doctor --fix` after a framework upgrade; the scaffold's pin |
| 202 | test is the gate that catches a stale `static/tokens.css`. |
| 203 | - Signing up requires JavaScript (spec ruling 2). Render the submit button |
| 204 | `disabled` and let the module remove `disabled` once it loads. JavaScript |
| 205 | cannot enable a control *inside* `<noscript>`, and this ordering fails safe: |
| 206 | a module that throws leaves the button disabled rather than live and inert. |
| 207 | - **The page does not explain why it needs JavaScript.** Ruled by Paul, |
| 208 | 2026-09-02, after copy review: proof of work is our implementation problem, |
| 209 | not a visitor's, and a paragraph about bots and arithmetic is noise on a page |
| 210 | asking someone a question about themselves. A no-JS visitor meets a disabled |
| 211 | button and no explanation. That is a dead end, and an accepted one β but it |
| 212 | is the reason the button must never render enabled. |
| 213 | - The proof-of-work solver runs in a Worker. On the main thread it freezes a |
| 214 | phone for seconds. |
| 215 | |
| 216 | **Deploys** |
| 217 | |
| 218 | - `CGO_ENABLED=0`, cgo-free SQLite. A cgo driver sneaking in is a bug. |
| 219 | - Never import `github.com/glebarez/*` or `gorm.io/driver/sqlite` β both |
| 220 | collide with `rastrillo/gormlite`. |
| 221 | - Build `linux/arm64`. The wrong architecture is adopted silently and then |
| 222 | refused forever. |
| 223 | - Stamp `-ldflags -X github.com/carlosframework/rastrillo.BuildVersion=<sha>`, |
| 224 | or `/api/version` reports `dev` and the deploy watch is blind. |
| 225 | - Pinned: Go 1.25, rastrillo v0.23.0. The app is scaffolded with |
| 226 | `rastrillo new movement` β hand-writing the shape redoes the scaffold's work |
| 227 | and skips its pin test, CI files and browser harness. |
| 228 | - The process contract is `<bin> --socket <path> --db <path>`. There is no |
| 229 | `$PORT`. `/healthz` and `/api/version` must both answer. |
| 230 | - Blank `opts.DBPath` before `Serve` β the app opens its own handle, and a |
| 231 | second connection to a single-writer SQLite file deadlocks under traffic. |
| 232 | |
| 233 | **Running the gate** |
| 234 | |
| 235 | - `make ci` is the one definition: vet, fmt, test, migration check, browser. |
| 236 | - The browser step needs Chromium and **fails rather than skips** without it, |
| 237 | on purpose. Under Claude Code's default sandbox it cannot start β Chromium |
| 238 | wants a writable `~/.config/chromium` and a socket β so run `make test-browser` |
| 239 | with the sandbox disabled. That is an environment limit, not a code failure. |
| 240 | - The Go build cache is also read-only under the sandbox; point `GOCACHE` at a |
| 241 | writable directory. |
| 242 | |
| 243 | **The scaffold's own instructions** |
| 244 | |
| 245 | `docs/scaffold-AGENTS.md` is what `rastrillo new` wrote: the framework's |
| 246 | layout rules, its serving contract, and the seeded UX conventions. It is |
| 247 | reference, not doctrine β **this file wins** where the two disagree, and the |
| 248 | spec wins over both. |
| 249 | |
| 250 | **Rules are tests, not memory** |
| 251 | |
| 252 | If you find yourself relying on remembering one of these, make it a test |
| 253 | instead. |
| 254 | |
| 255 | --- |
| 256 | |
| 257 | ## Authorship |
| 258 | |
| 259 | π€ / π¨ markers, `Co-Authored-By:` trailers, published prompt and carbon |
| 260 | ledgers. AI is a guest with a name tag: opt-in, disclosed, and every path |
| 261 | works with it switched off. |
| 262 | |