Skip to content

[FEAT] Postgres adapters and a conformance suite for four durable ports - #447

Open
justin13888 wants to merge 14 commits into
feat/server-binary-config-operator-commands-401from
feat/postgres-adapters-402
Open

[FEAT] Postgres adapters and a conformance suite for four durable ports#447
justin13888 wants to merge 14 commits into
feat/server-binary-config-operator-commands-401from
feat/postgres-adapters-402

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

The durable ports have named one production adapter shape in their own docs since S-C29 and S-C37 — PostgreSQL — and had no code behind it. This lands adapters for the four ports the finalization and sign-in paths cannot run without, each proved by a suite that runs case-for-case against the in-memory double and against a real Postgres, plus the migration crate, the container harness, the Durable boot arm's Postgres half, and E2E case 11.

Summary

The migration crate. capsule-server/migration (package capsule-server-migration, lib server_migration) holds four ordinals: the asset index, the account cluster, the device cohorts and the quota ledger. capsule-server takes it as a dev-dependency onlysea-orm-migration pulls sea-orm with its default with-chrono feature, and dependencies.md makes chrono a review-blocking gate outside capsule-cli/entity. So serve cannot migrate; it reads seaql_migrations and refuses to boot on a missing ordinal, naming capsule-server-migration up. That is also the safer rollout: a server that migrates on start migrates once per replica during a rolling deploy.

Four adapters, each beside its port.

Port Adapter The thing it had to get right
AssetIndex index/postgres.rs The sequence mint inside the transaction that makes the row readable — SELECT … FOR UPDATE, an upsert on owner_sequences, the state flip, COMMIT. Never a SEQUENCE: nextval is non-transactional, which is the skip window S-C21 is about.
AccountRegistry/AccountDirectory/AccountProfiles/PasswordChange auth/accounts_postgres.rs One accounts row behind four ports; AlreadyExists decided by a unique index, not a read-then-write; the lockout's decay, reset and increment in one UPDATE … CASE, which accounts_memory predicted was the trade a Postgres adapter should make and it could not.
CohortStore store/cohorts_postgres.rs One upsert whose composite key is the idempotence the port states.
QuotaStore quota/postgres.rs INSERT … ON CONFLICT (address) DO NOTHING — the primary key decides and the row count is the answer, so two sessions for one address cannot both read "unattributed" and both debit.

The conformance pattern, applied. auth/conformance.rs and quota/conformance.rs are new (sixteen and nine cases); store/conformance.rs's Harness splits into Harness and CohortHarness, because the cohort map is the one port there whose production adapter is not Valkey; index/conformance.rs gains two cases. Every suite ran against the in-memory adapter before its Postgres adapter existed.

Two gaps the shared suite found, both in coverage that predated this change:

  • AssetIndex::rows — the scrub's walk — had no conformance case at all. Two were added: that it covers pending, visible and tombstoned rows and resumes at any page size, and that it orders by the identifier's own bytes.
  • The second is why the adapter pins COLLATE "C". Asset ids are the manifest's client-chosen file_id and are full of punctuation, and a glibc PostgreSQL ignores - at the primary collation level: LC_ALL=en_US.UTF-8 sort orders walkord-a-b, walkord-ab, walkord-a-c where LC_ALL=C orders walkord-a-b, walkord-a-c, walkord-ab (measured, not assumed). A cursor handed between two adapters that disagree about that skips rows.

The Durable boot arm's Postgres half. It demands DATABASE_URL, opens the pool, refuses a schema this binary was not built for — and then still refuses, naming #403, because the ports it cannot fill are the ones a server loses state without. filesystem/server.md says required means required. The four adapters are deliberately not constructed in assemble (production code that builds something it cannot use is theatre); that they compose out of exactly what the boot path has, and that the schema the migration applied is the schema they query, is asserted by a container-gated boot test instead.

E2E case 11. The crash seam is the AssetIndex port itself, so no production code gains a test hook: tests/support/fault.rs loses exactly one record_blob. The case asserts the session is terminal and failed, the bytes are at their content address, the row is still Pending with no sequence number, nothing references the blob (find_reference is None, reference_count is 0), the collector marks it and reports no dangling reference, and the client's retry publishes. The fault's fire count is asserted first, so the case cannot rot into describing an ordinary upload.

The container tier is gated and says so. Every Postgres case lives under a module named postgres_conformance — which is what the containers nextest group's filterset matches, so a new port's suite joins by being named like the others — and skips with one line naming itself unless CAPSULE_TEST_POSTGRES=1. cargo nextest run on a machine with no container runtime stays green.

A new architecture check. check_chrono_isolation turns dependencies.md's chrono gate into a test. It is per package deliberately: cargo unifies features across a workspace build, so the workspace-wide cargo tree -i chrono lists capsule-server under sea-orm and always will while capsule-cli inherits sea-orm's defaults. What is decidable is whether the server's own manifest asks for chrono — see "Decisions taken inside the manifest" below.

The commit series

Commit What it leaves the tree with
feat(server): add the Postgres plumbing and the migration crate Four ordinals, src/postgres/{mod,error,time,testing}.rs, sea-orm at default-features = false, the containers filterset, check_chrono_isolation. No adapter.
test(server): let the Postgres container harness run on a rootless runtime The harness actually starts a container.
feat(server): add the Postgres asset index index/postgres.rs, the shared is_singular/set_singular, two new suite cases.
feat(server): add the Postgres device-cohort map The Harness/CohortHarness split, cohorts_postgres.rs, store/mod.rs's corrected adapter paragraph.
feat(server): add the Postgres account store and the auth suite auth/conformance.rs (16 cases), accounts_postgres.rs, last_failure_at.
feat(server): add the Postgres quota ledger and the quota suite quota/conformance.rs (9 cases), quota/postgres.rs, quota/tests.rs trimmed to the pure half.
feat(server): open the durable backend's Postgres half at boot DATABASE_URL, the pool, the schema refusal, three container-gated boot cases.
test(server): assert E2E case 11's crash boundary tests/support/fault.rs and the case.
docs: correct the summaries this lane's adapters falsify AGENTS.md, module-map.md, lib.rs, README.md, SLICES.md.
fix(server): clamp an instant to what a microsecond column reads back The to_micros clamp, stored(), three replacement cases. Found by test-rust.
test(server): re-point the durable refusal's binary-smoke case The one binary case the boot change falsified.
test(xtask): the smoke tier is no longer a planned entry The PLANNED_WORKSPACE_DEPENDENCIES guard, re-asserted at three entries.

