# 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.
