ADR-0010: Test architecture¶
- Status: Proposed
- Date: 2026-05-02
- Decider(s): Theo (SA), formalising Quill's (QA) lived-in
docs/architecture/testing.mdand Pax's (BE) Phase-1 fixture refinements (#97). - Closes: #103
- Coordinates with: Quill's
testing.md(lived how-to), Pax's #97 (chokepoint + 7 ADR-0004 tests), Kit's.gitlab-ci.yml(test:unit+test:migrate-smoke).
Context¶
The de-facto test architecture is already in production use:
- Quill's
docs/architecture/testing.md(Phase-0 deliverable, on main since #81) documents the rules and fixture API. - Pax's #97 shipped the tenancy chokepoint, the 7 ADR-0004 regression tests, and one e2e ASGI test under
tests/integration/, all using Quill's fixtures. - Kit's CI runs the suite on every MR via
test:unit(real Postgres service container, fail-closed) andtest:migrate-smoke(Alembic ladder against ephemeral Postgres).
What is missing is the decision record. Without one, future MRs may silently regress the rules (re-introducing DB mocks, breaking the SAVEPOINT pattern, sneaking in a parallel runner without an ADR, mocking the JWKS path, etc.). The lived-in document carries the how; this ADR carries the why and the commitments.
Two grounding facts:
- A prior project incident saw mocked tests pass while a production migration failed. That experience is the source of the Real-Postgres rule below; it is not negotiable.
- The keystone platform routes the runtime
DATABASE_URLthrough PgBouncer at127.0.0.1:6432(transaction-pool mode). The test suite deliberately bypasses PgBouncer because the per-test SAVEPOINT-rollback pattern requires a single physical connection per transaction — seeproject_pgbouncer_constraintmemory and Quill'stesting.md§"Why direct Postgres, not PgBouncer, in tests".
This ADR formalises existing practice. It introduces no new architectural decisions; it commits the ones we already rely on so that future readers can re-evaluate intentionally rather than drift unintentionally.
Options¶
Database test posture¶
- (D-1) Real Postgres for every persistence-touching test (chosen). The Phase-0 rule, codified by
tests/conftest.pywhich raisesRuntimeErrorifTEST_DATABASE_URLis unset. Catches migration / model drift. Slower than mocks but cheaper than the prod incident it prevents. - (D-2) Mocked SQLAlchemy session for "fast" unit tests, real Postgres only for integration. This is exactly what produced the prior incident. Rejected — no carve-outs. "But it's just a tiny SELECT" is the rationalisation we are guarding against.
- (D-3) SQLite for fast tests, Postgres for "real" tests. Schema dialect drift (JSONB,
gen_random_uuid(),EXCLUDEconstraints,tstzrange) means SQLite "passes" diverge from Postgres "fails." Rejected.
Postgres connectivity in tests¶
- (P-1) Direct port (5432), not PgBouncer (chosen). The SAVEPOINT-rollback pattern requires a single physical connection per test transaction; PgBouncer's transaction-pool mode does not guarantee that. Tests refuse to start if
TEST_DATABASE_URLpoints at port 6432. - (P-2) PgBouncer for test parity with prod. Breaks rollback semantics; tests would silently leak state across each other or fail mysteriously on
SAVEPOINToperations. Rejected. Pooler behaviour is exercised in production and via deploy-side smoke tests, not in unit/integration. - (P-3)
NullPoolto dodge connection reuse issues. Eachbegin()would acquire a fresh connection; the outer transaction + nested SAVEPOINT pattern collapses. Rejected.
Engine fixture scope¶
- (E-1) Function-scoped
db_engine(chosen). Pytest-asyncio 0.24 introduced strict event-loop scope alignment: a session-scoped async fixture used by a function-scoped test raises unless every async fixture'sloop_scopeis bumped to"session"andasyncio_default_fixture_loop_scopeis set globally. Function-scope sidesteps the cascade at the cost of one engine creation per test (cheap at our suite size). Pax's commit5877e53 fix(97): downgrade db_engine to function-scope for pytest-asyncio 0.24 loop alignmentis the lived implementation. - (E-2) Session-scoped engine + global
asyncio_default_fixture_loop_scope = "session". Works, but broadens the blast radius — every async fixture inherits session scope unless overridden. We re-evaluate when the suite grows past the volume where engine creation dominates.
Per-test isolation pattern¶
- (I-1) External-transaction + nested SAVEPOINT via
join_transaction_mode="create_savepoint"(chosen). Canonical SQLAlchemy test pattern. Test code can callsession.commit()and the data is still rolled back at teardown because the outer transaction wraps the SAVEPOINT. This is what the chokepoint regression suite relies on. - (I-2)
TRUNCATEbetween tests. Slower; loses inside-test commit semantics; depends on knowing every table. - (I-3) Schema-per-test. Heavyweight; would require Alembic to run per test. Rejected.
Schema bootstrap in tests¶
- (S-1)
alembic upgrade headonce per pytest session, autouse (chosen). Exercises the same migration code path prod will run; a broken migration fails every test loudly at session setup, which is the right shape. Companionalembic downgrade baseon teardown exercises the down-revision symmetry — a missing or broken downgrade fails the suite. - (S-2)
Base.metadata.create_all(). Skips the migration code entirely. The whole point of the Real-Postgres rule is to catch migration / model divergence. Rejected. - (S-3) Pre-baked Postgres image with schema applied. Operationally heavy (image rebuilds per migration); breaks the "MR catches breakage in MR pipeline" principle Kit's
test:migrate-smokealready provides cheaply.
HTTP test transport¶
- (H-1)
httpx.AsyncClientoverASGITransport(app=app)(chosen). No live socket. Drives the full FastAPI request/response lifecycle in-process. Fastest end-to-end-of-process verification; sufficient for everything below the uvicorn / Caddy boundary. - (H-2)
TestClient(Starlette). Sync wrapper; awkward inside an async test suite. Rejected. - (H-3) Live uvicorn + real port. Belongs in deploy-side smoke / e2e (
smoke:testin CI does this againsthttps://rbo-test.wagen.io/); not in the unit/integration tier.
Test categories (the canonical taxonomy)¶
- (C-1) Five active categories + two reserved slots (chosen). Active: unit, integration, e2e ASGI, migration smoke, tenancy regression. Reserved: receipt golden-file (M14), browser e2e (post-V2). See § Decision §2.
- (C-2) "Test pyramid" without category names. Names are load-bearing: Pax-A's Order tests, Pax-B's JWT tests, and future serialization-redaction tests need a single answer for "where do I put this?" Rejected.
Browser end-to-end testing in V2¶
- (B-1) Out of scope (chosen). No Playwright, no Cypress, no headless-browser harness. Smoke tests against
rbo-test.wagen.io(curl-level) cover the deploy-side liveness check; everything below uvicorn is covered by the ASGI category. - (B-2) Adopt Playwright now. Real value, but real cost (browser image, flake budget, selector convention with Juno). Stefan greenlit deferring to post-V2 per Quill's role profile. Re-evaluate when the first M-item that genuinely needs a browser lands.
Parallel test runner¶
- (R-1) Single-worker pytest (chosen). Suite size does not warrant
pytest-xdist. The session-scoped Alembic bootstrap is also xdist-hostile (each worker would race to upgrade the same DB). - (R-2)
pytest-xdistwith per-worker DB. Future option when suite size justifies it. Requires per-workerTEST_DATABASE_URLand per-worker engine. Out of V2 scope.
Property-based / mutation testing¶
- (Q-1) None in V2 (chosen). Hypothesis (property-based) and mutmut/cosmic-ray (mutation) are real signal at scale; at V2's suite size and team size the noise floor is too high to justify the maintenance. Documented "no" so future readers do not silently reintroduce.
Decision¶
1. Real-Postgres rule (load-bearing)¶
No mocks of the database, the SQLAlchemy session, or any layer between application code and Postgres.
This applies to:
- Handler tests (any route that touches a repository).
- Repository / ORM tests.
- Tenancy chokepoint tests.
- Receipt-generation tests once they touch rows (M14).
- Any future serialization / redaction test that depends on session state.
This does not apply to:
- Unit tests for pure functions / dataclasses with no DB access. They have nothing to mock.
- Tests for purely-static code paths (formatters, validators on values, etc.).
The fixture module enforces the rule at the entry point: tests/conftest.py's database_url fixture raises RuntimeError if TEST_DATABASE_URL is unset, and again if it points at port 6432 (PgBouncer). Loud failure forces an explicit choice; no silent localhost fallback.
Why this rule, not just a guideline: the prior incident saw mocked tests pass while a production migration failed. The cost of "tests are fast because they mock the DB" is "tests are decorative and bugs ship to prod." We are not repeating that.
2. Test categorisation (the canonical taxonomy)¶
| Category | Scope | Tools | Lives in | Owner |
|---|---|---|---|---|
| Unit | Pure functions / dataclasses; no DB; no ASGI. | pytest (sync or async). |
tests/test_*.py (top-level). |
Pax (BE) for backend; Juno (FE) for frontend. |
| Integration | DB-bound: handler → service → repo → real Postgres, via db_session. |
pytest + SQLAlchemy + real DB. |
tests/integration/test_*.py. |
Pax (BE), Juno (FE) for their own slices. |
| End-to-end (ASGI) | Full FastAPI request lifecycle via httpx.AsyncClient over ASGITransport(app=app); in-process; no live socket. |
pytest + httpx. |
tests/integration/test_*_e2e.py. |
Pax for the chokepoint-spanning paths; future M-item leads for their flows. |
| Tenancy regression | Every tenant-scoped table refused unbound reads; every list/show endpoint exercised with two stringers and asserted leak-free. Reflective: a new tenant-scoped model that forgets to register fails the suite. | pytest + db_session + as_stringer / as_person. |
tests/integration/test_tenancy_chokepoint.py (canonical). |
Quill (writes the framework); Pax adds new tenant-scoped models to the enumeration. |
| Migration smoke | alembic upgrade head (and downgrade-base on teardown) against an ephemeral Postgres. Catches bad migrations cheaply BEFORE build/deploy. |
Alembic + postgres:16-bookworm service container. |
Kit's test:migrate-smoke CI job (no in-repo test file; the migration ladder is the test). |
Kit (CI), Pax (revisions). |
Reserved (slot-only):
| Category | Status | Lives in | When |
|---|---|---|---|
| Receipt golden-file | Reserved (no implementation). | tests/golden/receipts/ (path reserved). |
Lands with M14 (WeasyPrint receipt rendering). See §4. |
| Browser e2e | Out of V2 scope. | tests/e2e/ (path reserved). |
Post-V2; tooling choice deferred (Playwright most likely). See §6. |
Naming convention for new tests: category prefix in the directory (tests/integration/...), descriptive snake-case in the filename. The chokepoint test, the e2e ASGI test, and Pax-A's incoming Order tests + Pax-B's JWT tests all live under tests/integration/. This is one boring shelf, not a carved-up taxonomy of subdirectories — the category is in the fixtures the test depends on, not the path. ADR-0010 commits the path; the seven-line pytestmark block at the top of each module commits the fixtures.
3. Fixture conventions (formalised from tests/conftest.py)¶
The fixtures live in tests/conftest.py and are auto-injected by name. The contract below is load-bearing: any future test framework MR that wants to break it must update this ADR first.
| Fixture | Scope | Contract |
|---|---|---|
database_url |
session | Returns TEST_DATABASE_URL or raises RuntimeError. Refuses port :6432 (PgBouncer). No silent fallback to localhost or to runtime DATABASE_URL. Used only in fixture composition; tests should depend on db_engine / db_session instead. |
db_engine |
function | Async SQLAlchemy AsyncEngine bound to the test DB. URL normalised to postgresql+psycopg. pool_pre_ping=True. Function-scope is mandatory under pytest-asyncio 0.24 — see §3.1. Disposes on teardown. |
db_session |
function | Per-test AsyncSession over db_engine.connect() with an outer BEGIN and join_transaction_mode="create_savepoint". Test code may call session.commit() without persisting (the commit collapses to a SAVEPOINT release; outer ROLLBACK on teardown wipes everything). expire_on_commit=False to match the production session factory. |
client |
function | httpx.AsyncClient driving app.main:app via ASGITransport(app=app). base_url="http://testserver". No live socket. |
as_stringer |
function | Yields a callable that returns a contextlib-style context manager. Inside the with block, current_stringer_id ContextVar is bound to the supplied UUID. __exit__ resets via the ContextVar token (LIFO; nesting works). |
as_person |
function | Symmetric with as_stringer for current_person_id. Used by V3-portal-shaped Person-bound tests of the Person-scope predicate. |
_alembic_upgrade_head |
session, autouse | Runs alembic upgrade head before any test, alembic downgrade base on teardown. Exercises the migration and its inverse — broken downgrades fail the suite. |
install_chokepoint_on_test_session |
function | No-op shim retained for backwards compatibility (the chokepoint listener is now bound at module-import time on the global Session class; importing any app.db.* module installs it). Kept so existing usefixtures("install_chokepoint_on_test_session") declarations remain valid. |
3.1 Why db_engine is function-scoped¶
Pax's commit 5877e53 (under #97) downgraded db_engine from session-scope (Quill's original Phase-0 design) to function-scope. The why:
- pytest-asyncio 0.24 enforces strict event-loop scope alignment between async fixtures and their consumers.
- A session-scoped async fixture used by a function-scoped async test now raises unless every async fixture's
loop_scopeis bumped to"session"ANDasyncio_default_fixture_loop_scope = "session"is set inpyproject.toml. - Both options broaden the blast radius — every async fixture inherits session scope unless explicitly overridden.
- Function-scope engine creation is cheap at the V2 suite size (a few dozen tests); SQLAlchemy engines are lazy about real connections, and
pool_pre_pingis the only per-test cost.
When the suite grows past the volume where engine creation dominates, the upgrade path is a single MR that flips asyncio_default_fixture_loop_scope and bumps engine scope — well-understood, cheap to reverse if it bites. Until then, function-scope is the contract.
3.2 Why the SAVEPOINT pattern is non-negotiable¶
Two things depend on it:
- Per-test isolation without per-test schema. The outer transaction + nested SAVEPOINT lets every test commit / rollback freely without persisting. Removing it (e.g. moving to
TRUNCATE-between-tests) loses inside-test commit semantics that the chokepoint tests rely on. - The chokepoint listener is a session-event hook. Tests of the chokepoint MUST exercise a real session against a real DB or the assertions are meaningless. Mocking the session loses the very thing the test is verifying.
A future MR that wants to break the SAVEPOINT pattern (e.g. to enable pytest-xdist) must update this ADR with the replacement strategy. Don't quietly swap the fixture.
4. Tenancy regression suite — location convention¶
tests/integration/test_tenancy_chokepoint.py is the canonical home of the ADR-0004 enforcement suite.
What lives there today (Pax's #97):
- The seven mandated ADR-0004 tests (#1–#7).
- Test #4 currently a placeholder pending the Order MR's serialization layer.
What lands there next (parallel work in flight):
- Pax-A — ADR-0007 Order lifecycle tests (Order writes refused unbound; Order list refused cross-stringer; state-transition validators run inside the chokepoint scope).
- Pax-B — ADR-0006 JWT tests (chokepoint binds
current_stringer_idfrom the JWT; unauth requests refused; HS256 sign-and-verify exercised with a shared secret in the active default mode, RS256+JWKS path gated behindjwt_verification_mode == "rs256"and skipped in CI until a JWKS-capable gotrue lands — see ADR-0006 §"Required tests" amendment 2026-05-04). - Future — serialization-redaction tests (ADR-0004 #4) once the Order serializer exists.
All of these belong under tests/integration/. One boring shelf, predictable place. New tenant-scoped models that forget to register with iter_tenant_scoped_models() fail the chokepoint enumeration test by construction — that's the point.
5. Receipt golden-file pattern (forward-looking, M14)¶
When M14 (receipt delivery) lands, the receipt regression suite will follow this shape. Documented now so neither Pax nor Juno reinvents shape-detection ad-hoc:
- Render a known receipt against fixed inputs (a fixture Order with frozen pricing).
- Extract text from the rendered PDF (pdfminer or equivalent) AND a structural fingerprint (page count, bbox of the total field — anchors the "total in top-left quadrant" invariant per ADR-0002).
- Diff against a checked-in golden under
tests/golden/receipts/. Goldens are committed alongside the code that produced them. - Update requires an explicit
--update-goldensflag plus reviewer sign-off in the MR description (no silent golden-flips). The flag's implementation is apytest_addoptionhook; MRs that touch goldens must call it out. - Failure modes the pattern catches: a Tailwind tweak that shifts the total out of the top-left quadrant; a template edit that adds a stray newline; a font-substitution that re-wraps the address block; a locale change that re-orders fields.
This slot is reserved here, not implemented. Implementation lands when receipts ship, not before. Sketch only — Quill's role profile owns the implementation choice (text+structural diff vs. screenshot diff).
6. CI integration (Kit's lane, formalised)¶
test:unit (every MR + main push)¶
image: python:3.12-slim-bookwormservices: [{ name: postgres:16-alpine, alias: postgres }]- Service-container env:
POSTGRES_DB,POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_HOST_AUTH_METHOD: trust. These never leave the CI job network namespace; the Postgres container is throwaway. - Test-suite env:
TEST_DATABASE_URL: postgresql://rbo_test:rbo_test_pw@postgres:5432/rbo_test— direct port (5432), NOT PgBouncer.TEST_DB_ECHO=1(optional, debug only) — when set, SQLAlchemy logs every query. Off by default in CI; useful locally.script: pytest -ra- Fail-closed: no
allow_failure: true. A redtest:unitblocks the MR.
test:migrate-smoke (every MR + main push)¶
- Extends the keystone v5
.test_migrate_smokebrick (see Kit's CI). MIGRATE_CMD: "alembic upgrade head"(mandatory at v5).image: python:3.12-slim(overrides the brick's node default; ours is Alembic-shaped).- Pre-installs
postgresql-clientforpg_isready; loops up to 30s waiting for the service. - Fail-closed: this is the early gate against bad migrations.
Why two jobs, not one¶
test:unit runs the application suite (which itself runs Alembic via the autouse fixture). test:migrate-smoke runs only the Alembic ladder — no application code, no fixtures. It catches cases where the migration breaks in isolation (e.g. an import that pulls in a setting unavailable in the migrate-smoke environment). Cheap, fast, narrow. Two jobs are clearer than one bloated job.
Inline definition vs. brick¶
test:unit is inlined (not a brick) because the v5 .test_migrate_smoke brick bundles a migration step and test:unit needs pytest only. If the inline shape stabilises across multiple apps, file a keystone issue to extract a brick. (See team/qa_engineer.md for ownership.)
7. What is deliberately NOT in V2¶
Documented "no" so future readers do not silently reintroduce.
- Browser e2e (Playwright / Cypress / WebDriver). Deferred. Smoke tests against
rbo-test.wagen.iocover the deploy-side liveness check; everything below uvicorn is covered by the ASGI category. Re-evaluate when the first M-item lands that genuinely depends on browser state (e.g. a sticky menu that must close on outside-click — even then, an integration test of the underlying handler is usually enough). pytest-xdist. Suite size does not warrant it; the session-scoped Alembic bootstrap is xdist-hostile (each worker would race to upgrade the same DB). Future option requires per-workerTEST_DATABASE_URL+ per-worker engine; an MR that wants xdist must extend this ADR.- Property-based testing (Hypothesis). Real signal at scale; too noisy at V2 surface area.
- Mutation testing (mutmut / cosmic-ray). Same reasoning.
- Snapshot testing of API responses. Tempting; produces churny golden files that mask real shape drift. Real assertions on real fields are clearer.
- Coverage gating in CI. Coverage reports are useful as a trend; coverage-as-gate produces noise-driven test-writing. If V3 needs coverage signal, add it as a non-gating report first.
A future MR that wants to add any of the above must extend this ADR with a re-evaluation rationale.
Required tests (this ADR mandates them)¶
This ADR formalises existing practice; the only "required test" that is genuinely new is the negative test for the Real-Postgres rule itself:
TEST_DATABASE_URLunset → fixture raises. Already implemented (thedatabase_urlfixture) but not yet asserted; add a meta-test that importstests/conftest.pyin a subprocess withTEST_DATABASE_URLcleared and assertsRuntimeError. (Quill's lane.)TEST_DATABASE_URLpointing at port 6432 → fixture raises. Symmetric meta-test. (Quill's lane.)
Both are non-blocking: the lived behaviour is correct; a regression would surface immediately. The meta-tests exist to make the contract visible to future readers grepping for it.
The other categories' required-tests are owned by their respective ADRs (ADR-0004 owns the seven tenancy tests; ADR-0006 will own the JWT tests; ADR-0007 will own the Order lifecycle tests; etc.). ADR-0010 names where those tests live, not what they assert.
Consequences¶
Good¶
- One canonical answer to "where does this test go?" Pax-A's Order tests, Pax-B's JWT tests, future serialization-redaction tests all have a predictable home (
tests/integration/). - One canonical answer to "can I mock the DB?" No. Documented, with the prior-incident rationale, so future MRs do not relitigate.
- One canonical answer to "why function-scoped engine?" pytest-asyncio 0.24's loop-scope strictness, with a documented upgrade path when the suite grows.
- Forward slots reserved. Receipt golden-file (M14) and browser e2e (post-V2) have documented shapes; future MRs slot in cleanly.
- CI contract is one table. Kit's
test:unitenv vars, fail-closed posture, and service shape are committed; future MRs that touch them must touch this ADR. - Banner on
testing.mdkeeps the two docs aligned. Quill's living document carries the how (fixture API, env vars, debugging tips); ADR-0010 carries the why and the commitments. Cross-reference both ways.
Costs we accept¶
- Function-scoped engine pays a small per-test cost. Cheap at current suite size; revisit when the suite grows.
- The Real-Postgres rule slows local "I just want to check this one thing" iteration. Acceptable — the prior incident is the cost we are NOT paying.
- The browser-e2e "no" defers a real signal. Acceptable until M-items demonstrate they need it; smoke tests + integration tests cover the load-bearing paths in the meantime.
- No coverage gate means no automatic "test writing" pressure. Acceptable — review pressure (Quill, Theo) substitutes for the metric until V3.
Migration impact¶
- Pax (Phase-2 work): ADR-0010 ratifies the conventions Pax's #97 MR already lives by; no code change required. New tests under
tests/integration/per the convention. - Quill (next sweep): add the banner to
docs/architecture/testing.mdcross-referencing this ADR (this MR does it). Future updates to fixture API land ontesting.md; future commitments land here. - Kit (CI): no immediate CI change. Future CI changes that touch the test contract (env vars, fail-closed posture, runner choice) must touch this ADR.
- Future M14 receipt work: ADR-0010 §5 is the slot; implementation MR cites this ADR + ADR-0002 (top-left-quadrant invariant).
Open questions (Stefan-confirm)¶
All five default to the values stated; each is a single-line change.
- Receipt golden-file extraction tool —
pdfminer.sixfor text + a homemade structural fingerprint (bbox of the total field) is the lowest-cost option.WeasyPrint's rendered HTML can also be diffed pre-PDF, which is faster to debug. Default: PDF text + structural fingerprint, decided at M14 implementation time. Either path satisfies §5; no commitment now. - Coverage report — non-gating coverage report on every
test:unit(HTML artefact, no threshold) would give a free trend signal without coverage-driven test churn. Default: defer; add when there's a question coverage data could answer. pytest-randomly— randomises test order each run; catches order-dependent state leaks. Cheap to add; cheap to reverse. Default: defer; the SAVEPOINT pattern already isolates per-test state, so the marginal value is low until the suite grows.- In-repo CONTRIBUTING / test-running quickstart — currently the test-running instructions live in Quill's
testing.md. ACONTRIBUTING.mdat repo root would surface them earlier. Default: defer to a separate doc MR; out of ADR-0010's scope. - Browser e2e tooling lock-in — Quill's role profile names Playwright as the likely choice. ADR-0010 deliberately does NOT commit it (the V2 "no" makes it premature). Default: decide when the first browser-state-dependent M-item lands.
Cross-references¶
- ADR-0001 — names the stack; ADR-0010 names how we test it.
- ADR-0002 — receipt invariants the future golden-file suite (§5) verifies.
- ADR-0004 — the seven required tenancy tests live in
tests/integration/test_tenancy_chokepoint.py(§4). - ADR-0006 — JWT tests will live under
tests/integration/per §4. - ADR-0007 — Order lifecycle tests will live under
tests/integration/per §4. - ADR-0009 — required tests 1–8 (logging /
request_id) ride with Pax's middleware MR; their location convention is §4. docs/architecture/testing.md— the lived-in how-to; carries the fixture API and debugging tips. ADR-0010 is the formal record; both stay in sync.team/qa_engineer.md— Quill's role profile and ownership boundary (regression / e2e / tenancy; not unit / basic integration).tests/conftest.py— fixture implementation; this ADR is the contractconftest.pyimplements..gitlab-ci.yml— Kit'stest:unitandtest:migrate-smokejobs; this ADR is the contract those jobs implement.project_pgbouncer_constraint(project memory) — the platform constraint that motivates §1's port-5432 rule.