Two merges of the base branch sit in the series: at 38addc6f and 0c38fce6, the second bringing 459e8af3. Merge commits, never a rebase.

Validation

Every command run inside the worktree Capsule-feat-postgres-adapters-402, at head b09fad7f unless noted.

Command Outcome
mise run check-rust pass (exit 0). All fifteen sub-gates: fmt, clippy (-D warnings, pedantic), rustdoc, i18n-check, i18n-guard, openapi-check-kynos, architecture-check, license-check, translate-readme-check, build-rust, build-check-wasm, build-ffi, lint-check-ffi, gen-bindings, verify-examples.
mise run test-rust pass (exit 0). cargo nextest run --workspace 1829/1829; -p capsule-core --features ffi 729/729; -p capsule-sdk --features ffi 160/160.
mise run check-docs-truth pass. 476 cross-links, 84 endpoint citations, 118 module paths, all resolve.
mise run check-md pass. 168 files, 0 issues. Run because the branch edits five Markdown files.
cargo tree -p capsule-server -i chrono -e no-dev pass — prints nothing. This is the form xtask's new check_chrono_isolation enforces; see below for the workspace-wide form.
DOCKER_HOST=unix:///run/user/2000/podman/podman.sock CAPSULE_TEST_CONTAINER_USERNS=keep-id CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)' pass. 8/8 in 81s: the index suite, the account suite, the cohort suite, the quota suite, the migration round trip, and three boot cases.
cargo nextest run -p capsule-server -E 'test(postgres_conformance)' (gate off, no DOCKER_HOST) pass. All 8 skip and each prints one line naming itself and the three variables. This is the acceptance gap module-map.md sets, asserted rather than claimed.

Nothing is unavailable. The podman user socket is active and the container tier ran for real.

Three failures this branch caused, and what was done

All three were caught by mise run test-rust and none by the targeted runs — because cargo clippy --workspace does not compile #[cfg(test)] modules and a filtered nextest -E … never selected them. Classified caused, all fixed and committed:

  1. postgres::time::tests::the_whole_representable_range_survives_and_nothing_outside_it_is_invented — a real defect, not a stale assertion. Timestamp::MAX is 9999-12-30T22:00:00.999999999Z and Timestamp::from_microsecond refuses anything past the whole second below it, so to_micros would write a number into a NOT NULL column that this server's own reader rejects as corrupt. Fixed by clamping (a3655ef7); see decision 13.
  2. capsule-server::binary::a_durable_backend_refuses_with_the_issue_that_will_honour_it — asserted the durable refusal names #403, which the new DATABASE_URL demand moved. Re-pointed and strengthened (7102ee52).
  3. xtask::architecture::tests::the_valkey_adapters_and_the_smoke_tier_are_the_only_planned_entries — pinned the exact PLANNED_WORKSPACE_DEPENDENCIES contents, which the first commit shrank. Re-asserted (b09fad7f).

The workspace-wide chrono command, stated plainly

dependencies.md says cargo tree -i chrono -e no-dev must resolve to capsule-cli/entity and sea-orm internals only. Run from the root it now also lists capsule-server and capsule-server-migration under sea-orm. That is feature unification, not a dependency this branch added: cargo unifies features across the packages a workspace build selects, capsule-cli inherits sea-orm with its defaults (which is why the gate names capsule-cli/entity), and no manifest edit in capsule-server can change that while it is true of capsule-cli. The decidable question — does the server's own manifest ask for chrono — is answered by the per-package command above, which prints nothing, and is now enforced by check_chrono_isolation rather than read. capsule-server-migration is expected on the path and is a dev-dependency for exactly that reason; the check asserts both halves.

Risks and rollout

Nothing is deployed, so nothing is irreversible. There is no released server binary and no database holding rows; reverting a slice removes its module and its migration ordinal together, and Migrator::down drops that slice's tables. That stops being true the first time a deployment holds real data, which is why the four ordinals were edited in place during the branch rather than superseded.

The Durable boot arm still refuses, and deliberately. A durable serve gets further than it did — it demands DATABASE_URL, opens the pool and checks the schema — and then fails naming #403. This branch's proof is the conformance suites and the boot tests, not a booted durable server; no reviewer should expect otherwise.

Operators gain one obligation. capsule-server-migration up must run before a durable serve, and serve refuses by name if it has not. .env.example and the README say so. The refusal is a missing-ordinal check and deliberately tolerates a newer schema than the binary knows, because that is the normal state during a rolling deploy.

The suite's default run is unchanged. cargo nextest run -p capsule-server needs no container: five gated cases skip with an explicit line each. What that costs is that CI proves nothing about Postgres until the workflow sets CAPSULE_TEST_POSTGRES=1 with a runtime — this branch does not touch CI, so that remains owed.

Cargo.lock grows a lot. testcontainers pulls bollard, and bollard pulls prost/prost-types through its buildkit proto crate. Both are on the architecture check's retired list — as direct member dependencies, which these are not: they are transitive dev-dependencies of a test harness, they are not on any member's manifest and not in [workspace.dependencies], and the check passes. Worth naming because "prost is back in the lock file" reads alarming in a diff. cargo deny check licenses is clean.

Concurrency is asserted by construction, not by the suite. The sequence mint under FOR UPDATE, create's unique index and charge's primary key are all single-transaction guarantees a single-process suite cannot exhibit — index/conformance.rs says so at length, and the same reasoning covers the other three. What the suites assert are the observable consequences; the structural guarantee is in the SQL and is reviewable there.

One case is weaker against the pinned image than in production. the_row_walk_orders_by_the_identifiers_own_bytes cannot distinguish COLLATE "C" from the default on postgres:17-alpine, because musl collates en_US.utf8 byte-for-byte. Measured non-vacuous on glibc; recorded in postgres/testing.rs beside the pin so the next person does not have to rediscover it.

Related Issues

Refs #402 — four of the durable ports, not all thirteen; the deliverable boundary is decision 1.

Refs #446 — "server: Postgres adapters for the remaining durable ports", filed by this lane for the nine that are left (album, directory, moderation, share, drop, escrow, revocation, gc marks, attestation receipts, TOTP), plus the process-level restart variant of E2E case 11.

Not Closes #402: the issue asks for "every durable port", and nine still have only their in-memory adapter.

Decisions taken

The lane's decision record, verbatim.

