ADR-0001: Hybrid Local Dev Stack (docker-compose, Flavor A)
- Status: Accepted
- Date: 2026-04-09
- Deciders: Vaisakh, Ashik (approval on #57), Principal Architect (proposal)
- Supersedes: None
- Related: #57 (approval), #58 (Phase 4 re-validation gate), #59 (CloudSQL deferred, closed not planned)
1. Context
UpsQuad is mid-flight on the Context Engine (PR series #29–#36). There are currently:
- No external design partners onboarded.
- No ingest API alpha exposed to anyone outside the core team.
- No SLO has been committed to any customer.
- No managed-infra budget allocated for a permanent CloudSQL/GKE dev footprint.
At the same time, the PR #51 regression — where transaction-pooled GUCs silently dropped at the PgBouncer session boundary — made it clear that go test alone does not give us production-fidelity confidence. We must exercise the real topology: the pgx pool talking to PgBouncer in transaction pooling mode, RLS policies enforced end-to-end, the audit log side-effect path, fake-gcs for memory snapshots, and a fake OIDC issuer for scope-chain middleware.
Two options were evaluated in #57:
- Flavor A — docker-compose hybrid stack (engine binary on host, dependencies containerised).
- Flavor B — kind (Kubernetes in Docker) cluster.
Vaisakh explicitly rejected Flavor B: the Kubernetes layer adds operational burden without adding fidelity for the specific regression class we are trying to prevent. The engine's scaling, networking, and Pod lifecycle are not on the critical path at this stage.
Separately, #59 (permanent CloudSQL dev instance) was closed not planned on 2026-04-09. The reasoning from the #59 architect review stands: the compose stack's topology — engine → pgbouncer:6432 (transaction mode) → postgres:5432 — is architecturally identical to what CloudSQL would present (engine → CloudSQL PgBouncer → CloudSQL Postgres). The PR #51 regression class is fully exercised by the compose stack. Until a tripwire fires (see Section 8), the compose PG is the sole authoritative dev database.
This ADR records the Flavor A decision, the hard conditions attached to it, and the downstream work it unblocks.
2. Decision
We adopt Flavor A: a hybrid local dev stack where the Context Engine runs as a native Go binary on the host (go run ./cmd/context-engine or a locally built binary) and all infrastructure dependencies run in a single docker-compose.dev.yml stack on the loopback interface.
2.1 Authoritative topology
host:9001 ─┐
├─ context-engine (go binary, built from cmd/context-engine)
│ │
│ ├─→ 127.0.0.1:6432 pgbouncer (transaction mode)
│ │ │
│ │ └─→ postgres:5432 (PG 16 + pgvector)
│ ├─→ 127.0.0.1:6379 redis (Redis Streams)
│ ├─→ 127.0.0.1:4443 fake-gcs (GCS API emulator)
│ └─→ 127.0.0.1:8080 fake-jwt-issuer (OIDC-compatible)
│
└─ host:9091 /metrics, /healthz, /readyz (see Section 5.2)
Every dependency is addressable from the host on 127.0.0.1 only. The compose network is internal; no port is exposed on 0.0.0.0. The engine is NOT containerised for local dev — it runs on the host so that dlv, pprof, and editor integration work without friction.
2.2 Services, versions, and purpose
All versions are pinned. DevOps MUST NOT float these tags.
| Service | Image / Version | Purpose |
|---|---|---|
| postgres | pgvector/pgvector:pg16 (pinned digest required) | Primary DB. Must match the CloudSQL major version we will eventually target (PG 16). |
| pgbouncer | edoburu/pgbouncer (pinned digest required) | Transaction pooling — the fidelity surface for the #51 regression class. |
| redis | redis:<Memorystore-matched> (see 2.3) | Redis Streams, caching. |
| fake-gcs | fsouza/fake-gcs-server (pinned digest) | GCS API emulator for memory snapshot path. |
| fake-jwt-issuer | A minimal OIDC discovery + JWKS server (pinned) | Serves /.well-known/openid-configuration and a JWKS so the engine's OIDC verifier resolves without external calls. |
Image digest pinning is a hard requirement — tag pinning alone is insufficient for reproducibility.
2.3 Redis version pin (hard condition)
Redis must be pinned to the exact major.minor that our target Memorystore tier will run. DevOps owns selecting and locking this version in docker-compose.dev.yml as a single-source constant. The constant must be referenced from the #58 Phase 4 re-validation so drift is detectable.
2.4 PgBouncer configuration (hard condition, non-negotiable)
The PgBouncer instance in the compose stack must be configured with the following options. These carry forward from the #59 architect review where the transaction-pooled GUC regression class was dissected:
pool_mode = transactionserver_reset_query = DISCARD ALLignore_startup_parameters = extra_float_digits,optionsmax_client_connsized to support concurrent smoke test runs (minimum 50)default_pool_sizesized to match the engine'sMAX_DB_CONNSdefault (20)server_lifetimeandserver_idle_timeoutset to values that force connection recycling during smoke tests (so reset-query correctness is exercised)- Logging set to verbose enough to surface reset-query failures (
log_pooler_errors = 1)
Any deviation from these settings — even "just for local" — is a violation of this ADR and must be raised as a new architect concern, not a config tweak.
2.5 Engine connectivity
The engine is configured via environment variables (already defined in cmd/context-engine/config.go):
| Env var | Value |
|---|---|
DATABASE_URL | postgres://upsquad:upsquad@127.0.0.1:6432/upsquad?sslmode=disable |
REDIS_URL | redis://:upsquad-dev-redis@127.0.0.1:6379/0 (auth required, #250) |
GCS_BUCKET | upsquad-dev |
GCS_ENDPOINT | http://127.0.0.1:4443/storage/v1/ (new env, must be added by backend) |
OIDC_ISSUER_URL | http://127.0.0.1:8080 (new env, must be added by backend) |
METRICS_PORT | See Section 5.2 — current default 9090 collides with Prometheus default |
ENVIRONMENT | development |
Adding GCS_ENDPOINT and OIDC_ISSUER_URL to Config is a backend task (Section 9a).
3. Hard Conditions (normative requirements)
These are the conditions attached to the #57 approval. Each is normative — the stack is non-compliant if any is missing.
3.1 PgBouncer in compose, transaction mode
Covered in Section 2.4. The presence of PgBouncer is what makes this stack worth building; removing it turns the effort into a rounding error on go test.
3.2 fake-gcs-server in compose
The memory snapshot path (PR #36) writes to GCS. Without fake-gcs, the persistent memory code path cannot be exercised locally, which means the #58 Phase 4 re-validation cannot cover it — which means the #59 closure argument weakens. fake-gcs is therefore load-bearing for the decision to defer CloudSQL.
3.3 fake JWT issuer in compose
The scope-chain middleware resolves a JWKS over HTTP. A local OIDC-compatible issuer removes all external dependencies from the smoke test and makes the stack fully airgapped. The issuer must expose /.well-known/openid-configuration and a JWKS endpoint. It accepts any signature (see Section 7 on the tenet exception).
3.4 Redis version pin matching Memorystore
Covered in Section 2.3.
3.5 Lint rules (see Section 4)
3.6 #58 Phase 4 re-validation gate
Phase 4 of the Context Engine rollout plan (tracked in #58) must re-run against this stack as the condition for opening any Phase 6+ work. Phase 4 re-validation is the primary consumer of this stack and is the check that the #59 closure decision remains sound.
3.7 In-scope vs deferred (Class A)
In now-scope for this ADR:
- This ADR itself.
- The compose stack and its supporting config.
- A ghcr.io image build workflow for the context-engine binary.
- An empty Pulumi scaffold (see Section 9e).
Deferred until a tripwire fires:
- Everything else in the original Class A list (managed CloudSQL, permanent GKE dev cluster, ArgoCD bootstrap, observability stack deployment, etc.).
4. Lint Rules (hard condition)
Two patterns are forbidden in internal/context/**. These must be enforced by automated lint — not code review — and must fail CI.
4.1 No dev-only code branches in internal/context/**
Forbidden:
- Runtime env-gated branches such as
if os.Getenv("DEV") == "1" { ... },if cfg.Environment == "development" { ... }, or any equivalent. - Build-tag gated files such as
//go:build devor legacy// +build dev. - Any import path containing
/devonly/or/testfixture/pulled into production packages.
Rationale: the whole point of running against a production-fidelity stack is that there is no dev-only code path to hide in. If a test needs different behaviour, it injects a different dependency; it does not flip a runtime flag inside the module under test.
4.2 No prepared statements in internal/context/**
Forbidden:
conn.Prepare(...),tx.Prepare(...),db.PrepareContext(...)on the standard library side.- Any pgx path that results in prepared-statement caching. The pgx pool config must set:
or the equivalent at pool construction time.config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec
Rationale: PgBouncer transaction mode and prepared statements are incompatible — a prepared statement issued on one backend connection is not visible when the next statement is routed to a different backend connection. This is the exact shape of the PR #51 regression class. Prevention is a lint concern, not a review concern.
4.3 Enforcement
Enforcement is via golangci-lint with a custom analyzer or a forbidigo ruleset, plus a small Go AST script under scripts/lint/ if the rule is beyond what forbidigo can express. DevOps picks the specific mechanism; the architect requirement is that both rules fail CI on violation. A failing lint must be reproducible locally with make lint or equivalent.
5. Smoke Test Contract (hard condition)
The smoke test harness is the sole gate on the dev stack being "green". It must assert all four of the following. A passing gRPC /healthz ping is not a smoke test.
5.1 RLS enforcement — cross-tenant read denial
- Seed fixtures for two tenants,
tenant_Aandtenant_B. - Ingest one event for
tenant_AviaIngestEventRPC with a JWT bound totenant_A. - Call a retrieve RPC with a JWT bound to
tenant_B. - Assert: zero rows returned AND no error leaking the existence of
tenant_A's data (no "not found for your tenant" distinction from "not found at all"). - Assert: the DB session variable
app.tenant_idwas set totenant_Bfor the retrieve call (verify via audit log or a test hook).
5.2 Audit log writes
- Perform one ingest and one retrieve against the stack.
- Assert: one row in
audit_logper call, with correcttenant_id,actor,action, and a non-nullcreated_at. - Assert: the audit row is committed in the same transaction as the business write (i.e., rolling back the business write rolls back the audit row — this is the only way to guarantee the audit trail cannot be bypassed).
5.3 Ingest → retrieve round trip
- Ingest a known event via
IngestEventfortenant_A. - Wait on the embedding worker's completion (bounded, with a hard timeout).
- Retrieve with a query that should match.
- Assert: the retrieved payload equals the ingested payload (up to expected normalisation).
- Assert: at least one of vector, BM25, or recency signals contributed a non-zero score to the hit (verifies the fused retrieval path, not just a SQL
SELECT *).
5.4 EXPLAIN ANALYZE of the hybrid retrieval query
- Capture
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)of the live hybrid retrieval query as executed by the engine (not a hand-written re-creation). - Assert: the query uses the pgvector HNSW/IVFFlat index for the vector leg (index scan, not seq scan).
- Assert: the query uses the BM25/tsvector GIN index for the lexical leg.
- Assert: total
Actual Rowsis bounded (no accidental cartesian product). - The
EXPLAINoutput is written to a test artifact so regressions are visible in PR diffs.
Any of these four failing is a hard fail. There is no "flaky smoke test" budget.