# 🤖 Movement

A CARLOS recruitment app. One public signup surface, one confirmed list.

This file is binding. Where it disagrees with a general skill, this file
wins. Where it disagrees with the design spec, the spec wins and this file
is wrong — fix it in the same commit.

**The design:** `docs/superpowers/specs/2026-09-01-movement-design.md`. Its
§1 is the settled-decisions table: dated, attributed, with the rejected
alternative beside each. Don't relitigate one in a drive-by.

---

## The one rule

> **An address joins the list only by clicking a link in its own inbox, and
> the public surface never reveals which addresses have.**

A row is inert until confirmed — never exported, never counted, never mailed
again beyond its own confirmation. Proof of work and honeypots raise the cost
of *submitting* an address; only a click from inside an inbox says the address
is real and the person consented.

The second half is the one that gets broken by accident. Every public response
is identical whether an address is unknown, pending, or long confirmed. The
moment someone adds a friendly "you're already signed up!" message, this app
becomes a service for checking who has taken a public position on AI. Two
tests stand in front of that, and a copy change that breaks them is the copy
being wrong, not the tests.

**We never claim the list is private from us.** The server reads every address
and holds its own key. The README says so and the UI never implies otherwise.

---

## Review — RULED 2026-09-01 by Paul

**Every spec and every branch is reviewed by the highest-capability model
available before it is approved or merged.**

Not the model that happened to write it, and not whichever model the session
started on. Cheaper models draft; the most capable one available reviews. A
review by a weaker model than the author is not a review.

Mechanically:

- **Specs** — a review pass before the spec is presented for human approval,
  and again after any material change to a settled decision.
- **Branches** — a review pass before the PR is opened, and again after any
  force-push that changes more than a rebase.
- **Record which model reviewed**, in the spec's status line or the branch
  discussion. A review nobody can attribute is a review nobody can check.

**The highest-capability model is discovered, not hardcoded.** Naming one here
without saying how to re-derive it is how this rule goes stale silently. The
check is: what is the most capable model this session can actually reach right
now. If a session cannot reach one at least as capable as the author's, it says
so and waits rather than passing a weaker review off as done.

This is a floor, not a ceiling.

---

## Shipping cadence — RULED 2026-09-01 by Paul

**Everything happens on a worktree, on a branch.** Never directly on `main`,
and never in the primary checkout.

1. **Worktree per session.** One isolated workspace per piece of work.
2. **Branch per piece of work**, cut from current `main`.
3. **Commit regularly and push to the branch.** Small commits that tell the
   story, pushed as they land — not one heroic commit at the end.
4. **Squash-merge to `main`** when, and only when, all three hold:
   - the work is **done** — not "done except",
   - it is **approved** — including the §Review pass,
   - **CI is green** — actually green, checked, not assumed.

Any one of the three missing means it does not merge.

Review happens on a **deployed canary, never localhost**. A 200 is not a
deploy verification. And a promote is not a deploy: until the process cycles,
the old binary is still serving.

**`X-Carlos-Version` is not proof either — `GET /api/version` is.** Learned
here on 2026-09-02. The header is injected by the edge from the version it
believes it promoted; `/api/version` is answered by the process actually
running. They disagree exactly when it matters: after a promote the header
flips immediately while the old binary keeps serving, and `carlos deploy`
reports "live" on the strength of the header. The deploy that taught us this
looked green and was serving code from the previous commit.

So: verify with `curl -s https://<host>/api/version` and compare it to the sha
you built. If it lags, `carlos restart --app <a>` and poll that endpoint until
it flips. Never take the header, and never take `carlos deploy`'s own word.

---

## Copy is reviewed before it ships

**No user-facing string enters a template without passing the copy-review
gate.** Headlines, the three stance messages, button labels, error text, both
emails. This app *is* its copy — the words are the mechanism that sorts people
into buckets, not decoration on top of one.

Templates carry **no literal user-facing text**. Every string resolves through
`internal/copy`, keyed by ID, and unreviewed entries are marked placeholders.
With any placeholder remaining the app **refuses to boot on the canonical
origin**, and boots behind a banner on a canary or loopback. There is only one
build — the canary serves the same binary production would — so the check is at
boot against the origin, not at build time. That is what lets the whole engine
be built and canary-reviewed before the words exist, while keeping unreviewed
copy off production.

---

## Mechanical rules

**Data**

- Migrations are additive-only. New code over an old database is always safe.
  Never delete data to update it, and never edit a shipped migration. The
  30-day sweep is a runtime deletion of rows nobody consented to keep, not a
  migration — the two are different things and the rule above is about
  migrations.
- **`/signup` never modifies a confirmed row.** A blanket upsert lets a
  stranger un-confirm any address, or re-file a named person under a stance
  they never chose.
- The challenge nonce is single-use — recorded as **spent on acceptance**,
  never written when the challenge is minted. An unconsumed nonce turns one
  solved challenge into two hours of repeat mail to a victim's inbox from our
  own verified identity; a nonce written on every page view turns a crawler
  into a denial of service against the single SQLite writer. Both are real and
  the second is the one that looks harmless.
- The budget columns are excluded from the pending-row `.Select` allowlist. A
  counter an attacker can reset by re-submitting is not a budget.
- Confirmation burns on **POST**, never on GET. Mail-scanning gateways fetch
  every link in an arriving message; confirm-on-GET confirms people who never
  clicked and leaves the human a dead link.
- `BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)`, in that
  order — auth's backfill reads the sessions table.
- `Stance` stores an internal key, never the label a visitor read. Labels are
  copy and copy changes.
- Never bind a request body onto a GORM model. Named fields via
  `r.PostFormValue`, writes through an explicit `.Select` allowlist.

