carlos / movement Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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.