Issue 402 - server: Postgres adapters and a conformance suite for every durable port
Plan:     v1 (planned against f433d918; executed on the head of lane #401's branch), amended by the orchestrator for the AGENTS.md/module-map correction (D18)
Branch:   feat/postgres-adapters-402
Base:     feat/server-binary-config-operator-commands-401 (head of the W-SERVE PR), stacked; the PR targets that branch
Worktree: /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-postgres-adapters-402
Cause:    -
Touches:  capsule-server/migration/** (new crate capsule-server-migration, lib name server_migration; ordinals m20260902_000001..000004), capsule-server/src/postgres/{mod,error,time,testing}.rs (new), capsule-server/src/index/postgres.rs (new), capsule-server/src/auth/{accounts_postgres.rs (new), conformance.rs (new)}, capsule-server/src/store/{cohorts_postgres.rs (new), conformance.rs (Harness/CohortHarness split), memory.rs}, capsule-server/src/quota/{postgres.rs (new), conformance.rs (new), tests.rs}, capsule-server/src/index/{mod.rs, conformance.rs, memory.rs}, capsule-server/src/{lib.rs, boot.rs (Durable arm's Postgres half), auth/mod.rs, store/mod.rs (:44 comment corrected), quota/mod.rs}, capsule-server/tests/support/fault.rs (new), capsule-server/tests/upload.rs (E2E case 11), capsule-server/Cargo.toml, Cargo.toml/Cargo.lock, .config/nextest.toml (containers filterset), xtask/src/architecture.rs (remove testcontainers, testcontainers-modules from PLANNED_WORKSPACE_DEPENDENCIES), AGENTS.md (the auth/upload-state bullet: adapters are in-memory + redis-rs; Postgres for durable ports), capsule-docs/src/content/docs/design/module-map.md (External Dependency Register: PostgreSQL row no longer claims the two typed state ports; redis-rs row parity wording), SLICES.md (S-C2 owed note, S-C29 owed note, S-C37 owed note ONLY)
Will not: touch any port trait; add a Postgres adapter for AuthStateStore, UploadSessionStore, the ceremony stores or CounterStore (Valkey, #403); add an entity crate; migrate on serve; add a new external crate
Lane:     serialised behind #401; parallel with #403/#404/#407 (disjoint files except boot.rs's Durable arm - #403 fills the Valkey half; whichever merges second resolves one hunk)
Settled:  Adapter seam and Backends enum (#401). Barrels (#399). Base = head of PR #418 -> stacks on #399 -> #401.

Decisions taken.

1. Deliverable boundary
   Taken:    AssetIndex, the four auth account ports, CohortStore, QuotaStore - the ports finalization and sign-in cannot run without - plus the migration crate, the shared conformance pattern, the testcontainers harness with the CAPSULE_TEST_POSTGRES=1 gate, the Durable boot arm's Postgres half, and E2E case 11. Nine remaining durable ports filed.
   Rejected: All thirteen durable ports in one lane - thirteen adapters/suites/migrations in one diff; the four here are what boot durably needs.
   Rejected: Postgres adapters for AuthStateStore/UploadSessionStore (the issue's literal reading) - filesystem/server.md:19 rejects a Postgres session table as "a second implementation of the same contract; Capsule ships exactly one", :21 records the fallback as considered and rejected; #403 owns both ports. Orchestrator amendment: AGENTS.md's bullet and module-map.md's register rows say otherwise and are corrected in this lane (doc-vs-doc D18; the owner doc wins).
   Reverses: add the nine remaining adapters following the same templates; or, for the state ports, add Postgres adapters under a new dependencies.md rationale that supersedes filesystem/server.md:19-21.
   Filed:    the lane files "server: Postgres adapters for the remaining durable ports (album, directory, moderation, share, drop, escrow, revocation, gc, attestation, totp)" and links it.

2. Adapter file layout
   Taken:    Per-port src/<port>/postgres.rs over a shared src/postgres/ (pool, DbErr->StoreError mapping, jiff<->i64, test harness). module-map.md:67-81 assigns ownership per behaviour module; the tree already puts adapters beside their port; no invariant in scope spans two ports.
   Rejected: One src/postgres/ tree holding every adapter - one directory co-owning thirteen contracts.
   Reverses: git mv each <port>/postgres.rs into src/postgres/<port>.rs.

3. How an instant is stored
   Taken:    BIGINT microseconds since the Unix epoch, converted from jiff::Timestamp in src/postgres/time.rs, with sea-orm at default-features = false (no with-chrono/with-time).
   Rejected: TIMESTAMPTZ via with-time (a third datetime crate with no dependencies.md row) or with-chrono (breaks the `cargo tree -i chrono -e no-dev` gate at dependencies.md:83).
   Reverses: enable with-time, switch the four migrations' columns, replace time.rs's two functions.

4. When migrations run
   Taken:    A separate capsule-server-migration binary applies them; serve reads seaql_migrations and refuses to boot on a mismatch; capsule-server takes the migration crate as a dev-dependency only (keeps the chrono gate green; no per-replica migration on rolling deploy).
   Rejected: Migrator::up on serve.
   Reverses: move the migration crate to [dependencies], call Migrator::up in the Durable arm, delete assert_schema_current.

Orchestrator note (host prerequisite, applied 2026-09-01): the podman user socket is now active on this host - `systemctl --user is-active podman.socket` -> active, socket at `/run/user/2000/podman/podman.sock`. Export `DOCKER_HOST=unix:///run/user/2000/podman/podman.sock` (or write `~/.testcontainers.properties`) before running the container-gated conformance suites, so `CAPSULE_TEST_POSTGRES=1` / `CAPSULE_TEST_VALKEY=1` runs are possible locally and are not to be reported as unavailable.

Decisions taken inside the manifest

Same shape; every one of these was taken during implementation and none is sanctioned by the record above.

5. Addresses are compared verbatim, and there is no folded column
   Taken:    `accounts.email` carries a plain unique index. `Registration::AlreadyExists` is
             decided by that index; `authenticate` looks up by the address as given.
   Rejected: The plan's `email_folded TEXT UNIQUE` (and its cousins, `citext` and a `lower()`
             index) - `accounts_memory` compares addresses verbatim and records a test saying
             so, adding that "#402's adapter has to make the same choice". Folding in the
             durable adapter only would make the two adapters disagree about who
             `Foo@example.test` is, which is precisely what one shared suite exists to prevent,
             and case folding is a decision about identity rather than about storage.
   Reverses: add the folded column, index it, and change the port's docs to say the address is
             normalized - which is a slice, not an adapter change.

6. `COLLATE "C"` wherever an asset id or a cohort hash is ordered
   Taken:    `rows`, `tombstoned`, `find_by_address` and `find_reference` order under
             `COLLATE "C"`, which is the deterministic double's `BTreeMap` byte order.
   Rejected: The database's own collation - self-consistent, and *different* from the double's.
             Measured on this host: `LC_ALL=en_US.UTF-8 sort` orders
             `walkord-a-b, walkord-ab, walkord-a-c` where `LC_ALL=C` orders
             `walkord-a-b, walkord-a-c, walkord-ab`. Asset ids are the client-chosen `file_id`
             and are full of hyphens, so this is reachable rather than theoretical, and a
             scrub cursor handed between two adapters that disagree skips rows.
   Cost:     The `assets` primary-key index is in the database's collation, so the scrub's
             ordered walk is a sort rather than an index scan. Acceptable for a bounded
             background pass; a deployment that minds declares the column `TEXT COLLATE "C"`,
             which sea-orm's `ColumnDef` cannot express and which raw DDL in a migration can.
   Reverses: drop the four `COLLATE "C"` clauses and delete
             `the_row_walk_orders_by_the_identifiers_own_bytes`.

7. sea-orm is declared in capsule-server rather than inherited
   Taken:    `sea-orm = { version = "1.1.20", default-features = false, features = [...] }`, the
             workspace pin restated, with a comment tying the two together.
   Rejected: `workspace = true` - cargo lets a member *add* features to an inherited dependency
             but not turn the workspace's defaults **off**, and `capsule-cli`/`capsule-cli/entity`
             need those defaults (which is why the chrono gate names `capsule-cli/entity`).
             Flipping the workspace entry would change their build, and both manifests are
             outside this lane.
   Cost:     Two version strings to keep in step. Guarded by decision 8's check rather than by
             a comment alone.
   Reverses: set `default-features = false` at the workspace and name the default features
             `capsule-cli` and `capsule-cli/entity` actually use in their own manifests.

8. The chrono gate becomes a per-package architecture check
   Taken:    `xtask`'s `check_chrono_isolation` asserts `cargo tree -p capsule-server -i chrono
             -e no-dev` prints nothing, and that `capsule-server-migration` (dev-only, and the
             reason it is dev-only) still does reach chrono.
   Rejected: Leaving `dependencies.md`'s workspace-wide command as the gate. It no longer says
             what it means: cargo unifies features across the packages a workspace build
             selects, so `cargo tree -i chrono -e no-dev` from the root now lists
             `capsule-server` under sea-orm and will while `capsule-cli` inherits the defaults.
             The decidable question - does *this package's manifest* ask for chrono - is what
             `default-features = false` buys, and it is now enforced rather than read.
   Reverses: delete `check_chrono_isolation` and its call site.

9. One container per port, not one per case
   Taken:    Each port contributes exactly one container-backed test running its whole suite in
             one `run_all` pass; the image is pinned at `postgres:17-alpine`, with `PGDATA` off
             the image's declared VOLUME and an optional `CAPSULE_TEST_CONTAINER_USERNS`.
   Rejected: The plan's shared `OnceCell<ContainerAsync>` with one `#[tokio::test]` per case -
             nextest runs a **process per test**, so a shared container buys nothing and thirty
             cases would be thirty containers. `index/conformance.rs` already said a Postgres
             smoke test should be "one `run_all` call".
   Cost:     A Postgres failure names the suite rather than the case. Mitigated by every case
             already passing against the double, so a Postgres-only failure is a short list.
   Known weaker here than in production: alpine is musl and collates `en_US.utf8`
             byte-for-byte, so `the_row_walk_orders_by_the_identifiers_own_bytes` cannot tell
             `COLLATE "C"` from the default *on this image*. Verified non-vacuous on glibc by
             the `sort` measurement in decision 6. `postgres:18.0` was tried and its entrypoint
             does not survive `--userns=keep-id`, which is the rootless-podman workaround.
   Reverses: move to a glibc tag once the entrypoint issue is resolved, or drop the userns
             workaround on a host whose home directory is world-traversable.

10. `MaintenanceNeedsMemory` is kept and re-argued rather than removed
   Taken:    `gc`/`purge`/`scrub` still require `--memory`, and the error now names what is
             actually missing: the collector's marks (#446) and the upload sessions the scrub
             reconciles against (#403).
   Rejected: Wiring the durable maintenance arm. `CollectionContext` needs `CollectionStore` and
             `ScrubContext` needs `UploadSessionStore`; neither has a durable adapter, and a
             collector over a durable index with a mark store that forgets can only ever mark -
             which is worse than refusing, because it looks like it worked.
   Reverses: fill both stores, then delete the variant and its binary-smoke case.

11. The account suite is one test, not one per case
   Taken:    `the_in_memory_account_store_conforms` runs all sixteen cases on one harness.
   Rejected: The `conformance_cases!` shape `store` and `quota` use - every case here registers
             an account, which costs an Argon2id hash at the server-side parameter set, and
             sixteen fresh harnesses would pay for sixteen decoys on top (measured: 33s for the
             one-harness run). Every case scopes its own addresses, which is what makes sharing
             safe.
   Reverses: add the macro; the cases are already independent.

12. `accounts_memory.rs`'s inline cases are left in place
   Taken:    The eleven contract-level cases now in `auth/conformance.rs` also remain as inline
             tests on the in-memory adapter.
   Rejected: Deleting them, which the plan's "cases move out where they are contract-level"
             asks for - `capsule-server/src/auth/accounts_memory.rs` is not on the record's
             `Touches:` line, and the base branch rewrote that file's test module during this
             lane. The duplication costs suite time and nothing else.
   Reverses: delete the promoted cases from `accounts_memory.rs`'s `mod tests`.

13. Instants are clamped on the way into a microsecond column, and truncated on the way back out
   Taken:    `to_micros` clamps to the range `from_micros` accepts (derived from `jiff`'s own
             constants), and `stored()` is what an adapter puts an incoming instant through
             before keeping it in a record it returns without re-reading.
   Rejected: The obvious `at.as_microsecond()`. It is not total: `Timestamp::MAX` is
             `9999-12-30T22:00:00.999999999Z` and `Timestamp::from_microsecond` refuses anything
             past the whole second below it, so the unclamped conversion writes a number into a
             `NOT NULL` column that this server's own reader rejects as corrupt - a row of its
             own making. `store::deadline` clamps at the same boundary for the same reason.
   Rejected: Leaving sub-microsecond truncation undocumented. Four sites in `index/postgres.rs`
             return a row built in Rust rather than read back; without `stored()` the value they
             hand a caller differs from the value the next `read` produces.
   Found by: `mise run test-rust`, not by the conformance suites - every instant they use lands
             on a whole second, which is the shape of hole a suite leaves when its fixtures are
             tidy. Three cases in `postgres::time` now state the properties directly.
   Reverses: drop the clamp and `stored()`, and accept that an instant in the last second of
             year 9999 is an undecodable row.

14. `S-C37`'s row moves from `done*` to `done`
   Taken:    Its owed line was the Postgres adapter - "where the row lock this design depends on
             actually lives" - and that is closed, so there is no remainder for `done*` to point
             at.
   Rejected: Keeping `done*`, which the `RETIRED`-row legend would prefer literally. `S-C21`
             two rows above is a RETIRED row at `done` for the same reason: the defect was
             closed by the rebuild rather than by the retired code.
   Reverses: set it back to `done\*` and add an `Owed ->` pointer.

Paths edited outside the record's Touches: line

Declared rather than widened silently. Each is named, with why.

Path Why
capsule-server/tests/support/mod.rs The record lists tests/support/fault.rs (new); a module in tests/support/ has to be declared, and the decorator has to be wired into the fixture the case drives. The plan's own lane manifest lists this file. Change: one mod declaration, one field, one constructor line, and the four contexts now reading the index through the seam.
capsule-server/README.md It carried the same two claims as capsule-server/src/lib.rs (which is on the line) — "every adapter is in-memory", "no Postgres, Valkey or filesystem adapter is written" — and its operator synopsis said --memory is required because the only index adapter is the in-memory one. All three are false as of this branch. Correcting lib.rs and leaving its README saying the opposite is worse than either.
capsule-server/.env.example Its Backends block said no adapter reads DATABASE_URL. One now does, and the migration command an operator must run before serve had nowhere else to be written down. The Valkey half is left for #403.
capsule-server/tests/binary.rs Its a_durable_backend_refuses_with_the_issue_that_will_honour_it case asserted the durable refusal names #403. The durable arm now demands DATABASE_URL first, so the refusal names the missing variable instead. The property the case exists for — never fall back to the in-memory adapters — is unchanged and is what it still asserts, plus a new one it could not make before: stdout is empty, so the process did not bind a listener.
capsule-server/migration/src/m20260902_000002_accounts.rs On the line as part of capsule-server/migration/**; noted only because the ordinal was edited in place after being committed rather than superseded by a fifth. No deployment holds a row, and last_failure_at is what the lockout's decay is measured from.

CLAUDE.md is a symlink to AGENTS.md and is not itself changed; the corrected bullet reaches it through the link.

Unresolved review notes

The durable ports have had one production adapter shape named in their docs
since `S-C29` and `S-C37` — PostgreSQL — and no code. This lands the half every
adapter needs before any of them can be written, and no adapter yet.

`capsule-server/migration` is a new workspace member holding the four ordinals
issue #402's adapters need: the asset index, the account cluster, the device
cohorts and the quota ledger. Its lib is `server_migration` rather than
`migration` because `capsule-cli/migration` already publishes that name, and two
libraries called `migration` in one workspace is legal and a trap.

`capsule-server` takes it as a **dev-dependency only**. That is a decision, not
layering: `sea-orm-migration` pulls sea-orm with its default `with-chrono`
feature, and design/dependencies.md makes chrono a review-blocking gate outside
`capsule-cli/entity`. So `serve` cannot migrate, and instead
`postgres::assert_schema_current` reads `seaql_migrations` and refuses to boot
on a missing ordinal, naming the command that fixes it — which is also the
safer rollout, since a server that migrates on start migrates once per replica
during a rolling deploy. `EXPECTED_MIGRATIONS` is compiled in because the server
cannot link the migrator; a `cfg(test)` assertion compares the two, so the copy
cannot drift silently.

sea-orm enters `capsule-server` spelled out rather than inherited, because cargo
lets a member add features to a workspace dependency but not turn its defaults
off, and `capsule-cli` needs those defaults. Every instant in the schema is a
`BIGINT` of epoch microseconds converted in `postgres::time`: without a datetime
feature there is no Rust binding for `TIMESTAMPTZ` at all, and both of sea-orm's
are refused — `with-chrono` breaks the gate, `with-time` would be a third
datetime crate with no row in the dependencies table. Every comparison these
tables make is an ordering on one column, and integers order identically.

`postgres::error` maps `DbErr` onto the three `StoreError` variants once for
every adapter, and deliberately reserves `Unavailable` for failures that happen
*before* a statement is sent. A connection dropped mid-statement has not
"certainly not happened", which is what that variant promises; it is `Rejected`,
whose contract is that whether state changed is unknown.

`testcontainers` and `testcontainers-modules` were pinned at the workspace and
consumed by nothing, sanctioned by xtask's `PLANNED_WORKSPACE_DEPENDENCIES`
because "no test starts a container yet". They are now dev-dependencies of
`capsule-server`, so the two entries leave that list. The `containers` nextest
group, empty since `S-C59`, gains its filterset: every container-backed case
lives under a module named `postgres_conformance`, so a new port's suite joins
the group by being named like the others rather than by editing a list.

The architecture check gains `check_chrono_isolation`, which turns the gate into
a test. It is per package on purpose: cargo unifies features across a workspace
build, so the workspace-wide `cargo tree -i chrono` lists `capsule-server` under
sea-orm and always will while `capsule-cli` inherits the defaults. What is
decidable — and what the rule is actually about — is whether the server's *own*
manifest asks for chrono, and `cargo tree -p capsule-server -i chrono -e no-dev`
prints nothing.

Refs #402
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: b09fad7
Status: ✅  Deploy successful!
Preview URL: https://eeeb0c9e.capsule-22k.pages.dev
Branch Preview URL: https://feat-postgres-adapters-402.capsule-22k.pages.dev

View logs

…ntime

The harness landed with the plumbing and had never been run. Starting it on a
rootless podman host turned up two failures, both of which would have met the
next person as an unexplained "permission denied".

The image's declared `VOLUME` is the first. An anonymous volume is created with
an ownership the container's own user cannot `chmod`, so `initdb` dies before
Postgres ever listens. `PGDATA=/tmp/pgdata` puts the cluster in the container's
own writable layer, which for a database that lives for one test is strictly
better anyway: nothing to create, nothing to reap.

The second is user namespaces. Under rootless podman a container process that is
not the container's root maps to a host *subuid*, and that subuid has to
traverse the image store to reach anything — so on a machine whose home is not
world-traversable the official image dies at
`gosu postgres /usr/local/bin/docker-entrypoint.sh` with a message that names
the entrypoint and says nothing about why. `--userns=keep-id` maps every
container uid onto the invoking user and the entrypoint then skips its
drop-privileges branch entirely. It is read from
`CAPSULE_TEST_CONTAINER_USERNS` rather than hardcoded because `keep-id` is
podman's spelling and Docker rejects it, so CI must not carry it. The skip line
every gated case prints now names all three variables.

The image tag moves off the module's `11-alpine` default to `17-alpine`: the
server targets a currently-supported PostgreSQL and a floating tag makes "it
passed yesterday" unfalsifiable. What that image cannot prove is recorded beside
the pin — musl collates `en_US.utf8` byte-for-byte, so a case that asserts byte
ordering is weaker here than on the glibc PostgreSQL a deployment runs.

Refs #402
`S-C37`'s central claim is that a sequence number is allocated inside the same
critical section that makes its row readable — and its own conformance suite
says a single-process suite cannot exhibit the race that claim is about, so the
structure has to live in the adapter. This is that adapter: every mutating
operation is one `BEGIN … COMMIT` that takes `SELECT … FOR UPDATE` on the asset
row, mints from `owner_sequences` inside it, and commits both together.

`owner_sequences` is a counter row and never a `SEQUENCE` or a `bigserial`.
`nextval` is deliberately non-transactional: it hands 5 and 6 to two concurrent
finalizations and does not roll back, so a reader who sees 6 commit first can
page past 5 forever. That is the whole of `S-C21`, and a counter row updated by
the allocating transaction makes allocation order equal commit order.

Each write hydrates the whole `AssetRow` under the lock, applies the same free
functions the in-memory adapter applies, and writes it back. Expressing the
state machine as a chain of `UPDATE … WHERE` statements would be a second
statement of rules the port already fixes, in a language the conformance suite
cannot see. `is_singular` and `set_singular` move from `index/memory.rs` up to
`index/mod.rs` beside `entry_for` for exactly that reason: which roles are
singular is a security property — `record_blob` refuses to re-point a role a
signature names — and the `S-C52` retention rule inside `set_singular` is the
kind of thing two implementations drift on, where the drift reclaims the
server's own rebuttal evidence.

`reference_count` is a `COUNT(*)`, never a stored counter, per the refcount rule
in design/filesystem/server.md: a counter is a second copy of a derivable fact,
and one that drifts low deletes a live blob.

Two conformance cases are added, and they are new coverage rather than
adapter-specific tests: `AssetIndex::rows` — the scrub's walk — had none at all.
The first asserts it covers pending, visible and tombstoned rows and resumes at
any page size. The second asserts the walk orders by the identifier's own bytes,
which is what made the adapter pin `COLLATE "C"`: asset ids are the manifest's
client-chosen `file_id` and are full of punctuation, and a glibc PostgreSQL
ignores `-` at the primary collation level, so `walkord-a-b, walkord-ab,
walkord-a-c` there against `walkord-a-b, walkord-a-c, walkord-ab` by bytes. A
cursor handed between two adapters that disagree about that skips rows.

Refs #402
`CohortStore` is the one port in `crate::store` whose production adapter is not
Valkey, and its own docs say why: a session store forgets a cohort exactly when
"have I seen this device before?" becomes worth asking, so the map has to
outlive the sessions that carried it. That makes it Postgres's, and it makes the
shared harness the wrong shape.

`conformance::Harness` is therefore split. `CohortHarness` carries the cohort map
and the `advance` seam; `Harness` extends it with the five volatile stores. A
Postgres-backed harness that had to implement `auth()`, `uploads()` and three
ceremony stores to run four cohort cases would have to invent five adapters it
will never have. One deterministic double still implements both, so `run_all`
still covers every case in the module — and the four cohort cases now get one
`#[tokio::test]` each in `store::memory` rather than only riding inside
`run_all`, which is what makes a cohort failure name the property that broke.

The adapter itself is one upsert. The composite primary key **is** the
idempotence the port states — seeing the same cohort twice is one row — so
`observe` is `INSERT … ON CONFLICT DO UPDATE SET last_seen = … RETURNING` and
never a read followed by a branch, whose race is two devices of one account
signing in at once. `first_seen` is untouched by the update, which is the half
that lets a client say "a device you've used before" rather than presenting a
stranger. The listing's tie-break is `COLLATE "C"` so the total order the port
contracts is the one the deterministic double produces: a cohort hash is
client-asserted text, and a locale collation orders it differently.

`store/mod.rs`'s adapter paragraph is corrected in the same change. It said
three adapters were planned per port — Postgres, Valkey and the double — which
was never true of any port in this module and was the one line in the tree
pointing at a Postgres session table, two sentences before its own paragraph
rejecting one. It now says two per port, and which two.

Refs #402
Four ports — registry, directory, profiles, password change — over one
`accounts` row. They are four ports because they answer four questions with four
disclosure contracts, which their module docs argue at length; none of that makes
them four stores, and splitting the row would put the lockout counter somewhere
the password change that must clear it cannot reach in the same statement.

`auth/conformance.rs` is new, and the four ports share it for the same reason
they share a table: most of the properties worth asserting cross them. A password
change is only interesting because the directory then grants the new password and
refuses the old one; a lockout is only interesting because a password change
clears it. Sixteen cases, run against `InMemoryAccounts` before this adapter was
written and against both since.

The suite asks the harness for `lockout_attempts` rather than reading the
constant, because the threshold is a deployment setting (`LOCKOUT_MAX_ATTEMPTS`)
— a suite that hardcoded ten would silently stop testing the ceiling the moment a
deployment moved it. Two things it deliberately does not assert are named in its
own docs rather than faked: the timing-equalized miss is a response *time*, and a
timing assertion is flaky by nature, so what is asserted is the consequence a
suite can see — both answers are one value; and `create`'s atomicity against two
racing registrations cannot be exhibited in one process, so the structural
guarantee stays in the adapter as a unique index.

Where this adapter is stricter than the in-memory one is the failed-attempt
bookkeeping, and `accounts_memory` predicted it: "that is the right trade for a
development adapter and it is not the trade a Postgres adapter should make: there
the increment is one statement". It is one statement here — the decay, the reset
of a stale run and the increment are a single `UPDATE … SET failures = CASE …` —
so two simultaneous wrong passwords are two counted failures rather than one.
Argon2id still runs outside every statement, because a verification held inside a
transaction is a row lock held for the length of the slowest primitive in the
process. An attempt made while an account is locked is refused without being
counted, so hammering somebody else's account cannot keep it locked forever.

The clock is injected and the window is never measured against the database's
`now()`: the suite has to move fifteen minutes without sleeping for them, and a
server and its database disagreeing about the hour should not change who is
locked out.

`email` carries a plain unique index — no `lower()`, no `citext`, no folded
companion column. Addresses are compared verbatim because case folding is a
normalization policy no port describes, and `addresses_are_compared_verbatim`
asserts it precisely because a `citext` column is the kind of thing that changes
an identity decision without anybody making one.

The accounts ordinal gains `last_failure_at`, edited in place rather than
appended as a fifth migration: no deployment holds a row, and the column is what
the lockout's decay is measured from. Without it the count is a one-way door —
no surface in this server can clear a lockout, so a permanent one is a
permanently lost account.

Also repairs a rustdoc link in `index/postgres.rs` that `cargo doc` flagged.

Refs #402
The ledger's cases lived in `quota/tests.rs` against `InMemoryQuota` only, which
made the double an unproven stand-in for exactly the adapter that has to get
concurrency right. They move to `quota/conformance.rs` and run against both. The
pure half stays where it was: `state_of` and `admits` take no store, and a suite
generic over an adapter cannot say anything about a function that takes none.

Three cases are new rather than promoted. `an_uncharged_account_owes_nothing`
covers the read path an adapter with a stored total would get wrong first.
`the_already_attributed_answer_says_nothing_about_who_holds_it` asserts the
disclosure property structurally — telling a caller "somebody else holds these
bytes" would answer, from a quota endpoint, the cross-tenant question
`AssetIndex::find_by_address` is owner-scoped to avoid.
`a_collector_release_clears_the_over_limit_clock` was in `tests.rs` and is kept
because both releases have to credit identically.

In the adapter, `used` is `SUM(size)` over the attribution rows and never a
stored column, for the reason `reference_count` is a query: a stored total is a
second copy of a derivable fact, and one that drifts low hands somebody free
storage. What cannot be derived is *when* an account crossed the hard limit and
has not been under it since, so that single instant is the whole of
`quota_usage`.

`charge` is `INSERT … ON CONFLICT (address) DO NOTHING` and the row count is the
answer, so two concurrent sessions for one address cannot both read
"unattributed" and both debit — neither reads. The transaction around it is for
the second half: a debit that crosses the limit has to re-total and stamp
`over_since`, and a crash between the two would leave an account over its limit
with no crossing recorded. `state_of` models that state deliberately, treating it
as newly over rather than expired so a missing timestamp cannot lock somebody out
of the writes that free space — but leaving it reachable when one statement away
is a choice, not a fallback. `WHERE over_since IS NULL` is what stamps the
crossing once, so a later charge while still over does not restart the grace
window.

Both releases clear the clock unconditionally, exactly as the in-memory ledger's
one `credit` helper does: an account still over after a release gets a fresh
window rather than inheriting a running one, and two copies of that rule would
eventually disagree.

Refs #402
`Backends::Durable` refused before it did anything. It now demands
`DATABASE_URL`, opens the pool, and refuses a database whose schema is not the
one this binary was built for — naming `capsule-server-migration up`, because
the server cannot migrate: it does not link the migrator (the `chrono` gate in
`capsule-server/migration`'s manifest), and a server that migrated on start would
run the same schema change once per replica during a rolling deploy.

Then it still refuses, and that is the point rather than a shortfall. The ports
it cannot fill — session state, upload sessions, the ceremony stores and the
rate-limit counters — are the ones a server loses state without, and
design/filesystem/server.md is explicit that required means required. So the arm
does everything it honestly can, says exactly what is missing, and #403 turns
the last `Err` into an `Ok`. The refusal now names only #403 rather than #402 as
well.

The four adapters are deliberately **not** constructed in `assemble`: production
code that builds something it cannot use is theatre. What #403 needs to know —
that its one hunk will type-check, and that the schema the migration applied is
the schema the adapters query — is asserted instead by
`every_postgres_adapter_composes_from_the_boot_configuration`, which builds all
four from a real `Config` and asks each one a question through its port.

`DATABASE_URL` is demanded by this path rather than by `Demands::Serve`, because
`gc`, `purge` and `scrub` load the same configuration and need neither backend
URL. `a_durable_backend_without_a_database_url_refuses_by_name` is the assertion
that the demand is made rather than discovered as a `None` further in; the case
it replaces asserted the old blanket refusal, which no longer happens.

`MaintenanceNeedsMemory` said the only index adapter written was the in-memory
one. That stopped being true with this change, so it now says what is actually
missing on the workers' own side: the collector marks a blob on one pass and
sweeps it on a later one, so a `CollectionStore` that forgets can only ever mark
(#446); and the scrub reconciles the index against the upload sessions, which is
how it tells a live transfer from an orphan (#403). It still says `--memory` and
still never says `VALKEY_URL`, which is what its binary-smoke case asserts.

`.env.example`'s Backends block said no adapter reads either URL. Its Postgres
half is corrected and the migration command is written down where an operator
setting `DATABASE_URL` will read it; the Valkey half is left for #403.

The container harness grows `url()` and `roll_back()`, which is what lets the
unmigrated-database case reach the state a deployment is in between
`compose up` and the migration command.

Refs #402
Finalization's order is the contract and it is the way round it is *because* of
this case: the blob is committed onto its content address — a rename and an
fsync, irreversible — and only then recorded against its asset. A crash in that
window leaves a blob nothing references, which is the safe half of the trade;
the other order produces a dangling reference the feed would serve and the scrub
would report as an integrity error that is never auto-repaired. Until now that
argument was a paragraph in `upload/finalize.rs` with nothing exercising it.

The seam is the `AssetIndex` port itself, so no production code gains a test
hook. `tests/support/fault.rs` wraps the index the fixture already builds and
loses exactly one `record_blob` — a crash is a single transaction that never
commits, not a database that stopped answering, and a fault that fired forever
would be testing `SwitchableIndex`'s case instead. It is in the chain for every
fixture rather than swapped in by a second constructor, disarmed, delegating.

The case asserts the state a recovering operator would look at, in that order:
the session is terminal and failed rather than claimed forever; the bytes are at
their content address, because custody was taken before the window; the asset row
is still `Pending` with no sequence number, so there is no zombie visible row;
nothing references the blob — `find_reference` is `None` and `reference_count` is
0, which is the property the ordering exists to guarantee; the collector marks it
and reports no dangling reference, so the orphan is reclaimable rather than
permanent; and the client's retry publishes, because `BlobStore::commit` is
idempotent on identical ciphertext and the second transfer lands on the occupied
address.

The fault's fire count is asserted before anything else. A fault that never fired
would leave every assertion after it describing an ordinary successful upload,
which is the way this kind of test rots.

A *process*-level restart — a real kill, and a second process over the same blob
root and database — belongs to the binary-smoke tier and is filed with the
remaining durable adapters (#446).

Refs #402
Four documents said things that stopped being true, and two of them said things
that were never true.

**AGENTS.md** listed PostgreSQL among the adapters for authentication state and
upload-session state. `filesystem/server.md` — the owner document — rejects a
Postgres-resident session table outright: it "would be a second implementation of
the same contract; Capsule ships exactly one", and it records the
Postgres-instead-of-Valkey fallback as considered and rejected because emulating
TTL and expiry in SQL is the generic TTL abstraction the module map declines to
introduce. The bullet is a summary of that document and disagreed with it. It now
says what PostgreSQL *is* the adapter for: the durable records.

**module-map.md**'s register said the same thing in its PostgreSQL row ("default
implementations of the two typed state ports") and had the `redis-rs` row
promising parity "with the PostgreSQL and in-memory adapters" for ports that have
no PostgreSQL adapter and are not getting one. Both rows are corrected, and the
PostgreSQL row's acceptance gaps are marked discharged for the four adapters that
now exist rather than left as a list nobody has walked.

**`capsule-server/src/lib.rs` and its README** both said "every adapter is
in-memory" and "no Postgres, Valkey or filesystem adapter is written". Four are
now. They also say what has not changed and is the reason the ordering was
deliberate: the suite still runs without a container, because every Postgres case
is gated and prints one line naming itself when it skips. The README's operator
synopsis said `--memory` is required because the only index adapter is the
in-memory one, which is exactly what stopped being true; the real reason is the
collector's marks (#446) and the upload sessions the scrub reconciles (#403).

**SLICES.md**: `S-C2`, `S-C29` and `S-C37`. `S-C37`'s owed line said the Postgres
adapter is "where the row lock this design depends on actually lives — the
in-memory adapter's mutex stands in for it and proves nothing about it", which
was the whole remainder and is closed; its status follows `S-C21`'s precedent in
the same lane, a RETIRED row whose defect the rebuild closed. `S-C29`'s owed line
now names only the Valkey adapters, and records the `Harness`/`CohortHarness`
split and the `store/mod.rs` correction the cohort map's adapter forced. `S-C2`
records that the feed's Postgres half is `index/postgres.rs` and that its paging
and monotonicity cases run there unchanged, which is the point of the suite
living in `src/`.

Refs #402
`postgres::time`'s round-trip case asserted `Timestamp::MAX` survives, and it
does not: `MAX` is `9999-12-30T22:00:00.999999999Z` and
`Timestamp::from_microsecond` accepts nothing past the whole second below it. So
the conversion was not total, and the failure mode was the worst shape available
— `to_micros` would happily write a number into a `NOT NULL` column that this
server's own reader rejects as corrupt.

`to_micros` now clamps to the range `from_micros` accepts, which is the same
choice `store::deadline` makes and for the same reason: an instant in the last
second of year 9999 is indistinguishable from never. The bounds are derived from
`jiff`'s own constants rather than written down, so a repin cannot move them out
from under this.

The second edge is sub-microsecond precision, which a `BIGINT` cannot carry. It
matters in exactly one place: an adapter that builds a record in Rust and returns
it without reading it back hands the caller a value the next read does not
produce. `stored()` is what those four sites in `index/postgres.rs` now put the
instant through — `record_blob`'s `updated_at`, `tombstone`'s, and `apply_op`'s
`updated_at` and `retention_until`. Everything else in the four adapters already
returned a value it had read back.

The conformance suites did not catch either one: every instant they use lands on
a whole second, which is the shape of hole a suite leaves when its fixtures are
tidy. Three cases now state the properties directly — what can be read back, that
truncation happens and `stored` is idempotent, and that the clamp is a clamp
rather than a wrap.

The local `stored` in `apply_op` (the album's high-water epoch) is renamed to
`album_epoch`; it shadowed the new helper.

Refs #402
`a_durable_backend_refuses_with_the_issue_that_will_honour_it` asserted the
refusal names `#403`, which was true while nothing read either backend URL. The
durable arm now demands `DATABASE_URL` first — it is what the Postgres half
opens the pool from — so a deployment that set `VALKEY_URL` and not the other is
told which variable is missing rather than pointed at an issue number.

The property the case exists for is unchanged and is what it still asserts:
falling back to the in-memory adapters is the one thing that must never happen.
It now also asserts stdout is empty, because `serve` prints the address it bound
to and a silent fallback would look exactly like a successful start — which the
old assertion could not have distinguished.

The `#403` refusal is still there, one step further in, and is asserted where it
can be reached: `the_durable_arm_clears_postgres_and_refuses_on_the_valkey_ports`
needs a live database to get past the schema check, so it lives at the library
tier under the container gate.

Refs #402
`the_valkey_adapters_and_the_smoke_tier_are_the_only_planned_entries` pinned the
exact contents of `PLANNED_WORKSPACE_DEPENDENCIES`, which is what that guard is
for: the list is the one place an unused pin can hide, so changing it should be a
deliberate edit in two places rather than one.

`testcontainers` and `testcontainers-modules` were exempted because "no test
starts a container yet". The Postgres conformance suites are tests that start
one, so both moved into `capsule-server`'s dev-dependencies and out of this list,
and the exemption stopped describing a decision. Renamed and re-asserted at three
entries.

Shrinking is the shape a planned pin is meant to leave in: it graduates into a
member's manifest, where `check_dependencies` can see it.

Refs #402
@justin13888 justin13888 changed the title [FEAT] Postgres adapters and a conformance suite for the durable ports [FEAT] Postgres adapters and a conformance suite for four durable ports Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant