carlos / movement Public

Plan 1: the engine, in ten tasks

Every task is TDD with the test first and the gate named. The order is
dictated by the spec's sequencing: nothing that touches a visitor's words
happens here, because internal/copy holds them as placeholders and the app
refuses the canonical host while any remain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175aLTHqLrYkGe1skMV1rvq
Paul Campbell 260f960456298d6dd53914724b21be665ccac6fd parent b90d500
1 file changed, +1158 −0
  • docs/superpowers/plans/2026-09-01-movement-engine.md +1158 −0
diff --git a/docs/superpowers/plans/2026-09-01-movement-engine.md b/docs/superpowers/plans/2026-09-01-movement-engine.md
new file mode 100644
index 0000000..5049f02
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-01-movement-engine.md
@@ -0,0 +1,1158 @@
+# Movement Engine Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use
+> superpowers:subagent-driven-development (recommended) or
+> superpowers:executing-plans to implement this plan task-by-task. Steps use
+> checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build every mechanism of the Movement signup app — challenge sealing,
+proof of work, double opt-in, mail budgets, admin, and the sweep — with all
+user-facing words held as marked placeholders, so the whole engine is testable
+and canary-reviewable before copy review happens.
+
+**Architecture:** One Go binary on rastrillo. A public signup surface posts to a
+single handler that checks a honeypot, an HMAC-sealed challenge, input bounds
+and a proof of work bound to the submitted address, then does one write
+transaction (spend the nonce, upsert the signup, bump a mail counter) and
+sends a confirmation mail after commit. Confirmation is a POST. Admin is a
+magic link restricted to an allowlist. A scheduled tick sweeps.
+
+**Tech Stack:** Go 1.25, `github.com/carlosframework/rastrillo` v0.23.0,
+`github.com/go-chi/chi/v5`, GORM with `rastrillo/gormlite` (pure-Go
+`modernc.org/sqlite`), `html/template`, hand-written ES modules. No bundler, no
+CDN, `CGO_ENABLED=0`.
+
+**Spec:** `docs/superpowers/specs/2026-09-01-movement-design.md`
+
+**Framework pin:** rastrillo v0.23.0 — the version the sibling `sheets` runs in
+production, so its API shape is known-good rather than assumed. Go 1.25 to
+match. **Where rastrillo's own `SKILL.md` disagrees with this plan about an API,
+SKILL.md wins** — read it before Task 1 and treat any mismatch as this plan
+being stale, not as a reason to improvise.
+
+**Scope boundary:** This plan is the engine. It deliberately excludes real copy,
+the three stance slugs, the visual design of any page, difficulty calibration,
+and deployment to the canonical host. Those are plan 2, which runs after copy
+review. Every string this plan writes is a placeholder registered in
+`internal/copy`.
+
+---
+
+## Global Constraints
+
+Copied from the spec. Every task's requirements implicitly include these.
+
+- **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.
+- Every `/signup` outcome renders the same bytes. The accepting path is one
+ write transaction and at most one send; every rejecting path writes nothing.
+- `/signup` never writes `Confirmed`, `ConfirmedAt`, `Stance`, `Background`,
+ `TokenHash` or `TokenExpiry` on a confirmed row.
+- Email and stance together are the sensitive pair. Neither reaches a log line,
+ error string, job status or metric label. The logging package takes typed
+ reason codes and **no `error` values**.
+- `CGO_ENABLED=0`. Never import `github.com/glebarez/*` or
+ `gorm.io/driver/sqlite` — both collide with `rastrillo/gormlite`.
+- Migrations are additive-only; never edit a shipped migration. Add subsystems
+ to `BootSchema`, never `Schema`.
+- Never bind a request body onto a GORM model. Named fields via
+ `r.PostFormValue`; writes through an explicit `.Select` allowlist.
+- `csrf.Protect(origin)` is mounted app-wide on the chi router. The tick route
+ is mounted **outside** it.
+- Blank `opts.DBPath` before `rastrillo.Serve` — the app opens its own handle.
+- `/healthz` and `/api/version` come from `rastrillo.Serve`. Never hand-roll
+ them.
+- No literal user-facing text in any template. Every string resolves through
+ `internal/copy`.
+- Build `linux/arm64`, stamped with
+ `-ldflags "-X github.com/carlosframework/rastrillo.BuildVersion=$(git rev-parse --short HEAD)"`.
+
+---
+
+## File Structure
+
+```
+cmd/movement/main.go thin dispatch: Resolve -> db.Open -> App -> Serve
+internal/movement/
+ app.go migrate, sessions, auth, router assembly
+ config.go env loading and the boot refusals
+ logging.go the only slog user; typed reason codes
+ copy.go the copy table and placeholder discipline
+ seal.go challenge minting, HMAC seal, key derivation
+ pow.go proof-of-work verification (server side)
+ models.go Signup, SpentNonce, MailCounter
+ migrations.go Schema and BootSchema
+ signup.go POST /signup: checks, transaction, send
+ confirm.go GET renders, POST burns
+ mailer.go budget-wrapping mail.Sender, send-after-commit
+ budget.go per-address and global budget arithmetic
+ admin.go app-owned signin, allowlist middleware, list, CSV
+ sweep.go the tick handler
+ render.go template registry, buffered render
+ pages.go stance page handlers
+ templates/*.html markup only; every string via copy
+ static/pow.js the Worker solver (hand-written ES module)
+ static/sha256.js synchronous SHA-256 used by the Worker
+ migrations/*.sql
+internal/movementtest/ HTTP harness and the gates
+```
+
+**Rationale for the splits.** `signup.go` is the one place all four checks and
+the transaction meet, so everything it depends on — sealing, PoW, budgets — is
+a separate file with a testable surface, keeping the handler readable. `copy.go`
+and `logging.go` are single-purpose because both are enforced by guard tests
+that forbid their concerns appearing anywhere else. `admin.go` holds the
+allowlist in one place so the "seven routes 404" gate can iterate one list.
+
+---
+
+### Task 1: Scaffold, pins, and the platform contract
+
+**Files:**
+- Create: everything `rastrillo new movement` emits, then edit `go.mod`,
+ `Makefile`, `.amadan/ci.d/`
+- Test: the scaffold's own `internal/movementtest/`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: a booting binary; `movement.App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error)`.
+
+- [ ] **Step 1: Read the framework's own rules first**
+
+Read `/home/paulca/go/pkg/mod/github.com/carlosframework/rastrillo@v0.23.0/SKILL.md`
+in full. It is written to be read instead of the source. If anything below
+contradicts it, it wins.
+
+- [ ] **Step 2: Scaffold**
+
+```bash
+cd /home/paulca/amadan.net/carlos/movement/.claude/worktrees/design
+rastrillo new movement
+```
+
+This emits `cmd/`, `internal/movement/`, `internal/movementtest/`, `Makefile`,
+`.amadan/ci.d/`, `static/tokens.css`, a `vendored_test.go` pin test, and a
+`//go:build browser` chromedp test. Do not hand-write these.
+
+- [ ] **Step 3: Pin the versions**
+
+Set `go 1.25.0` in `go.mod`, the module path to `amadan.net/carlos/movement`,
+and `github.com/carlosframework/rastrillo v0.23.0`. Then:
+
+```bash
+go mod tidy && go build ./...
+```
+
+- [ ] **Step 4: Add the browser and version steps to the one CI definition**
+
+`make test` and `.amadan/ci.d/*` must stay one definition. Add a
+`test-browser` target running `go test -tags browser ./...`, and a
+`.amadan/ci.d/60-browser` that is exactly `exec make test-browser`. Keep the
+scaffold's `version-check`.
+
+If the runner cannot install Chromium, set `RASTRILLO_BROWSER_OPTIONAL=1` in
+that ci.d step and record in `AGENTS.md` that gates 3 and 7 are a manual
+pre-deploy step — the browser test **fails rather than skips** without a
+browser, so this must be a deliberate choice.
+
+- [ ] **Step 5: Stamp the build version**
+
+In the `Makefile`'s release target:
+
+```make
+release:
+ @git diff --quiet || (echo "dirty tree" && exit 1)
+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
+ -ldflags "-X github.com/carlosframework/rastrillo.BuildVersion=$$(git rev-parse --short HEAD)" \
+ -o movement-linux-arm64 ./cmd/movement
+```
+
+Without the stamp `/api/version` reports `dev` and the deploy watch is blind.
+
+- [ ] **Step 6: Run the gate**
+
+Run: `make test`
+Expected: PASS, including `migration check` and the pin test.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "Skeleton: the platform contract and a build that stamps itself"
+```
+
+---
+
+### Task 2: Config, the boot refusals, and the logging package
+
+**Files:**
+- Create: `internal/movement/config.go`, `internal/movement/logging.go`
+- Create: `internal/movement/config_test.go`, `internal/movement/logging_test.go`
+- Create: `internal/movementtest/guards_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces:
+ - `type Env struct { Origin, InstanceKey string; Admins []string }`
+ - `func LoadEnv() (Env, error)`
+ - `type Reason string` with constants `ReasonSealInvalid`, `ReasonNonceSpent`,
+ `ReasonTooFast`, `ReasonTooOld`, `ReasonPowShort`, `ReasonHoneypot`,
+ `ReasonBounds`, `ReasonMailFailed`, `ReasonMailBudget`
+ - `func Rejected(r Reason)`, `func Info(msg string)` — neither accepts an
+ `error` or any request-derived string.
+
+- [ ] **Step 1: Write the failing config test**
+
+```go
+func TestLoadEnvRefusesMissing(t *testing.T) {
+ t.Setenv("MOVEMENT_ORIGIN", "")
+ t.Setenv("MOVEMENT_INSTANCE_KEY", "")
+ if _, err := LoadEnv(); err == nil {
+ t.Fatal("expected refusal with no origin and no instance key")
+ }
+}
+
+func TestLoadEnvRefusesUnconfiguredMailOnPublicOrigin(t *testing.T) {
+ t.Setenv("MOVEMENT_ORIGIN", "https://movement.bdj.oncarlos.com")
+ t.Setenv("MOVEMENT_INSTANCE_KEY", strings.Repeat("a", 64))
+ t.Setenv("CARLOS_SMTP_HOST", "")
+ t.Setenv("CARLOS_SMTP_FROM", "")
+ if _, err := LoadEnv(); err == nil {
+ t.Fatal("expected refusal: mail.Logged would write tokens into carlos logs")
+ }
+}
+
+func TestLoadEnvAllowsUnconfiguredMailOnLoopback(t *testing.T) {
+ t.Setenv("MOVEMENT_ORIGIN", "http://127.0.0.1:8080")
+ t.Setenv("MOVEMENT_INSTANCE_KEY", strings.Repeat("a", 64))
+ t.Setenv("CARLOS_SMTP_HOST", "")
+ t.Setenv("CARLOS_SMTP_FROM", "")
+ if _, err := LoadEnv(); err != nil {
+ t.Fatalf("loopback should warn, not refuse: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `go test ./internal/movement/ -run TestLoadEnv -v`
+Expected: FAIL, `undefined: LoadEnv`.
+
+- [ ] **Step 3: Implement config.go**
+
+```go
+// Package movement ... (keep the scaffold's doc comment)
+
+// loopback reports whether origin is a local development origin. The mail
+// refusal below is relaxed only here, and the check is deliberately
+// conservative: an instance claiming a loopback origin in production is a
+// misconfiguration we would rather see loudly than quietly excuse.
+func loopback(origin string) bool {
+ u, err := url.Parse(origin)
+ if err != nil {
+ return false
+ }
+ h := u.Hostname()
+ return h == "localhost" || h == "127.0.0.1" || h == "::1"
+}
+
+type Env struct {
+ Origin string
+ InstanceKey string
+ Admins []string
+}
+
+func LoadEnv() (Env, error) {
+ env := Env{
+ Origin: strings.TrimSpace(os.Getenv("MOVEMENT_ORIGIN")),
+ InstanceKey: strings.TrimSpace(os.Getenv("MOVEMENT_INSTANCE_KEY")),
+ }
+ for _, a := range strings.Split(os.Getenv("MOVEMENT_ADMINS"), ",") {
+ if a = strings.ToLower(strings.TrimSpace(a)); a != "" {
+ env.Admins = append(env.Admins, a)
+ }
+ }
+
+ var missing []string
+ if env.Origin == "" {
+ missing = append(missing, "MOVEMENT_ORIGIN")
+ }
+ if env.InstanceKey == "" {
+ missing = append(missing, "MOVEMENT_INSTANCE_KEY")
+ }
+ if len(missing) > 0 {
+ return Env{}, fmt.Errorf("movement: set %s", strings.Join(missing, ", "))
+ }
+
+ // mail.FromEnv falls back to mail.Logged when SMTP is unset, and Logged
+ // writes recipient, subject and body -- the magic link and the
+ // confirmation token -- into carlos logs, which cannot be grepped clean
+ // afterwards. loggedSender is unexported, so the fallback cannot be
+ // detected from the returned value; check the environment instead.
+ if os.Getenv("CARLOS_SMTP_HOST") == "" || os.Getenv("CARLOS_SMTP_FROM") == "" {
+ if !loopback(env.Origin) {
+ return Env{}, errors.New("movement: no SMTP configured; run carlos email enable")
+ }
+ }
+ return env, nil
+}
+```
+
+- [ ] **Step 4: Run the config tests**
+
+Run: `go test ./internal/movement/ -run TestLoadEnv -v`
+Expected: PASS.
+
+- [ ] **Step 5: Write the failing logging containment guard**
+
+```go
+// internal/movementtest/guards_test.go
+func TestOnlyLoggingPackageImportsSlog(t *testing.T) {
+ root := "../movement"
+ err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
+ return err
+ }
+ if filepath.Base(path) == "logging.go" {
+ return nil
+ }
+ src, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if bytes.Contains(src, []byte(`"log/slog"`)) {
+ t.Errorf("%s imports log/slog; route it through logging.go", path)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+```
+
+- [ ] **Step 6: Run it and watch it fail**
+
+Run: `go test ./internal/movementtest/ -run TestOnlyLogging -v`
+Expected: FAIL — the scaffold's `app.go` imports slog.
+
+- [ ] **Step 7: Implement logging.go and route existing uses through it**
+
+```go
+// Reason is a fixed vocabulary. It exists so that a log line cannot carry a
+// request-derived string: email and stance together are the sensitive pair,
+// and rastrillo/mail formats its own errors as "send to <address>", so
+// logging an error verbatim would assemble that pair by accident.
+type Reason string
+
+const (
+ ReasonSealInvalid Reason = "seal_invalid"
+ ReasonNonceSpent Reason = "nonce_spent"
+ ReasonTooFast Reason = "too_fast"
+ ReasonTooOld Reason = "too_old"
+ ReasonPowShort Reason = "pow_short"
+ ReasonHoneypot Reason = "honeypot"
+ ReasonBounds Reason = "bounds"
+ ReasonMailFailed Reason = "mail_failed"
+ ReasonMailBudget Reason = "mail_budget"
+)
+
+var logger = slog.Default()
+
+func SetLogger(l *slog.Logger) { logger = l }
+
+// Rejected records why a submission was refused. It takes no address, no
+// stance, and no error.
+func Rejected(r Reason) { logger.Info("signup rejected", "reason", string(r)) }
+
+func Info(msg string) { logger.Info(msg) }
+```
+
+- [ ] **Step 8: Run the whole gate**
+
+Run: `make test`
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add -A
+git commit -m "Config that refuses to boot, and logging that cannot leak the pair"
+```
+
+---
+
+### Task 3: The copy table and its placeholder discipline
+
+**Files:**
+- Create: `internal/movement/copy.go`, `internal/movement/copy_test.go`
+- Modify: `internal/movementtest/guards_test.go`
+
+**Interfaces:**
+- Consumes: `Env` from Task 2.
+- Produces:
+ - `func T(id string) string` — resolves a copy ID, panicking on an unknown one
+ - `func Placeholders() []string` — IDs still unreviewed
+ - `func CheckPlaceholders(origin string, canonicalHost string) error`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestPlaceholdersBlockCanonicalOriginOnly(t *testing.T) {
+ canonical := "movement.bdj.oncarlos.com"
+
+ err := CheckPlaceholders("https://movement.bdj.oncarlos.com", canonical)
+ if err == nil {
+ t.Fatal("placeholders must refuse to boot on the canonical host")
+ }
+
+ // A canary serves the same binary and must boot, or the branch cannot be
+ // reviewed on a canary as the shipping cadence requires.
+ if err := CheckPlaceholders("https://c1.movement.bdj.oncarlos.com", canonical); err != nil {
+ t.Fatalf("a canary must boot with placeholders: %v", err)
+ }
+ if err := CheckPlaceholders("http://127.0.0.1:8080", canonical); err != nil {
+ t.Fatalf("loopback must boot with placeholders: %v", err)
+ }
+}
+
+func TestUnknownCopyIDPanics(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("an unknown copy ID must panic, not render empty")
+ }
+ }()
+ T("no.such.id")
+}
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `go test ./internal/movement/ -run 'TestPlaceholders|TestUnknownCopy' -v`
+Expected: FAIL, `undefined: CheckPlaceholders`.
+
+- [ ] **Step 3: Implement copy.go**
+
+```go
+type entry struct {
+ text string
+ placeholder bool
+}
+
+// table holds every user-facing string. Plan 2 replaces these with reviewed
+// copy and clears the placeholder flag; until then the app will not boot on
+// the canonical host.
+var table = map[string]entry{
+ "index.headline": {"PLACEHOLDER headline", true},
+ "index.lede": {"PLACEHOLDER lede", true},
+ "stance.a.label": {"PLACEHOLDER stance A", true},
+ "stance.b.label": {"PLACEHOLDER stance B", true},
+ "stance.c.label": {"PLACEHOLDER stance C", true},
+ "stance.a.message": {"PLACEHOLDER message A", true},
+ "stance.b.message": {"PLACEHOLDER message B", true},
+ "stance.c.message": {"PLACEHOLDER message C", true},
+ "form.email": {"PLACEHOLDER email label", true},
+ "form.background": {"PLACEHOLDER background label", true},
+ "form.submit": {"PLACEHOLDER submit", true},
+ "form.nojs": {"PLACEHOLDER no-javascript explanation", true},
+ "form.solving": {"PLACEHOLDER working", true},
+ "form.bademail": {"PLACEHOLDER that address does not look right", true},
+ "sent.title": {"PLACEHOLDER check your inbox", true},
+ "sent.body": {"PLACEHOLDER check your inbox body", true},
+ "confirm.title": {"PLACEHOLDER confirm", true},
+ "confirm.button": {"PLACEHOLDER confirm button", true},
+ "confirm.done": {"PLACEHOLDER you are on the list", true},
+ "confirm.dead": {"PLACEHOLDER that link is no longer valid", true},
+ "mail.confirm.subject": {"PLACEHOLDER confirm subject", true},
+ "mail.confirm.body": {"PLACEHOLDER confirm body with %s", true},
+ "signin.title": {"PLACEHOLDER sign in", true},
+ "signin.sent": {"PLACEHOLDER check your inbox for a link", true},
+ "admin.title": {"PLACEHOLDER admin", true},
+ "banner.placeholder": {"This build carries unreviewed placeholder copy.", false},
+}
+
+// T resolves a copy ID. It panics on an unknown one rather than rendering
+// empty: a missing string should fail in a test, not ship as a blank page.
+func T(id string) string {
+ e, ok := table[id]
+ if !ok {
+ panic("movement: unknown copy id " + id)
+ }
+ return e.text
+}
+
+func Placeholders() []string {
+ var ids []string
+ for id, e := range table {
+ if e.placeholder {
+ ids = append(ids, id)
+ }
+ }
+ sort.Strings(ids)
+ return ids
+}
+
+// CheckPlaceholders refuses the canonical host while any copy is unreviewed.
+// The check is at boot against the origin, not at build time, because there is
+// only one build: the canary serves the same binary production would, and a
+// build-time refusal would make canary review impossible.
+func CheckPlaceholders(origin, canonicalHost string) error {
+ if len(Placeholders()) == 0 {
+ return nil
+ }
+ u, err := url.Parse(origin)
+ if err != nil {
+ return fmt.Errorf("movement: unparseable origin")
+ }
+ if u.Hostname() == canonicalHost {
+ return fmt.Errorf("movement: %d unreviewed copy strings; not booting on %s",
+ len(Placeholders()), canonicalHost)
+ }
+ return nil
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `go test ./internal/movement/ -run 'TestPlaceholders|TestUnknownCopy' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Add the no-literal-copy guard**
+
+Append to `internal/movementtest/guards_test.go`: walk
+`internal/movement/templates/*.html`, strip `{{...}}` actions and HTML tags, and
+fail if any run of 3+ consecutive alphabetic words remains. Markup and
+attribute values are fine; sentences are not.
+
+- [ ] **Step 6: Run it**
+
+Run: `go test ./internal/movementtest/ -v`
+Expected: PASS (no templates yet — it will start biting in Task 6).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "Copy lives in a table, and placeholders never reach production"
+```
+
+---
+
+### Task 4: The seal, derived keys, and spent nonces
+
+**Files:**
+- Create: `internal/movement/seal.go`, `internal/movement/seal_test.go`
+- Create: `internal/movement/models.go` (the `SpentNonce` part)
+
+**Interfaces:**
+- Consumes: `Env.InstanceKey`.
+- Produces:
+ - `type Challenge struct { Nonce string; IssuedAt int64; Difficulty int; Seal string }`
+ - `func NewChallenge(key []byte, now time.Time, difficulty int) Challenge`
+ - `func VerifySeal(key []byte, c Challenge, now time.Time) (Reason, bool)`
+ - `func challengeKey(instanceKey string) []byte`
+ - `const MinAge = 3 * time.Second`, `const MaxAge = 2 * time.Hour`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestSealRoundTripAndBounds(t *testing.T) {
+ key := challengeKey(strings.Repeat("a", 64))
+ now := time.Now()
+ c := NewChallenge(key, now, 18)
+
+ if _, ok := VerifySeal(key, c, now.Add(10*time.Second)); !ok {
+ t.Fatal("a fresh seal must verify")
+ }
+ if r, _ := VerifySeal(key, c, now.Add(time.Second)); r != ReasonTooFast {
+ t.Fatalf("under 3s must be too_fast, got %q", r)
+ }
+ if r, _ := VerifySeal(key, c, now.Add(3*time.Hour)); r != ReasonTooOld {
+ t.Fatalf("over 2h must be too_old, got %q", r)
+ }
+
+ forged := c
+ forged.Difficulty = 1 // the seal covers difficulty; lowering it must fail
+ if r, _ := VerifySeal(key, forged, now.Add(10*time.Second)); r != ReasonSealInvalid {
+ t.Fatalf("a tampered difficulty must be seal_invalid, got %q", r)
+ }
+}
+
+func TestChallengeKeyIsDomainSeparated(t *testing.T) {
+ // The instance key is shared with rastrillo/auth. Deriving with a label
+ // stops a token from one subsystem verifying in the other.
+ if bytes.Equal(challengeKey("abc"), []byte("abc")) {
+ t.Fatal("the challenge key must be derived, not the raw instance key")
+ }
+}
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `go test ./internal/movement/ -run 'TestSeal|TestChallengeKey' -v`
+Expected: FAIL, `undefined: challengeKey`.
+
+- [ ] **Step 3: Implement seal.go**
+
+```go
+const (
+ MinAge = 3 * time.Second
+ MaxAge = 2 * time.Hour
+)
+
+// challengeKey derives the HMAC key for challenge seals from the instance key
+// with a label, mirroring auth's own domain separation. Two subsystems sharing
+// one secret must not be able to verify each other's tokens.
+func challengeKey(instanceKey string) []byte {
+ sum := sha256.Sum256([]byte("movement/challenge\x00" + instanceKey))
+ return sum[:]
+}
+
+type Challenge struct {
+ Nonce string
+ IssuedAt int64
+ Difficulty int
+ Seal string
+}
+
+func sealOf(key []byte, nonce string, issuedAt int64, difficulty int) string {
+ m := hmac.New(sha256.New, key)
+ fmt.Fprintf(m, "%s\x00%d\x00%d", nonce, issuedAt, difficulty)
+ return hex.EncodeToString(m.Sum(nil))
+}
+
+func NewChallenge(key []byte, now time.Time, difficulty int) Challenge {
+ var raw [16]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ panic("movement: out of randomness")
+ }
+ nonce := hex.EncodeToString(raw[:])
+ issued := now.Unix()
+ return Challenge{nonce, issued, difficulty, sealOf(key, nonce, issued, difficulty)}
+}
+
+// VerifySeal returns the reason a challenge is unacceptable, and whether it is
+// acceptable. Order matters: the seal is checked before the age, because
+// IssuedAt is attacker-controlled until the signature says otherwise.
+func VerifySeal(key []byte, c Challenge, now time.Time) (Reason, bool) {
+ want := sealOf(key, c.Nonce, c.IssuedAt, c.Difficulty)
+ if !hmac.Equal([]byte(want), []byte(c.Seal)) {
+ return ReasonSealInvalid, false
+ }
+ age := now.Sub(time.Unix(c.IssuedAt, 0))
+ switch {
+ case age < MinAge:
+ return ReasonTooFast, false
+ case age > MaxAge:
+ return ReasonTooOld, false
+ }
+ return "", true
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `go test ./internal/movement/ -run 'TestSeal|TestChallengeKey' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Add the SpentNonce model and migration**
+
+```go
+// models.go
+// SpentNonce records a nonce that has been accepted. Nothing is written when a
+// challenge is minted: a row per page view would make a crawler into a
+// serialised-write attack on the single SQLite writer. Written on acceptance,
+// each row costs an attacker one proof-of-work solve.
+type SpentNonce struct {
+ Nonce string `gorm:"primaryKey"`
+ ExpiresAt time.Time `gorm:"index"`
+}
+```
+
+Run `rastrillo migration generate`, read the emitted SQL, and commit it.
+
+- [ ] **Step 6: Run the gate**
+
+Run: `make test`
+Expected: PASS, including `migration check`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "Sealed challenges, a derived key, and somewhere to spend a nonce"
+```
+
+---
+
+### Task 5: Proof of work, both sides, and the crossing test
+
+**Files:**
+- Create: `internal/movement/pow.go`, `internal/movement/pow_test.go`
+- Create: `internal/movement/static/sha256.js`, `internal/movement/static/pow.js`
+- Create: `internal/movementtest/browser_pow_test.go` (`//go:build browser`)
+
+**Interfaces:**
+- Consumes: `Challenge` from Task 4.
+- Produces:
+ - `func NormalizeEmail(s string) string` — ASCII-only lowercasing
+ - `func PowPreimage(nonce, email, counter string) string`
+ - `func VerifyPow(nonce, email, counter string, difficulty int) bool`
+ - `const DefaultDifficulty = 18`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestPowBindsToTheAddress(t *testing.T) {
+ const d = 8 // low, so the test can brute-force it quickly
+ nonce := "abc123"
+
+ counter := solveForTest(t, nonce, "alice@example.com", d)
+ if !VerifyPow(nonce, "alice@example.com", counter, d) {
+ t.Fatal("a valid solution must verify")
+ }
+ // Ruling 3: the whole point. One solve must not be reusable against
+ // another address, or a bulk attack costs an attacker one solve total.
+ if VerifyPow(nonce, "bob@example.com", counter, d) {
+ t.Fatal("a solution must not transfer to another address")
+ }
+ if VerifyPow(nonce, "alice@example.com", "0", d+8) {
+ t.Fatal("a short solution must be rejected")
+ }
+}
+
+func TestNormalizeEmailIsASCIIOnly(t *testing.T) {
+ // Go's strings.ToLower and JS toLowerCase disagree on some Unicode, and a
+ // disagreement here is a silent pow_short nobody can diagnose.
+ in := "İX@Example.COM"
+ if got := NormalizeEmail(in); got != "İx@example.com" {
+ t.Fatalf("ASCII-only lowering expected, got %q", got)
+ }
+}
+
+func solveForTest(t *testing.T, nonce, email string, d int) string {
+ t.Helper()
+ for i := 0; i < 1<<24; i++ {
+ c := strconv.Itoa(i)
+ if VerifyPow(nonce, email, c, d) {
+ return c
+ }
+ }
+ t.Fatal("no solution found")
+ return ""
+}
+```
+
+- [ ] **Step 2: Run it and watch it fail**
+
+Run: `go test ./internal/movement/ -run 'TestPow|TestNormalize' -v`
+Expected: FAIL, `undefined: VerifyPow`.
+
+- [ ] **Step 3: Implement pow.go**
+
+```go
+const DefaultDifficulty = 18
+
+// NormalizeEmail lowercases ASCII only. Unicode case folding differs between
+// Go and JavaScript, and the preimage must be byte-identical on both sides.
+func NormalizeEmail(s string) string {
+ b := []byte(strings.TrimSpace(s))
+ for i, c := range b {
+ if c >= 'A' && c <= 'Z' {
+ b[i] = c + 32
+ }
+ }
+ return string(b)
+}
+
+func PowPreimage(nonce, email, counter string) string {
+ return nonce + ":" + email + ":" + counter
+}
+
+func leadingZeroBits(sum []byte) int {
+ n := 0
+ for _, b := range sum {
+ if b == 0 {
+ n += 8
+ continue
+ }
+ n += bits.LeadingZeros8(b)
+ break
+ }
+ return n
+}
+
+func VerifyPow(nonce, email, counter string, difficulty int) bool {
+ sum := sha256.Sum256([]byte(PowPreimage(nonce, NormalizeEmail(email), counter)))
+ return leadingZeroBits(sum[:]) >= difficulty
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `go test ./internal/movement/ -run 'TestPow|TestNormalize' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Write the browser solver**
+
+`static/sha256.js` — a synchronous SHA-256 exporting `sha256Bytes(str)`.
+WebCrypto's `subtle.digest` is async per call and cannot be an inner loop.
+
+`static/pow.js` — a module that starts a Worker, and a worker body that loops:
+
+```js
+// pow.js — solves SHA-256(nonce ":" email ":" counter) for `difficulty`
+// leading zero bits. Runs in a Worker: 2^18 hashes on the main thread freezes
+// a phone for seconds.
+import { sha256Bytes } from "./sha256.js";
+
+export function solve(nonce, email, difficulty, onProgress) {
+ return new Promise((resolve) => {
+ const lower = email.trim().replace(/[A-Z]/g, (c) =>
+ String.fromCharCode(c.charCodeAt(0) + 32)); // ASCII only, matching Go
+ let i = 0;
+ const step = () => {
+ const deadline = performance.now() + 50;
+ while (performance.now() < deadline) {
+ const sum = sha256Bytes(nonce + ":" + lower + ":" + i);
+ if (leadingZeroBits(sum) >= difficulty) return resolve(String(i));
+ i++;
+ }
+ onProgress(i);
+ setTimeout(step, 0);
+ };
+ step();
+ });
+}
+```
+
+- [ ] **Step 6: Write the crossing test**
+
+```go
+//go:build browser
+
+// The gate that matters: the browser's solver against the Go verifier. A
+// Go-only test proves the check, not the scheme.
+func TestBrowserSolverSatisfiesGoVerifier(t *testing.T) { /* drive pow.js via chromedp, assert VerifyPow */ }
+```
+
+- [ ] **Step 7: Run both**
+
+Run: `make test && make test-browser`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add -A
+git commit -m "Proof of work bound to the address, proven across the wire"
+```
+
+---
+
+### Task 6: Models, migrations, and the signup handler
+
+**Files:**
+- Create: `internal/movement/signup.go`, `internal/movement/budget.go`,
+ `internal/movement/render.go`, `internal/movement/pages.go`,
+ `internal/movement/templates/*.html`
+- Modify: `internal/movement/models.go`, `internal/movement/migrations.go`
+- Create: `internal/movementtest/signup_test.go`
+
+**Interfaces:**
+- Consumes: `Challenge`, `VerifySeal`, `VerifyPow`, `T`, `Rejected`.
+- Produces: `func (a *app) handleSignup(w http.ResponseWriter, r *http.Request)`;
+ `type Signup`, `type MailCounter`.
+
+- [ ] **Step 1: Write the failing gates**
+
+Write, in `internal/movementtest/signup_test.go`: no-enumeration (unknown,
+pending and confirmed produce byte-identical responses); nonce replay rejected;
+confirmed row immutable and unmailed; honeypot returns success and writes
+nothing; a stance-page GET writes no row; bounds rejection; and that budget
+columns survive a pending overwrite.
+
+- [ ] **Step 2: Run and watch them fail**
+
+Run: `go test ./internal/movementtest/ -v`
+Expected: FAIL, no `/signup` route.
+
+- [ ] **Step 3: Add the models**
+
+```go
+type Signup struct {
+ ID int64
+ Email string `gorm:"uniqueIndex"`
+ Stance string
+ Background string
+ Confirmed bool
+ ConfirmedAt *time.Time
+ TokenHash []byte
+ TokenExpiry time.Time
+ LastMailedAt *time.Time
+ MailCount int
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type MailCounter struct {
+ HourBucket string `gorm:"primaryKey"` // UTC, "2006-01-02T15"
+ Count int
+}
+```
+
+`rastrillo migration generate`, read the SQL, commit it.
+
+- [ ] **Step 4: Implement the handler**
+
+Check order — honeypot first, because it is free and must not sit behind
+anything that writes; then seal, age, bounds, PoW; then the in-memory limiter
+(after PoW, so tripping a stranger's budget costs a solve); then one
+transaction: insert the nonce `ON CONFLICT DO NOTHING` and roll back if
+`RowsAffected == 0`, then the signup write, then the counter bump. Send after
+commit (Task 8).
+
+```go
+func (a *app) handleSignup(w http.ResponseWriter, r *http.Request) {
+ if r.PostFormValue("website") != "" { // the honeypot
+ Rejected(ReasonHoneypot)
+ a.renderSent(w, r) // success, and nothing written
+ return
+ }
+ // ... seal, age, bounds, pow, limiter — every failure calls Rejected and
+ // then a.renderSent(w, r), so every outcome renders the same bytes.
+
+ err := a.db.Transaction(func(tx *gorm.DB) error {
+ res := tx.Clauses(clause.OnConflict{DoNothing: true}).
+ Create(&SpentNonce{Nonce: c.Nonce, ExpiresAt: time.Unix(c.IssuedAt, 0).Add(MaxAge)})
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return errReplay // rolls back; the only statement was a no-op
+ }
+ // ... the signup write per ruling 14, then the counter bump
+ return nil
+ })
+ // ... classify, Rejected(...), then always a.renderSent(w, r)
+}
+```
+
+The pending overwrite must name its columns and exclude the budget ones:
+
+```go
+tx.Model(&row).Select("Stance", "Background", "TokenHash", "TokenExpiry", "UpdatedAt").
+ Updates(map[string]any{ /* ... */ })
+```
+
+- [ ] **Step 5: Run the gates**
+
+Run: `go test ./internal/movementtest/ -v`
+Expected: PASS.
+
+- [ ] **Step 6: Prove the no-enumeration gate can fail**
+
+Temporarily make the confirmed path render a different message. Re-run; the
+gate must catch it. Revert.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "One handler, four checks, one transaction, one page for every outcome"
+```
+
+---
+
+### Task 7: Confirmation, GET renders and POST burns
+
+**Files:**
+- Create: `internal/movement/confirm.go`, `internal/movement/templates/confirm.html`
+- Create: `internal/movementtest/confirm_test.go`
+
+**Interfaces:**
+- Consumes: `Signup`, `T`.
+- Produces: `handleConfirmPage`, `handleConfirm`.
+
+- [ ] **Step 1: Write the failing test** — a `GET /confirm?token=…` confirms
+ nothing (mail scanners fetch links on arrival); a `POST` confirms once; a
+ second POST is indistinguishable from an unknown token; an expired token is
+ refused.
+
+- [ ] **Step 2: Run and watch it fail.** Expected: 404, no route.
+
+- [ ] **Step 3: Implement.** GET renders a page with one Confirm button and the
+ token in a hidden field. POST hashes the token, finds a live row with that
+ hash and an unexpired `TokenExpiry`, sets `Confirmed`/`ConfirmedAt`, clears
+ `TokenHash`. Unknown, expired and burned all render the same page.
+
+- [ ] **Step 4: Run.** Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "Confirmation burns on POST, so a mail scanner cannot join for you"
+```
+
+---
+
+### Task 8: The mailer, the budgets, and sending after commit
+
+**Files:**
+- Create: `internal/movement/mailer.go`
+- Modify: `internal/movement/budget.go`, `internal/movement/signup.go`
+- Create: `internal/movementtest/mail_test.go`
+
+**Interfaces:**
+- Consumes: `mail.Sender`, `MailCounter`, `Signup`.
+- Produces: `func BudgetedSender(inner mail.Sender, db *gorm.DB) mail.Sender`;
+ `const PerAddressPerDay = 3`, `const GlobalPerHour = 200`.
+
+- [ ] **Step 1: Write the failing tests** — a fourth mail to one address in 24h
+ is suppressed; the global ceiling suppresses and logs `mail_budget`; a
+ suppressed send returns `nil` (an error would make `auth.Begin` redirect to
+ `?err=`, a different response, and log the address); a hung sender does not
+ block a second submission; the send happens after commit.
+
+- [ ] **Step 2: Run and watch them fail.**
+
+- [ ] **Step 3: Implement.** Bump the counter in one statement inside the
+ transaction:
+
+```go
+tx.Exec(`INSERT INTO mail_counters (hour_bucket, count) VALUES (?, 1)
+ ON CONFLICT(hour_bucket) DO UPDATE SET count = count + 1`, bucket)
+```
+
+Wrap the sender handed to `auth.New` so admin sign-in mail draws on the same
+global budget. The wrapper commits its count before sending, applies its own
+deadline — `mail.Send` discards the context it is given and `smtp.SendMail`
+dials with none — and returns `nil` when it suppresses.
+
+Send from a goroutine after commit, with a deadline of a few seconds, well
+under the platform's idle window.
+
+- [ ] **Step 4: Run.** Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "Budgets that survive a restart, and a send that cannot hold the writer"
+```
+
+---
+
+### Task 9: Admin — an app-owned sign-in, and an export that cannot execute
+
+**Files:**
+- Create: `internal/movement/admin.go`, templates
+- Create: `internal/movementtest/admin_test.go`
+
+**Interfaces:**
+- Consumes: `auth`, `Env.Admins`.
+- Produces: `func (a *app) requireAdmin(next http.Handler) http.Handler`;
+ `handleSigninPage`, `handleSignin`, `handleAdmin`, `handleExport`.
+
+- [ ] **Step 1: Write the failing tests** — all seven admin routes 404 with an
+ empty allowlist, iterated not spot-checked; a non-allowlisted address posted
+ to `/signin` sends no mail and gets the same redirect; a removed admin loses
+ access after a restart with the old cookie (not `t.Setenv` — the environment
+ is fixed at exec, so that would pass while proving nothing); the CSV excludes
+ pending rows and escapes formula-leading cells.
+
+- [ ] **Step 2: Run and watch them fail.**
+
+- [ ] **Step 3: Implement.** `POST /signin` is ours: check `MOVEMENT_ADMINS`
+ first, then delegate to `auth.Begin`, answering the identical redirect either
+ way — `auth.Begin` sends before `Authorize` ever runs, which would make this
+ app a mailer anyone could point at any address. Set
+ `auth.Config.SessionTTL = 8 * time.Hour`. `requireAdmin` re-checks the
+ allowlist per request. Escape every CSV cell beginning `=`, `+`, `-`, `@`,
+ tab or CR.
+
+- [ ] **Step 4: Run.** Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -m "Admin that fails closed, and a sign-in that will not mail strangers"
+```
+
+---
+
+### Task 10: The sweep
+
+**Files:**
+- Create: `internal/movement/sweep.go`
+- Modify: `internal/movement/app.go` (mount outside the CSRF router)
+- Create: `internal/movementtest/sweep_test.go`
+
+**Interfaces:**
+- Consumes: `carlos.Tick`, `auth.Sweep`.
+- Produces: `func (a *app) handleSweep(w http.ResponseWriter, r *http.Request)`.
+
+- [ ] **Step 1: Write the failing test** — the headline gate: a POST carrying
+ **no** `Origin`, `Referer` or `Sec-Fetch-Site` and a valid bearer must reach
+ the sweep. `csrf.SameOrigin` refuses header-less POSTs, and the platform's
+ scheduler sends none of the three, so mounted inside the protected router the
+ tick 403s forever and the platform reads 4xx as "do not retry". Plus: the
+ sweep spares confirmed rows and anything with a live token.
+
+- [ ] **Step 2: Run and watch it fail.**
+
+- [ ] **Step 3: Implement.** Mount `POST /tick/sweep` on the outer
+ `http.ServeMux` beside `mux.Handle("/", r)`, guarded by `carlos.Tick(r)`
+ alone. Work synchronously in the request — a 202 and a goroutine risks
+ hibernation mid-job. Delete unconfirmed rows older than 30 days, expired
+ `spent_nonces`, and call `auth.Sweep(now)`. All three are idempotent, so
+ at-least-once delivery needs no `TickOccurrence` dedupe.
+
+- [ ] **Step 4: Run.** Expected: PASS.
+
+- [ ] **Step 5: Prove the CSRF gate can fail.** Temporarily mount the tick
+ inside the chi router; the test must catch the 403. Revert.
+
+- [ ] **Step 6: Declare the schedule**
+
+```bash
+carlos schedule set -app movement -name sweep -every 1h -path /tick/sweep
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "A tick the scheduler can actually reach"
+```
+
+---
+
+## Deferred to plan 2 (copy-dependent)
+
+Named here so their absence is a decision rather than a gap: real copy in
+`internal/copy`, the three stance slugs, page design and `tokens.css` work,
+difficulty calibration at p95/p99 on real hardware, and the deploy to
+`movement.bdj.oncarlos.com`.
+
+## Self-review notes
+
+**Spec coverage.** §3 → Task 2. §4 → Tasks 4, 6. §5 → Task 5. §6 → Task 4.
+§7 → Tasks 4, 6. §8 → Task 6. §9 → Task 8. §10 → Tasks 2, 8. §11 → Task 9.
+§11a → Task 10. §12 gates 1–14a → Tasks 2–10; gate 15 is plan 2.
+
+**Type consistency.** `Challenge` is produced in Task 4 and consumed unchanged
+in Tasks 5 and 6. `Reason` constants are defined once in Task 2 and used by
+name thereafter. `NormalizeEmail` is the single lowercasing path and both the
+Go verifier and `pow.js` must agree byte-for-byte — Task 5's crossing test is
+what proves it.
+
+**Known soft spots.** Task 5's `sha256.js` is the one substantial piece of
+hand-written cryptographic code; it is a straight transcription and its test is
+comparison against Go's `crypto/sha256` over a corpus, not cleverness. Task 6's
+handler is the largest single file and the place to watch for growth — if it
+passes ~250 lines, the checks should move to `checks.go` behind one function
+returning `(Reason, bool)`. Task 8's send-after-commit is the only place the
+app does work after writing the response; the deadline must stay short.