**Logs**

- Email and stance **together** are the sensitive pair. Neither reaches a log
  line, error string, job status, or metric label. `carlos logs` is
  platform-held storage that cannot be grepped clean afterwards.
- Rejected submissions log a reason code and no address.

**Boot**

- `MOVEMENT_ORIGIN` and `MOVEMENT_INSTANCE_KEY` are required; the app refuses
  to start without either. A missing origin refuses loudly, a *wrong* one
  boots happily and mails links to the wrong host — which is how a canary ends
  up mailing people into production.
- Refuse to boot on `mail.FromEnv`'s `mail.Logged` fallback unless the origin
  is loopback. `Logged` writes recipient, subject and body — magic links,
  confirmation tokens, addresses — straight into `carlos logs`.

**Identity**

- Under the `auth` plugin the session subject is a verified **address**, not a
  numeric id. `sessions.UserID` silently returns `(0, false)` — never call it.
  Read the viewer with `auth.From(r)` or `sessions.Current(r)`.
- With `MOVEMENT_ADMINS` empty, all seven admin routes answer **404** —
  `/signin` included. Fail closed, and indistinguishable from an app with no
  admin at all.
- `Authorize` runs once, when a magic link is redeemed. Re-check the allowlist
  on **every** admin request, or a removed admin keeps access for the life of
  their session. The list itself is read at boot — a process's environment is
  fixed at exec, so nothing re-reads it; `carlos env set` bounces the instance
  within seconds, and the per-request check is what then kills the session.
- **Never mount `auth.Begin` directly at `POST /signin`.** It sends the mail
  before `Authorize` ever runs, which makes this app a mailer anyone can point
  at any address from our verified identity. Check the allowlist first and
  answer the same redirect either way.
- Mail is sent **after** the write transaction commits, bounded by our own
  deadline. `mail.Send` discards its context and `smtp.SendMail` dials with no
  timeout, so a hung connection inside the transaction holds the single writer
  and stalls every other signup.

**CSRF**

- `csrf.Protect(origin)` is mounted app-wide on the chi router, above the
  route groups.
- **The scheduled-tick route is mounted outside it**, guarded by `carlos.Tick`
  alone. `csrf.SameOrigin` refuses any POST carrying no `Sec-Fetch-Site`,
  `Origin` or `Referer`, and the platform's scheduler sends none of the three.
  Inside the protected router the tick 403s forever, and the platform reads
  4xx as "do not retry". It fails silently and permanently, so a test stands
  in front of it.

**Frontend**

- Hand-written ES modules, no build step, no bundler, no CDN. Served through
  `rastrillo.Assets` for content-hash fingerprinting.
- Run `rastrillo doctor --fix` after a framework upgrade; the scaffold's pin
  test is the gate that catches a stale `static/tokens.css`.
- Signing up requires JavaScript (spec ruling 2). Render the submit button
  `disabled` and let the module remove `disabled` once it loads. JavaScript
  cannot enable a control *inside* `<noscript>`, and this ordering fails safe:
  a module that throws leaves the button disabled rather than live and inert.
- **The page does not explain why it needs JavaScript.** Ruled by Paul,
  2026-09-02, after copy review: proof of work is our implementation problem,
  not a visitor's, and a paragraph about bots and arithmetic is noise on a page
  asking someone a question about themselves. A no-JS visitor meets a disabled
  button and no explanation. That is a dead end, and an accepted one — but it
  is the reason the button must never render enabled.
- The proof-of-work solver runs in a Worker. On the main thread it freezes a
  phone for seconds.

**Deploys**

- `CGO_ENABLED=0`, cgo-free SQLite. A cgo driver sneaking in is a bug.
- Never import `github.com/glebarez/*` or `gorm.io/driver/sqlite` — both
  collide with `rastrillo/gormlite`.
- Build `linux/arm64`. The wrong architecture is adopted silently and then
  refused forever.
- Stamp `-ldflags -X github.com/carlosframework/rastrillo.BuildVersion=<sha>`,
  or `/api/version` reports `dev` and the deploy watch is blind.
- Pinned: Go 1.25, rastrillo v0.23.0. The app is scaffolded with
  `rastrillo new movement` — hand-writing the shape redoes the scaffold's work
  and skips its pin test, CI files and browser harness.
- The process contract is `<bin> --socket <path> --db <path>`. There is no
  `$PORT`. `/healthz` and `/api/version` must both answer.
- Blank `opts.DBPath` before `Serve` — the app opens its own handle, and a
  second connection to a single-writer SQLite file deadlocks under traffic.

**Running the gate**

- `make ci` is the one definition: vet, fmt, test, migration check, browser.
- The browser step needs Chromium and **fails rather than skips** without it,
  on purpose. Under Claude Code's default sandbox it cannot start — Chromium
  wants a writable `~/.config/chromium` and a socket — so run `make test-browser`
  with the sandbox disabled. That is an environment limit, not a code failure.
- The Go build cache is also read-only under the sandbox; point `GOCACHE` at a
  writable directory.

**The scaffold's own instructions**

`docs/scaffold-AGENTS.md` is what `rastrillo new` wrote: the framework's
layout rules, its serving contract, and the seeded UX conventions. It is
reference, not doctrine — **this file wins** where the two disagree, and the
spec wins over both.

**Rules are tests, not memory**

If you find yourself relying on remembering one of these, make it a test
instead.

---

## Authorship

🤖 / 👨 markers, `Co-Authored-By:` trailers, published prompt and carbon
ledgers. AI is a guest with a name tag: opt-in, disclosed, and every path
works with it switched off.
