[FEAT] Give capsule-server a binary, configuration, operator commands and a serve task - #435
Open
justin13888 wants to merge 9 commits into
Conversation
`capsule-server` has never been assembled outside its own test fixture, so "every port has an adapter" and "the router builds from real ones" were claims rather than assertions. `config` reads the operator's settings once — command line over environment over default — and reports **every** fault in one message, because an operator otherwise restarts the process once per variable. What a subcommand requires is a parameter: `gc`/`purge`/`scrub` demand a blob root and deliberately no key material, so a maintenance host never needs the production token-signing key. `--config PATH` is accepted and refused, which keeps a configuration-file crate out of a domain the dependencies doc has no row for while leaving the precedence slot named. `boot::assemble` is the one composition root and the only place adapters are chosen. Selection is a two-arm match on `Backends`: `--memory` takes every deterministic in-crate adapter over a real filesystem blob store, and anything else refuses. That makes two sentences `store/mod.rs` has carried since `S-C29` true for the first time — Valkey is required, and the in-memory adapters are not a deployment profile — because until now there was no boot path to enforce either. The account ports and the second factor had no adapter at all, which would have left `register` and `login` answering their declared refusal on a development server. `auth::credential` is the Argon2id helper the Postgres adapter (#402) reuses; `auth::accounts_memory` is a real directory over it — PHC strings, the timing-equalized miss, a lockout that a password change clears — and `auth::totp` gains the deterministic store its port's three properties are all expressible over. None is the permissive credential double `tests/support/mod.rs` warns must never be linkable by a server. Refs #401, #402, #403
…binary `gen_openapi` was this crate's only executable, and the Salvo tree it replaces shipped four — `capsule-gc`, `capsule-scrub`, `capsule-keygen` and its own document dump. Four executables would each carry their own copy of the configuration loader and the composition root, which is the duplication `boot` exists to prevent, so this is one `capsule-server` binary with subcommands, as `capsule-cli` already is. `main.rs` installs error reporting and dispatches; the parsing, the log stream and every subcommand body live in `cli`, in the library, so a test asserts against the same code the binary runs. Logs go to stderr. `gen-openapi` writes a path to stdout and the operator commands will write a report there, and a subscriber sharing that stream is how a pipeline ends up parsing a log line — the failure `capsule-cli/tests/cull_round_trip.rs` works around with `RUST_LOG=off`. `mise run openapi-kynos` and `openapi-check-kynos` re-point at the subcommand. The committed `openapi.json` is untouched and `openapi-check-kynos` passes against it, which is the strongest available evidence the port preserved the document byte for byte. BREAKING CHANGE: `cargo run -p capsule-server --bin gen_openapi` is now `cargo run -p capsule-server -- gen-openapi`. Refs #401
Deploying capsule with
|
| Latest commit: |
459e8af
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://659ae409.capsule-22k.pages.dev |
| Branch Preview URL: | https://feat-server-binary-config-op.capsule-22k.pages.dev |
`capsule-server serve` binds, says where it landed, and drains on a termination signal. The bound address is logged at INFO and also written to stdout as one `listening on <url>` line. That is not a duplicate: `--listen 127.0.0.1:0` is a request for the operating system to choose a port, and a caller that asked for that has no other way to learn which one it got — making them parse a log format `LOG_FORMAT` can change under them would be a contract nobody wrote down. `Shutdown::signals()` covers SIGINT and SIGTERM with a second one forcing, and the drain deadline defaults to Kynos's own 25 seconds, under the usual 30-second orchestrator window. TLS stays off: `failure-modes.md` is explicit that application servers do not terminate it, so Kynos's `tls` feature is not enabled and a certificate cannot be configured by accident. `tests/binary.rs` drives the process, which is the only way to assert what a binary does. It proves the four properties an in-process client cannot: the server binds and reports its port; `capsule-sdk`'s **generated** client reaches it over TCP and reads a `server-info` record whose published signing key is the one derived from the configured private key; an account registers and signs in while a wrong password is refused; and SIGTERM drains to exit 0. The three refusal cases assert the non-zero code and the message — no `VALKEY_URL` without `--memory` names the variable, `VALKEY_URL` set names Refs #401
`filesystem/maintenance.md` has described these as operator-invoked commands, schedulable as jobs, since before there was a binary to invoke them from. They existed only as library functions with no entry point. Dry run is the default for the two that write, because the first thing an operator does with a collector is find out what it thinks; `--apply` opts in and the report says which posture produced it. `scrub` exits 1 on a non-empty report and mutates nothing, which is what makes it usable as a monitoring probe — and a truncated deep pass is reported as truncated, because a clean report from a pass that stopped early is the one answer a scrub must never give. Reports name what they found rather than counting it. `CollectionReport`'s own docs are why: "a count tells an operator that something happened without telling them what to look at." A scrub's findings print through their own `Debug`, so a variant added later renders as itself instead of as nothing. `boot` splits into `assemble` and `assemble_maintenance`. That is not tidiness: `config` claims `gc`/`purge`/`scrub` need no key material, and the way to make that true is for the assembly they use to have none in scope, not for it to build a token signer it then ignores. A maintenance host that had to hold the production signing key to sweep a directory would be a reason to put the key on a maintenance host. Both entry points build the same `Stores`, so the application and the workers never disagree about what is in the index. `tests/binary.rs` covers all three against a seeded blob root: the dry run names the unreferenced blob and leaves it, `--apply` marks it (the sweep is a later pass, and this profile's mark store does not outlive the process), the scrub exits 1 with the store byte-identical afterwards, `--deep` finds the byte mismatch a structural pass cannot see, and none of them is given a signing key. Refs #401
… target `mise run serve-api` and the compose stack behind it went with the Salvo tree in `S-C59`, so there has been no way to bring a server's services up and nothing to point a client at. `capsule-server/compose.yaml` is Postgres 18 and Valkey 9.0.4 — the versions dependabot is already tracking — with the retired deployment's Valkey flags carried over verbatim, because those are the flags the session and upload-session stores were sized against. Both services carry a healthcheck: `serve-deps` returns as soon as compose has started the containers, so a developer who runs `serve` immediately afterwards would otherwise race the database's own startup. There is still no object store: the filesystem `BLOB_ROOT` is the blob backend. `serve-deps` and `serve` are separate tasks, because a task that silently starts containers is a task that leaks them. `serve` supplies no environment on purpose — it refuses and names what is missing. `serve-memory` is the one that just works, and its fallback signing key is the published example: every token it mints is forgeable by anyone who has read this repository, which is exactly why that task is not `serve`. `.env.example` documents every setting, its default, and why the default is what it is. It ships in the release archive, because a release without it is a binary that refuses to start and an operator reading GitHub to find out which variables it wanted. `release.yml` builds `capsule-server` beside `capsule` and puts both in the one per-target archive: an operator wants the server and the CLI that talks to it at the same version, and two downloads is two chances to mix versions. Unix only — Windows is already best-effort for the CLI, and adding a server build to a job allowed to fail would make "did the Windows CLI ship" harder to answer. `dependabot.yml`'s three `/capsule-api` entries were watching a directory that has not existed since `S-C59`. The cargo one moves to the workspace root, the docker-compose one to `/capsule-server`, and the `docker` one goes: no Containerfile exists anywhere in the active tree, and an ecosystem pointed at an absent file is a permanent dashboard error rather than an update. Refs #401, #402, #403
…n one `local-development.md` said that as a known gap, and it was true. It now documents the binary, both profiles, the operator commands, where logs go and where TLS is terminated. It is deliberately explicit about what the development profile is *not*: the blob store is real and everything else lives in the process, so a restart leaves every blob an orphan the scrub will report, and `gc` can only ever mark because the collector's two-pass design needs a mark store that outlives the process. Both are consequences a developer would otherwise meet as a surprise. It is also explicit that `mise run serve` does not work yet and refuses rather than pretending, and that `serve-memory`'s fallback signing key is published — every token it mints is forgeable by anyone who has read this repository. `capsule-server/README.md`'s "no binary, no configuration loading" section becomes "Running it", and the flat "every adapter is in-memory" claim gains the qualifier it now needs: two of them live beside their ports rather than in `tests/support/`, and the distinction the port docs were drawing is between a double and an implementation. Three stale `serve-api` citations follow: `capsule-web/README.md` asserted the Kynos server "has no binary yet", and the CLI's default-endpoint comment and the Swift project's local-networking comment both named a task that retired in `S-C59`. `SLICES.md`'s three remain, and are not this change's. Refs #401
Review repairs on the binary, configuration and operator commands. **The attestation seed is no longer derived from the token-signing key.** `attestation/mod.rs` requires the attestation key to be distinct from the operational one, so that holding the operational key does not let anything manufacture custody evidence. Deriving the seed by HKDF from `JWT_ED25519_DER` collapsed exactly that: anyone with the token key recomputes it and signs receipts. Worse, a comment claimed the separation was structural. `ATTESTATION_KEY_SEED` is now required on the durable path, the derivation survives only under `--memory` — where the whole state is discarded on exit, so a development server still comes up on one variable — and the comment says what is actually true. **A lockout now decays, because nothing else could clear it.** `login`, `reauthenticate` and `password` each ask the directory first and refuse on `Locked` before verifying anything; there is no unlock operation on any surface and no operator command reaches the state. Ten failures were therefore a permanently lost account rather than a throttle. The window runs from the last counted failure and defaults to fifteen minutes, which `design/authentication.md` does not name, so it is written down here. Attempts made *during* a lockout are refused without extending it: extending would hand anyone who can reach the endpoint a way to hold somebody else's account shut. The threshold joins the window as a setting — a lockout is two numbers, and driving the ten-failure default through the login route would cost ten Argon2id verifications whose spacing under load can exceed a short window, which is a test racing its own subject. **A maintenance command is told what it actually needs.** `gc`, `purge` and `scrub` never demand `VALKEY_URL`, so naming it sent an operator to configure something that would not have helped; they need `--memory`, because they compare the index against the blob store and the in-memory index is the only one written. The README and the docs stop showing `--memory` as optional there. Also: `serve-memory` binds loopback rather than every interface, since the key it falls back to is published; the compose stack publishes Postgres and Valkey to loopback, which is what makes `--protected-mode no` a concession rather than an exposure; `.env.example` ships `JWT_ED25519_DER` commented out, so `cp .env.example .env` cannot silently produce a forgeable deployment; and the `gc`/`purge` doc comment no longer claims a partial report is printed on failure, which the library's signature does not permit — it says what the operator does see instead. `VALKEY_EXTRA_FLAGS` was checked rather than assumed: `valkey/valkey:9.0.4`'s own entrypoint ends with `exec "$@" $VALKEY_EXTRA_FLAGS`, so the flags reach the server. The compose file now records that, with the evidence. Refs #401, #402
…9' into feat/server-binary-config-operator-commands-401
…late The repair commit added `ATTESTATION_KEY_SEED` to `.env.example` uncommented, with `$(CHANGE_ME)` as the placeholder, while `JWT_ED25519_DER` beside it was commented out. Both halves of that are wrong. This file is read by more than a shell. `podman --env-file`, compose's `env_file:`, systemd's `EnvironmentFile=` and a Kubernetes ConfigMap all take a line literally — no expansion, no command substitution — so the characters `$(CHANGE_ME)` become the *value*. That is present-but-malformed rather than absent, so `decode_seed` raises `ConfigFault::Invalid` and stops `capsule-server gc|purge|scrub`, which are built to need no key material at all and which no `Demands` arm can excuse it for. Sourced by bash it fails the other way, printing `CHANGE_ME: command not found`. So the line is commented out exactly as the token-signing key is, and the placeholder is plain text. `local-development.md` said "two keys are commented out", which was false when it was written and is now true — it says which two and why, and records that nothing in the template is a shell expression. Two cases in `tests/binary.rs` parse the shipped template rather than restating it: one asserts no uncommented value carries `$` or a backtick, which is the defect's general form and catches it anywhere in the file; the other runs `scrub` under exactly the settings the template ships uncommented and asserts it exits 0 without naming `ATTESTATION_KEY_SEED`. Both were confirmed to fail against the reintroduced placeholder before being kept. Refs #401
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
capsule-serverhad fifty-nine operations, a committed OpenAPI 3.2 document, seven hundred tests — and no way to run it. Nomain, no configuration loading, no compose file, no serve task, and GC / retention purge / integrity scrub existing only as library functions thatfilesystem/maintenance.mddescribes as operator commands.store/mod.rshas asserted sinceS-C29that the server refuses to boot withoutVALKEY_URL; there was no boot path to refuse in.This makes it runnable.
Summary
config— every setting an operator decides, read once. Precedence is command line → environment → default, andConfig::loadreports every fault in one message rather than failing on the first, because an operator otherwise restarts the process once per variable. What a subcommand requires is a parameter (Demands):gc/purge/scrubneed a blob root and deliberately no key material, so a maintenance host never has to hold the production token-signing key. Environment names are the retired deployment's (SERVER_HOST,SERVER_PORT,SERVER_DOMAIN,JWT_ED25519_DER,DATABASE_URL,VALKEY_URL), withBLOB_ROOTreplacingUPLOAD_DIRand still honouring it with a warning.SYNC_CURSOR_MAC_KEYandATTESTATION_KEY_SEEDare HKDF-SHA256-derived from the signing key under separateinfostrings when unset.boot::assemble— the one composition root, and the only place adapters are chosen. Selection is a two-armmatchonBackends.--memory(orCAPSULE_PROFILE=memory) takes every deterministic in-crate adapter over a realFilesystemBlobStoreand a realSystemClock; anything else refuses. That makes two sentencesstore/mod.rshas carried sinceS-C29true for the first time: noVALKEY_URLand no--memoryis a configuration fault naming the variable, andVALKEY_URLset isBootError::AdapterUnavailablenaming server: Valkey adapters for the auth-state and upload-session ports #403 and server: Postgres adapters and a conformance suite for every durable port #402. Neither ever silently becomes an in-memory server.TotpStorehad no adapter at all, so a development server would have answeredregisterandloginwith their declaredunavailablerefusal.auth::credentialis the Argon2id helper (PHC strings, a fresh salt per hash, the timing-equalized miss against a decoy computed once at startup) that server: Postgres adapters and a conformance suite for every durable port #402's Postgres adapter reuses;auth::accounts_memoryis a real directory over it with a lockout a password change clears;auth::totpgains the deterministic store its port's three properties are all expressible over. None of these is the permissive credential doubletests/support/mod.rs:1-19warns must never be linkable by a server — a wrong password is refused, and a test asserts it.serve | gc | purge | scrub | gen-openapi, replacing thegen_openapi[[bin]]. Four executables would each carry their own configuration loader and composition root. Logs go to stderr so stdout stays a data channel.servebinds, logs its bound address before accepting, drains on SIGINT/SIGTERM insideSHUTDOWN_TIMEOUT_SECONDS, and terminates TLS never —design/cryptography/failure-modes.md:68puts that on the ingress.gc/purge/scrub— dry run is the default for the two that write;--applyopts in.scrubexits1on a non-empty report and mutates nothing, whichfilesystem/maintenance.mdrequires of it.capsule-server/compose.yaml(Postgres 18, Valkey 9.0.4, podman-first:Z,Ulabels),capsule-server/.env.example,mise run serve/serve-deps/serve-memory,release.ymlpackaging the server binary beside the CLI, anddependabot.ymlre-pointed off the deleted/capsule-api.local-development.md's "there is no local server today" is replaced with how to run one; the three staleserve-apicitations are corrected.Not here, and tracked: the Postgres adapters (#402) and the Valkey adapter (#403). Both fill the one
Backends::Durablearm.Validation
Every command run inside the worktree at
/var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-server-binary-config-operator-commands-401, on the merged head.cargo nextest run -p capsule-servercargo nextest run -p capsule-server --test binarymise run openapi-check-kynosopenapi.jsonunmodified in the diff — the proof the bin port is byte-preservingmise run lint-check-rustcargo clippy --workspacewith the repo's$CLIPPY_FLAGS)mise run check-docs-truthmise run lint-check-mdmise run lint-check-rust(re-run after the template fix)mise run build-docs(re-run after the template fix)mise run build-docsmise run build-rustpodman compose -f capsule-server/compose.yaml configmise run check-rustformat-check-rust,lint-check-rust,doc-check-rust,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. Run step by step in the foreground rather than through the aggregate, because two attempts at the aggregate were killed by a signal on this shared machine mid-clippywith no diagnostic emitted; the commands and their order are the task's ownmise run test-rustcargo nextest run --workspace1798 passed / 0 skipped,-p capsule-core --features ffi729 passed,-p capsule-sdk --features ffi160 passedThe three gates named for the template fix, re-run on
459e8af3:The two new cases were confirmed to fail against the reintroduced placeholder before being kept, so they are a regression test rather than a restatement.
Failures classified:
718bc820) and on the two commits before it, and every error is Kotlin:capsule-core-kotlin/src/test/.../SoftwareSignerSmokeTest.kt("No value passed for parameter 'client'") andcapsule-android/src/androidMain/.../CapsuleApp.kt(unresolveddi,ListViewModel,DetailViewModel,initKoin). This branch touches no Kotlin or Android file. The job is not in therequiredcheck.cargo clippy -p capsule-server --all-targets— pre-existing. It reports findings insrc/counter/tests.rs,src/gc/tests.rs,tests/support/mod.rs,tests/ops.rs,tests/sync.rsand others. The repo gate iscargo clippy --workspacewithout--all-targets, so test targets are outside it; every finding predates this branch and none is in a file this touches. Run with--all-targetsrestricted to this branch's own files, there are none.SUCCESSon the previous head with the same non-Swift diff, and neither this branch's commits nor the merged base commit touches a Swift file;build-ffiandgen-bindings, which are what the Swift build consumes from Rust, pass locally. Every other check isSUCCESSexcept the Android job above.VALKEY_EXTRA_FLAGSinertness — refuted, not unverified. A live container probe is not possible on this host (libc.so.6: Permission deniedunder the rootless runtime), so the image's own entrypoint was read instead:valkey/valkey:9.0.4's/usr/local/bin/docker-entrypoint.shends withexec "$@" $VALKEY_EXTRA_FLAGS, unquoted. The flags reachvalkey-server.Risks and rollout
Four lanes (#402, #403, #404, #407) branch from this head, which is why it was opened at its first coherent commit.
ATTESTATION_KEY_SEEDas well asJWT_ED25519_DER; the first pass derived the former from the latter and that is fixed here (decision 10). Anyone who took the first pass's.envand only set the token key will findserverefuses and names the missing variable, which is the intended outcome rather than a regression.auth::credentialis the single implementation, the routes were already written against the port, and the adapter's own tests assert the refusing direction (wrong password refused, unknown address indistinguishable from a wrong one, lockout reachable, lockout cleared by a password change). If it is wrong, the reversal is one line inboot::assemble— swapInMemoryAccountsfor a fail-closed stub — and the routes already map that to their declaredunavailableresponse, so the OpenAPI document is unaffected either way.openapi.jsonis untouched, andopenapi-check-kynosis the gate that proves it. Nothing undercapsule-server/src/routes/changed, so no client regenerates.--memoryprofile keeps blobs and loses everything else on a restart, because the filesystem blob store is the one durable adapter written. That is stated inboot's module docs, incapsule-server/README.mdand inlocal-development.mdrather than left for an operator to discover. It also means the collector can only ever mark in that profile:gcmarks on one pass and sweeps on a later one once the grace window has passed, and the mark store does not survive the process. Sweeping needs the durable mark store server: Postgres adapters and a conformance suite for every durable port #402 brings.design/authentication.mdnames no figure — and both numbers are settings (LOCKOUT_MAX_ATTEMPTS,LOCKOUT_WINDOW_SECONDS) so a deployment can move them without a release.git revertof the series. The only externally visible change to an existing workflow iscargo run -p capsule-server --bin gen_openapibecomingcargo run -p capsule-server -- gen-openapi, and bothmisetasks were re-pointed in the same commit.Related Issues
Closes #401
Refs #402, #403
Decisions taken
The record below is carried verbatim from the lane's decision record, with decision 1 as amended by the orchestrator. Decisions 5 onward were taken inside the manifest while implementing, and are recorded in the same shape.
1. Deliverable boundary - how much of "boots on in-memory" ships now. (Orchestrator amendment: the planner's rejection evidence was falsified.)
serve --memorythat boots on in-crate in-memory adapters INCLUDING a real in-memory account adapter (register / authenticate with Argon2id / profile / password change) socapsule auth registerandloginwork againstmise run serve-memory; WriteAuthority uses the crate's existing ProvisionedAuthority if it implements the trait, else a fail-closed unprovisioned adapter; TotpStore gets a real InMemoryTotp. Fail-closedunprovisionedstubs remain only for ports with no deterministic in-memory semantics.argon2 = "0.5.3"is a workspace dependency (Cargo.toml:41) consumed by capsule-core (capsule-core/Cargo.toml:104) and pinned under the Cryptography primitives doc's Argon2id row; the credential-verification helper this lane writes (auth::credential) is the one server: Postgres adapters and a conformance suite for every durable port #402's Postgres adapter reuses, so nothing is written twice.2. Config file format - environment-only for this lane;
--config PATHreserved and rejected with "config files are not supported yet".3. One binary with subcommands, not separate
[[bin]]s.mise run openapi-kynosbecomescargo run -q -p capsule-server -- gen-openapi; src/bin/ removed.[[bin]]s as the Salvo tree had - maintenance.md:41 calls scrub "an operator-invoked command, schedulable as a job", not a distinct executable; four binaries would each carry a config loader; capsule-cli sets the one-binary precedent.[[bin]]stanzas; boot.rs/config.rs unchanged.4. TLS termination - reverse-proxy only; Kynos
tlsfeature stays off..tls(TlsConfig::from_pem(..))call.5.
capsule-server/src/unprovisioned.rsis not written, and the manifest slot for it is unused.WriteAuthorityalready has the productionalbum::authority::ProvisionedAuthority(S-C19/S-C20), which reads the album's own protocol pin and the account's published device directory and works unchanged overInMemoryAlbums+InMemoryDeviceDirectory;ReadAuthorityhasserve::owned_assets(); the account ports andTotpStoreget the real adapters decision 1 requires. A stub with no port to fill would be dead code in a server binary.UnprovisionedAuthorityanyway to keep the manifest literal. It would be an unreachableArc<dyn WriteAuthority>that refuses, which is strictly worse than the production one the crate already has:ProvisionedAuthorityrefuses too — an unprovisioned album isDeniedand an account with no published directory has no invariant-7 floor — and it refuses for the right reason.src/unprovisioned.rsand swap theBackends::Memoryarm'sauthorityfor it. Nothing else moves.6. The new modules attach through
auth/mod.rs, not onlylib.rs.auth::credentialandauth::accounts_memoryare submodules ofauth, so the twopub modlines and threepub usere-exports are incapsule-server/src/auth/mod.rs. The record'sTouchesline says "lib.rs (module lines)"; this reads that as "the module-declaration lines wherever the new modules attach", because a credential helper that hung off the crate root rather than off the module whose three ports oblige it would be in the wrong place for the reasonS-C29gives about cohesion.auth/mod.rs's own "Adapters this slice does not write" paragraph is corrected in the same edit, since it is now false.crate::credential/crate::accounts_memoryat the crate root, to keep the manifest literal. It would put the account ports' adapter outside the module that declares them and leaveauth/mod.rs's adapter paragraph stale.capsule-server/src/{credential,accounts_memory}.rsand theirpub modlines tolib.rs.7.
boot::assembletakes one argument, not two.assemble(&Config)readsconfig.backends, andBackendsis declared inconfigbecause which adapter family to run on is a thing an operator decides. The plan wroteassemble(config, backends).Backendsthe configuration did not select, which is exactly the "it silently came up in-memory" failure the whole seam exists to make impossible.Backendstobootand re-add the parameter; thematchis unchanged.8. A configuration refusal exits 2; a non-empty read-only report exits 1.
cli::EXIT_MISCONFIGURED = 2(which is also clap's usage-error code, so the two kinds of "the invocation was wrong" agree) andcli::EXIT_FINDINGS = 1. TheConfigErrorreport goes straight to stderr rather than throughtracingorcolor_eyre, because it is already the full multi-line list of faults and it has to be visible whateverRUST_LOGsays.Err(...)for the configuration path and let color-eyre exit 1.9. The failed-attempt ceiling is a constant, and email addresses are compared verbatim.
accounts_memory::MAX_FAILED_ATTEMPTS = 10, cleared by a success and by a password change. Addresses are byte-compared, exactly astests/support/mod.rs's double compares them.Foo@example.testandfoo@example.testare two accounts until a slice says otherwise.AccountDirectoryis explicit thatAuthentication::Lockedis account state the adapter owns and that rate limiting is a counter with no port in this crate; an adapter that never returnedLockedwould leave a declared response unreachable on a development server.failuresfield and theLockedarm; normalize withto_lowercaseat the map key if a slice decides identity is case-insensitive.Decisions 10-15 were taken during the review-repair pass and 16 during the confirming round; 1-9 above are unchanged.
10. The attestation seed is required for
serve, and derived only in the memory profile.ATTESTATION_KEY_SEEDis a required setting on the durable path.Backends::Memorykeeps the HKDF derivation fromJWT_ED25519_DER, soserve --memorystill comes up on one variable, and a development server's whole state is discarded on exit anyway.attestation/mod.rsrequires the receipt-signing key to be separate from the operational key precisely so that holding the operational key cannot manufacture custody evidence, and a different HKDF label over the same input is not a separation — anyone withJWT_ED25519_DERrecomputes the seed. The first pass also carried a comment asserting the separation was structural, which made the defect harder to see rather than easier.Demands::Serve— onematcharm inconfig::Config::load.11.
DEFAULT_PURGE_LIMIT = 1000andDEFAULT_SCRUB_BUDGET = 1 GiBstay defaults rather than required flags.--limit/--budgetoverride them.required = trueon bothclaparguments.12. The memory profile runs
QuotaLimits::unlimited().--quota-bytesflag with a finite default, passed toQuotaContext::new.13. The
dockerdependabot ecosystem entry is deleted rather than retargeted./capsule-api/Containerfile, and no Containerfile exists anywhere in the active tree; an ecosystem pointed at an absent file is a permanent dashboard error rather than an update./capsule-server, which has no Containerfile either.14. The lockout is two settings, and an attempt made during one does not extend it.
LOCKOUT_WINDOW_SECONDS(default 900) andLOCKOUT_MAX_ATTEMPTS(default 10, zero refused). The window runs from the last counted failure; an attempt made while an account is locked is refused without being counted, so the deadline does not move.login,reauthenticateandpasswordall ask the directory first and refuse onLockedbefore verifying anything, there is no unlock operation on any surface, and no operator command reaches the state — so ten failures were a permanently lost account rather than a throttle.MAX_FAILED_ATTEMPTSas the only figure, and deleteAccount::last_failure_at.15.
VALKEY_EXTRA_FLAGSstays inenvironment:rather than moving tocommand:.command:on the belief that the variable is a Bitnami-only convention the official image ignores. Checked rather than assumed:valkey/valkey:9.0.4's own/usr/local/bin/docker-entrypoint.shends withexec "$@" $VALKEY_EXTRA_FLAGS, unquoted, so the variable word-splits into arguments ofvalkey-server. The convention is shared with Bitnami's images, not exclusive to them.command:if a future image drops the entrypoint line.16.
ATTESTATION_KEY_SEEDships commented out, with a placeholder carrying no shell metacharacters.JWT_ED25519_DERis, and the placeholder is plain text (replace-with-your-own-base64-seed).local-development.mdnames the two commented keys instead of counting them, and records that nothing in the template is a shell expression. Twotests/binary.rscases parse the shipped template: one asserts no uncommented value carries$or a backtick, the other runsscrubunder exactly the settings the template ships uncommented and asserts it exits 0 without namingATTESTATION_KEY_SEED.ATTESTATION_KEY_SEED=$(CHANGE_ME)uncommented and fixing only the doc's count. The placeholder is not inert. This file is read by more than a shell —podman --env-file, compose'senv_file:, systemd'sEnvironmentFile=, a Kubernetes ConfigMap — and every one of them takes the line literally, so the characters$(CHANGE_ME)become the value. That is present but malformed, not absent, sodecode_seedraisesConfigFault::Invalidand stopsgc/purge/scrub— the three commands decision 1 promises need no key material, and which noDemandsarm can excuse it for. Sourced by bash it fails the other way, printingCHANGE_ME: command not found. A doc-only fix would have left both.ATTESTATION_KEY_SEED=). It works —Environment::varalready treats empty as absent — but it says nothing about what belongs there, and the commented form both carries the example and matches the key beside it.Unresolved review notes
capsule-server/src/store/mod.rsandsrc/store/memory.rsdescribe the in-memory adapters as "a test double, never a deployment profile". After this branch they are also the--memorydevelopment profile, which is not a deployment and so does not falsify either sentence — but "test double" now understates them. Both files are outside this lane's manifest andsrc/store/**is server: Valkey adapters for the auth-state and upload-session ports #403's, so the wording is left for that lane rather than widened here.--memoryprofile cannot demonstrate agcsweep end to end, because the collector marks on one pass and sweeps on a later one andInMemoryCollectiondoes not survive the process.tests/binary.rstherefore asserts what is true — the dry run reports and mutates nothing,--applyreports the same and the blob is still there — and the sweep is proved bygc's own in-process unit tests. A durable mark store (server: Postgres adapters and a conformance suite for every durable port #402) is what makes the cross-invocation path assertable.gcandpurgereport nothing when a pass fails part-way.gc::collectandgc::purge_expiredreturnResult<Report, StoreError>, so the work done before a store failure is not recoverable by the caller: the operator gets the error on stderr, a non-zero exit, and the collector's ownINFOlines as the only record of how far it got. Closing it means returning the partial report alongside the error, andcapsule-server/src/gc/mod.rsis outside this lane's manifest. The state left behind is safe either way — a mark is reversible and a sweep only ever removed a blob confirmed unreferenced twice — so re-running the pass is the correct response.auth::totp::InMemoryTotpandtests/support/mod.rs's double implement the sameTotpStorebody twice. The suite's copy adds an availability switch the coverage walk needs and is otherwise identical. Collapsing it means the double delegating to the real adapter, the waySwitchableSessionsalready delegates toInMemoryAuthState— buttests/support/mod.rsis outside this lane's manifest and belongs to server: the protocol headers the design puts on every route are on four operations #404, so it is recorded here rather than edited.Argon2::default(), m=19456 KiB / t=2 / p=1). The parameters ride inside every PHC string, so raising them is not a flag day, but the number itself is an unmeasured default on this hardware.Contributor Checklist