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