[FEAT] Postgres adapters and a conformance suite for four durable ports - #447
Open
justin13888 wants to merge 14 commits into
Open
[FEAT] Postgres adapters and a conformance suite for four durable ports#447justin13888 wants to merge 14 commits into
justin13888 wants to merge 14 commits into
Conversation
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
Deploying capsule with
|
| 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 |
…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
…eat/postgres-adapters-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
…eat/postgres-adapters-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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The durable ports have named one production adapter shape in their own docs since
S-C29andS-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, theDurableboot arm's Postgres half, and E2E case 11.Summary
The migration crate.
capsule-server/migration(packagecapsule-server-migration, libserver_migration) holds four ordinals: the asset index, the account cluster, the device cohorts and the quota ledger.capsule-servertakes it as a dev-dependency only —sea-orm-migrationpulls sea-orm with its defaultwith-chronofeature, anddependencies.mdmakes chrono a review-blocking gate outsidecapsule-cli/entity. Soservecannot migrate; it readsseaql_migrationsand refuses to boot on a missing ordinal, namingcapsule-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.
AssetIndexindex/postgres.rsSELECT … FOR UPDATE, an upsert onowner_sequences, the state flip,COMMIT. Never aSEQUENCE:nextvalis non-transactional, which is the skip windowS-C21is about.AccountRegistry/AccountDirectory/AccountProfiles/PasswordChangeauth/accounts_postgres.rsaccountsrow behind four ports;AlreadyExistsdecided by a unique index, not a read-then-write; the lockout's decay, reset and increment in oneUPDATE … CASE, whichaccounts_memorypredicted was the trade a Postgres adapter should make and it could not.CohortStorestore/cohorts_postgres.rsQuotaStorequota/postgres.rsINSERT … 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.rsandquota/conformance.rsare new (sixteen and nine cases);store/conformance.rs'sHarnesssplits intoHarnessandCohortHarness, because the cohort map is the one port there whose production adapter is not Valkey;index/conformance.rsgains 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.COLLATE "C". Asset ids are the manifest's client-chosenfile_idand are full of punctuation, and a glibc PostgreSQL ignores-at the primary collation level:LC_ALL=en_US.UTF-8 sortorderswalkord-a-b, walkord-ab, walkord-a-cwhereLC_ALL=Corderswalkord-a-b, walkord-a-c, walkord-ab(measured, not assumed). A cursor handed between two adapters that disagree about that skips rows.The
Durableboot arm's Postgres half. It demandsDATABASE_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.mdsays required means required. The four adapters are deliberately not constructed inassemble(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
AssetIndexport itself, so no production code gains a test hook:tests/support/fault.rsloses exactly onerecord_blob. The case asserts the session is terminal and failed, the bytes are at their content address, the row is stillPendingwith no sequence number, nothing references the blob (find_referenceisNone,reference_countis 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 thecontainersnextest group's filterset matches, so a new port's suite joins by being named like the others — and skips with one line naming itself unlessCAPSULE_TEST_POSTGRES=1.cargo nextest runon a machine with no container runtime stays green.A new architecture check.
check_chrono_isolationturnsdependencies.md's chrono gate into a test. It is per package deliberately: cargo unifies features across a workspace build, so the workspace-widecargo tree -i chronolistscapsule-serverunder sea-orm and always will whilecapsule-cliinherits 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
feat(server): add the Postgres plumbing and the migration cratesrc/postgres/{mod,error,time,testing}.rs, sea-orm atdefault-features = false, thecontainersfilterset,check_chrono_isolation. No adapter.test(server): let the Postgres container harness run on a rootless runtimefeat(server): add the Postgres asset indexindex/postgres.rs, the sharedis_singular/set_singular, two new suite cases.feat(server): add the Postgres device-cohort mapHarness/CohortHarnesssplit,cohorts_postgres.rs,store/mod.rs's corrected adapter paragraph.feat(server): add the Postgres account store and the auth suiteauth/conformance.rs(16 cases),accounts_postgres.rs,last_failure_at.feat(server): add the Postgres quota ledger and the quota suitequota/conformance.rs(9 cases),quota/postgres.rs,quota/tests.rstrimmed to the pure half.feat(server): open the durable backend's Postgres half at bootDATABASE_URL, the pool, the schema refusal, three container-gated boot cases.test(server): assert E2E case 11's crash boundarytests/support/fault.rsand the case.docs: correct the summaries this lane's adapters falsifyfix(server): clamp an instant to what a microsecond column reads backto_microsclamp,stored(), three replacement cases. Found bytest-rust.test(server): re-point the durable refusal's binary-smoke casetest(xtask): the smoke tier is no longer a planned entryPLANNED_WORKSPACE_DEPENDENCIESguard, re-asserted at three entries.Two merges of the base branch sit in the series: at
38addc6fand0c38fce6, the second bringing459e8af3. Merge commits, never a rebase.Validation
Every command run inside the worktree
Capsule-feat-postgres-adapters-402, at headb09fad7funless noted.mise run check-rust-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-rustcargo nextest run --workspace1829/1829;-p capsule-core --features ffi729/729;-p capsule-sdk --features ffi160/160.mise run check-docs-truthmise run check-mdcargo tree -p capsule-server -i chrono -e no-devxtask's newcheck_chrono_isolationenforces; 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)'cargo nextest run -p capsule-server -E 'test(postgres_conformance)'(gate off, noDOCKER_HOST)module-map.mdsets, 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-rustand none by the targeted runs — becausecargo clippy --workspacedoes not compile#[cfg(test)]modules and a filterednextest -E …never selected them. Classified caused, all fixed and committed:postgres::time::tests::the_whole_representable_range_survives_and_nothing_outside_it_is_invented— a real defect, not a stale assertion.Timestamp::MAXis9999-12-30T22:00:00.999999999ZandTimestamp::from_microsecondrefuses anything past the whole second below it, soto_microswould write a number into aNOT NULLcolumn that this server's own reader rejects as corrupt. Fixed by clamping (a3655ef7); see decision 13.capsule-server::binary::a_durable_backend_refuses_with_the_issue_that_will_honour_it— asserted the durable refusal names#403, which the newDATABASE_URLdemand moved. Re-pointed and strengthened (7102ee52).xtask::architecture::tests::the_valkey_adapters_and_the_smoke_tier_are_the_only_planned_entries— pinned the exactPLANNED_WORKSPACE_DEPENDENCIEScontents, which the first commit shrank. Re-asserted (b09fad7f).The workspace-wide chrono command, stated plainly
dependencies.mdsayscargo tree -i chrono -e no-devmust resolve tocapsule-cli/entityand sea-orm internals only. Run from the root it now also listscapsule-serverandcapsule-server-migrationunder sea-orm. That is feature unification, not a dependency this branch added: cargo unifies features across the packages a workspace build selects,capsule-cliinherits sea-orm with its defaults (which is why the gate namescapsule-cli/entity), and no manifest edit incapsule-servercan change that while it is true ofcapsule-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 bycheck_chrono_isolationrather than read.capsule-server-migrationis 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::downdrops 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
Durableboot arm still refuses, and deliberately. A durableservegets further than it did — it demandsDATABASE_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 upmust run before a durableserve, andserverefuses by name if it has not..env.exampleand 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-serverneeds no container: five gated cases skip with an explicit line each. What that costs is that CI proves nothing about Postgres until the workflow setsCAPSULE_TEST_POSTGRES=1with a runtime — this branch does not touch CI, so that remains owed.Cargo.lockgrows a lot.testcontainerspullsbollard, andbollardpullsprost/prost-typesthrough 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 licensesis clean.Concurrency is asserted by construction, not by the suite. The sequence mint under
FOR UPDATE,create's unique index andcharge's primary key are all single-transaction guarantees a single-process suite cannot exhibit —index/conformance.rssays 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_bytescannot distinguishCOLLATE "C"from the default onpostgres:17-alpine, because musl collatesen_US.utf8byte-for-byte. Measured non-vacuous on glibc; recorded inpostgres/testing.rsbeside 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.
Decisions taken inside the manifest
Same shape; every one of these was taken during implementation and none is sanctioned by the record above.
Paths edited outside the record's
Touches:lineDeclared rather than widened silently. Each is named, with why.
capsule-server/tests/support/mod.rstests/support/fault.rs (new); a module intests/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: onemoddeclaration, one field, one constructor line, and the four contexts now reading the index through the seam.capsule-server/README.mdcapsule-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--memoryis required because the only index adapter is the in-memory one. All three are false as of this branch. Correctinglib.rsand leaving its README saying the opposite is worse than either.capsule-server/.env.exampleDATABASE_URL. One now does, and the migration command an operator must run beforeservehad nowhere else to be written down. The Valkey half is left for #403.capsule-server/tests/binary.rsa_durable_backend_refuses_with_the_issue_that_will_honour_itcase asserted the durable refusal names#403. The durable arm now demandsDATABASE_URLfirst, 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.rscapsule-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, andlast_failure_atis what the lockout's decay is measured from.CLAUDE.mdis a symlink toAGENTS.mdand is not itself changed; the corrected bullet reaches it through the link.Unresolved review notes