Phase 2: persistence and tenancy
What happened
Phase 2 added Postgres (pgx, goose migrations embedded in the binary), a scoped store whose tenant comes from the handle and fails closed, API keys stored as lookup plus SHA-256, an auth middleware with a pre-auth limiter, a per-route authorization matrix, an append-only audit trail carrying trace ids, platform bootstrap from HANGO_BOOTSTRAP_ADMIN_KEY, and the hango migrate command. Integration tests run against a reused testcontainers Postgres with one database per package.
Live verification found two bugs the tests missed: the image build failed because the Dockerfile did not copy migrations/ for embed, and the request log line lacked the tenant id because the auth middleware runs inside the logging middleware. The fix is a mutable request-log holder that inner middleware can add attributes to.
What review found
- The pre-auth limiter was keyed by IP and key lookup, so a different made-up key per request got a fresh bucket each time: database lookups were unbounded and the bucket map grew without limit. Anyone who knew a real key’s lookup could also drain that key’s bucket. Now the limiter is keyed per IP and capped at 16 x 4096 buckets, and a key that authenticated in the last ten minutes passes an empty bucket.
- The platform could mint a key for any keyless user in any tenant. Now it may create only the tenant’s first key, for an owner.
- “First user” and “first key” were check-then-write races. Now they run in a transaction holding
select ... for updateon the tenant row. A test fires 8 concurrent owner creations and gets exactly one 201. - The goose comment claimed an advisory lock, but goose locks only when given
WithSessionLocker. Now it gets one: three concurrent migrate runs on an empty database gave oneapplied=1and twoapplied=0. - Smaller fixes: a denied platform attempt is audited in the platform tenant, a panicking handler still gets an audit row, the key response sends
Cache-Control: no-store, and unknown CLI arguments exit 2.
Decisions
- The test helper is a shared
pgtestpackage instead of a copy per package, which deviates from the plan.go list -depsproves testcontainers stays out of the binary. - G101 false positives are suppressed inline, with reasons.
Next steps
Merge the PR, tag phase-02, then phase 3 (provider abstraction, Anthropic).
Historical work record — not durable authority. Prefer docs/specs/ADRs for current decisions.