Skip to content
Phase 2: persistence and tenancy

Phase 2: persistence and tenancy

October 15, 2026

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 update on 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 one applied=1 and two applied=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 pgtest package instead of a copy per package, which deviates from the plan. go list -deps proves 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.