carlos / movement Public
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
/signupoutcome renders the same bytes. The accepting path is one write transaction and at most one send; every rejecting path writes nothing. /signupnever writesConfirmed,ConfirmedAt,Stance,Background,TokenHashorTokenExpiryon 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
errorvalues. CGO_ENABLED=0. Never importgithub.com/glebarez/*orgorm.io/driver/sqlite— both collide withrastrillo/gormlite.- Migrations are additive-only; never edit a shipped migration. Add subsystems to
BootSchema, neverSchema. - Never bind a request body onto a GORM model. Named fields via
r.PostFormValue; writes through an explicit.Selectallowlist. csrf.Protect(origin)is mounted app-wide on the chi router. The tick route is mounted outside it.- Blank
opts.DBPathbeforerastrillo.Serve— the app opens its own handle. /healthzand/api/versioncome fromrastrillo.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 gatesRationale 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 movementemits, then editgo.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
cd /home/paulca/amadan.net/carlos/movement/.claude/worktrees/design
rastrillo new movementThis 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:
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:
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/movementWithout 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
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 stringwith constantsReasonSealInvalid,ReasonNonceSpent,ReasonTooFast,ReasonTooOld,ReasonPowShort,ReasonHoneypot,ReasonBounds,ReasonMailFailed,ReasonMailBudget-func Rejected(r Reason),func Info(msg string)— neither accepts anerroror any request-derived string.
- [ ] Step 1: Write the failing config test
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
// 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
// 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
// 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
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:
Envfrom 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
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
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
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(theSpentNoncepart)
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
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
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
// 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
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:
Challengefrom 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
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
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:
// 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: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
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
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).
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:
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
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); aPOSTconfirms 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, setsConfirmed/ConfirmedAt, clearsTokenHash. Unknown, expired and burned all render the same page.
- [ ] Step 4: Run. Expected: PASS.
- [ ] Step 5: Commit
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 returnsnil(an error would makeauth.Beginredirect 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:
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
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
/signinsends no mail and gets the same redirect; a removed admin loses access after a restart with the old cookie (nott.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 /signinis ours: checkMOVEMENT_ADMINSfirst, then delegate toauth.Begin, answering the identical redirect either way —auth.Beginsends beforeAuthorizeever runs, which would make this app a mailer anyone could point at any address. Setauth.Config.SessionTTL = 8 * time.Hour.requireAdminre-checks the allowlist per request. Escape every CSV cell beginning=,+,-,@, tab or CR.
- [ ] Step 4: Run. Expected: PASS.
- [ ] Step 5: Commit
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,RefererorSec-Fetch-Siteand a valid bearer must reach the sweep.csrf.SameOriginrefuses 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/sweepon the outerhttp.ServeMuxbesidemux.Handle("/", r), guarded bycarlos.Tick(r)alone. Work synchronously in the request — a 202 and a goroutine risks hibernation mid-job. Delete unconfirmed rows older than 30 days, expiredspent_nonces, and callauth.Sweep(now). All three are idempotent, so at-least-once delivery needs noTickOccurrencededupe.
- [ ] 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
carlos schedule set -app movement -name sweep -every 1h -path /tick/sweep- [ ] Step 7: Commit
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.