carlos / movement Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Movement
2
3A CARLOS recruitment app. One public signup surface, one confirmed list.
4
5This file is binding. Where it disagrees with a general skill, this file
6wins. Where it disagrees with the design spec, the spec wins and this file
7is 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
11alternative 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
20A row is inert until confirmed β€” never exported, never counted, never mailed
21again beyond its own confirmation. Proof of work and honeypots raise the cost
22of *submitting* an address; only a click from inside an inbox says the address
23is real and the person consented.
24
25The second half is the one that gets broken by accident. Every public response
26is identical whether an address is unknown, pending, or long confirmed. The
27moment someone adds a friendly "you're already signed up!" message, this app
28becomes a service for checking who has taken a public position on AI. Two
29tests stand in front of that, and a copy change that breaks them is the copy
30being wrong, not the tests.
31
32**We never claim the list is private from us.** The server reads every address
33and 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
40available before it is approved or merged.**
41
42Not the model that happened to write it, and not whichever model the session
43started on. Cheaper models draft; the most capable one available reviews. A
44review by a weaker model than the author is not a review.
45
46Mechanically:
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
56without saying how to re-derive it is how this rule goes stale silently. The
57check is: what is the most capable model this session can actually reach right
58now. If a session cannot reach one at least as capable as the author's, it says
59so and waits rather than passing a weaker review off as done.
60
61This 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`,
68and never in the primary checkout.
69
701. **Worktree per session.** One isolated workspace per piece of work.
712. **Branch per piece of work**, cut from current `main`.
723. **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.
744. **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
79Any one of the three missing means it does not merge.
80
81Review happens on a **deployed canary, never localhost**. A 200 is not a
82deploy verification. And a promote is not a deploy: until the process cycles,
83the old binary is still serving.
84
85**`X-Carlos-Version` is not proof either β€” `GET /api/version` is.** Learned
86here on 2026-09-02. The header is injected by the edge from the version it
87believes it promoted; `/api/version` is answered by the process actually
88running. They disagree exactly when it matters: after a promote the header
89flips immediately while the old binary keeps serving, and `carlos deploy`
90reports "live" on the strength of the header. The deploy that taught us this
91looked green and was serving code from the previous commit.
92
93So: verify with `curl -s https://<host>/api/version` and compare it to the sha
94you built. If it lags, `carlos restart --app <a>` and poll that endpoint until
95it 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
102gate.** Headlines, the three stance messages, button labels, error text, both
103emails. This app *is* its copy β€” the words are the mechanism that sorts people
104into buckets, not decoration on top of one.
105
106Templates carry **no literal user-facing text**. Every string resolves through
107`internal/copy`, keyed by ID, and unreviewed entries are marked placeholders.
108With any placeholder remaining the app **refuses to boot on the canonical
109origin**, and boots behind a banner on a canary or loopback. There is only one
110build β€” the canary serves the same binary production would β€” so the check is at
111boot against the origin, not at build time. That is what lets the whole engine
112be built and canary-reviewed before the words exist, while keeping unreviewed
113copy 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
246layout rules, its serving contract, and the seeded UX conventions. It is
247reference, not doctrine β€” **this file wins** where the two disagree, and the
248spec wins over both.
249
250**Rules are tests, not memory**
251
252If you find yourself relying on remembering one of these, make it a test
253instead.
254
255---
256
257## Authorship
258
259πŸ€– / πŸ‘¨ markers, `Co-Authored-By:` trailers, published prompt and carbon
260ledgers. AI is a guest with a name tag: opt-in, disclosed, and every path
261works with it switched off.
262