Local Development
DraftEvery command in this repository runs through mise. mise.toml at the repo root pins the shared toolchain and defines the whole task graph; anything too long for a TOML line lives as a shebang script in mise-tasks/. CI jobs map 1:1 onto the check-* tasks below, so a green local check-* is the same command CI runs.
First-time setup
Section titled “First-time setup”mise install # installs the pinned tools from [tools] in mise.tomlmise run setup # bun install in capsule-web + capsule-docs, uv sync in capsule-visionmise run hooks-install # installs the git hooks (hk)mise install provides hk (the git-hook runner, which replaced lefthook), convco (Conventional Commits), cargo-nextest, and wasm-bindgen-cli. The Rust toolchain itself is pinned separately in /rust-toolchain.toml, and the Apple client’s Swift tooling in capsule-swift/mise.toml.
mise tasks lists everything. The naming convention is uniform: a bare task fixes, and the -check variant verifies only.
The task graph
Section titled “The task graph”Aggregates
Section titled “Aggregates”| Task | What it does |
|---|---|
mise run check | The full gate: format-check → lint-check → test, sequentially |
mise run format / format-check | Every toolchain |
mise run lint / lint-check | Every toolchain |
mise run test | Rust + web + Kotlin |
mise run build | Every toolchain |
Per-toolchain entrypoints
Section titled “Per-toolchain entrypoints”| Task | Contents |
|---|---|
mise run check-rust | format-check-rust, lint-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 — sequential, because they all contend on one target/ lock |
mise run check-web | format-check-web, lint-check-web, test-web, build-web |
mise run check-docs | format-check-docs, lint-check-docs, build-docs (a real Astro build — a broken internal link fails it) |
mise run check-md | lint-check-md (markdownlint over every .md in the repo) |
mise run check-vision | format-check-vision, lint-check-vision |
mise run check-kotlin | format-check-kotlin, lint-check-kotlin |
The ones you run most
Section titled “The ones you run most”- Rust:
format-rust(cargo fmt),lint-rust/lint-check-rust(cargo clippy --workspacewith the shared$CLIPPY_FLAGSset — lints are configured once inmise.toml, never per crate),test-rust(cargo nextest run, three invocations: the workspace, thencapsule-coreandcapsule-sdkwith--features ffi),build-rust,test-coverage-rust. - Web:
format-web/lint-web(biome),test-web(bun; depends onbuild-wasm,share-kat, anddrop-kat, which generate the WASM glue and the cross-language KAT fixtures the tests load),build-web. - Docs:
format-docs/lint-docs(biome overcapsule-docs),build-docs(Astro/Starlight). - Markdown:
lint-md/lint-check-md. - Codegen gates:
i18nandi18n-check(compilelocales/into the per-platform catalogs),i18n-guard(fails on hardcoded user-facing literals in web/Swift/Compose),openapi-kynosandopenapi-check-kynos(dump/verifycapsule-server/openapi.json),translate-readmeandtranslate-readme-check(regenerate/verify the translated READMEs). - FFI and cross-compilation:
targets-addfirst, thenbuild-ffi,gen-bindings(uniffi Kotlin/Swift sources),build-apple,build-android,build-linux-cross,build-windows,build-targets. On macOS,setup-swiftbuilds the FFI xcframework and generates the Xcode workspace.
Running a server locally
Section titled “Running a server locally”capsule-server is one binary with subcommands:
capsule-server [--config PATH] <SUBCOMMAND> serve [--listen HOST:PORT] [--memory] [--blob-root PATH] gc [--apply] [--grace-window-hours N] --memory --blob-root PATH purge [--apply] [--limit N] --memory --blob-root PATH scrub [--deep] [--budget BYTES] --memory --blob-root PATH gen-openapi [FILE] [--check]
`--memory` is written as required on the three operator commands because today it is: theycompare the index against the blob store, and the only index adapter written is the in-memoryone. Without it they refuse and say so. It becomes optional when #402 lands.The development profile
Section titled “The development profile”mise run serve-memoryThat is a server you can point a client at: it binds, prints the address it bound, and answers every operation. An account registers and signs in — the credential is checked with Argon2id against a real in-memory account directory, so a wrong password is refused rather than accepted.
What it is missing is durability. The blob store is a real filesystem store under
target/capsule-server-blobs; everything else — the index, sessions, albums, the device
directory, quota, the collector’s marks — lives in the process and is gone when it exits. That
is not a gap to route around, it is the shape of a profile whose durable half is exactly the one
adapter that has been written. Two consequences worth knowing before they surprise you:
- After a restart,
capsule-server scrubwill honestly report every blob still on disk as an orphan, because the index that referenced them is gone. capsule-server gccan only ever mark in this profile. Collection is two passes by design — a blob that reaches zero references is marked, and swept on a later pass once the grace window has passed — and the mark store does not outlive the process.
The signing key serve-memory falls back to is the published example in
capsule-server/.env.example — commented out there, so a cp .env.example .env cannot silently
produce a forgeable deployment. Every token the task mints under it is forgeable by anyone who has
read this repository, which is why it is serve-memory and not serve, and why it binds
127.0.0.1 rather than every interface. Set JWT_ED25519_DER yourself and it is used instead:
JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr -d '\n')" \ mise run serve-memoryA configured server
Section titled “A configured server”cp capsule-server/.env.example capsule-server/.env # then edit itmise run serve-deps # Postgres 18 + Valkey 9, on loopbackmise run serveThe template ships with both secrets commented out — JWT_ED25519_DER and
ATTESTATION_KEY_SEED — so a copy you have not finished editing produces a server that refuses
and names what it wants, rather than one that starts under a published key. Uncomment each and
put your own value in. Every other setting is either a working default or optional.
Nothing in the template is a shell expression, deliberately: the file is read by more than a
shell — podman --env-file, compose’s env_file:, systemd’s EnvironmentFile= — and those take
a line literally, so a placeholder shaped like $(...) would be stored as the value rather than
replaced.
serve-deps and serve are separate tasks on purpose: a task that silently starts containers is
a task that leaks them. Bring them down with
podman compose -f capsule-server/compose.yaml down (docker compose accepts the same file).
mise run serve does not work yet, and refuses rather than pretending. The Valkey half is
real: VALKEY_URL is connected to and PINGed before anything else is assembled, and a server
that cannot be reached is a refusal naming the failure (never the URL). The Postgres adapters are
not written, so with Valkey reachable serve exits non-zero naming DATABASE_URL and the issue
that will honour it (#402). Without VALKEY_URL and without --memory it exits 2 naming the
variable — the refusal capsule-server/src/store/mod.rs has documented since S-C29 and nothing
could enforce until there was a boot path. Neither ever silently falls back to the in-memory
adapters, which is the whole point: a deployment that forgot a variable must fail closed.
The Valkey adapters are proven against a live server by capsule-server/tests/valkey.rs, which
runs the same conformance suites the in-memory doubles pass. It is env-gated so mise run test-rust needs no podman: CAPSULE_TEST_VALKEY=1 starts a valkey/valkey container through
testcontainers (DOCKER_HOST pointing at the podman socket; CAPSULE_TEST_VALKEY_TAG overrides
the image tag), and CAPSULE_TEST_VALKEY_URL=redis://127.0.0.1:6379 runs it against a server
already up — the one mise run serve-deps starts, say. The suite writes under the capsule:
namespace, so point it at a database you do not mind sharing with test keys.
A configured server also has to supply ATTESTATION_KEY_SEED. It is not derived from
JWT_ED25519_DER, and that is deliberate: the attestation key signs custody receipts and has to
be distinct from the key that signs session tokens, or anyone holding the operational key could
manufacture custody evidence — see
Cryptography — Failure Modes. A different HKDF label over
the same input is not a separation. serve --memory derives it, because a development server’s
whole state is discarded when it exits.
Every configuration fault is reported in one message with exit code 2, so bringing a deployment up is one read of one log line rather than one restart per variable.
capsule-server/.env.example is the full list of settings. The precedence is command-line flag,
then the environment, then the built-in default; there is no configuration file, and --config PATH is accepted and refused with a sentence saying so.
The server does not terminate it. HTTPS is the ingress or reverse proxy’s job — see
Cryptography — Failure Modes — so there is no certificate
setting and Kynos’s tls feature is off.
Logs and reports
Section titled “Logs and reports”Every log line goes to stderr; stdout is a data channel. serve writes one
listening on <url> line there (which is how a --listen 127.0.0.1:0 caller learns its port),
gen-openapi writes the path it wrote, and the operator commands write their report. LOG_FORMAT
is pretty in a debug build and json in a release one; RUST_LOG is the usual filter.
The operator commands
Section titled “The operator commands”gc, purge and scrub are the three jobs
Filesystem — Maintenance describes. They need a blob root and
deliberately no key material: a maintenance host that had to hold the production
token-signing key to sweep a directory would be a reason to put the key on a maintenance host.
They do need --memory today, and they say so rather than naming a variable that would not have
helped: all three compare the index against the blob store, and the in-memory one is the only
index adapter written.
Dry run is the default for the two that write; --apply opts in, and the report says which
posture produced it. scrub mutates nothing at all and exits non-zero on a non-empty report,
which is what makes it usable as a monitoring probe — and a --deep pass that ran out of budget
says so, because a clean report from a pass that stopped early is not a clean store.
Without running anything
Section titled “Without running anything”To exercise the server the way its own tests do — in process, no socket, no container:
cargo nextest run -p capsule-serverkynos::test::TestClient drives a built Service directly. Two test files do use a socket:
capsule-server/tests/sdk_client.rs, because the property it proves is that the generated
SDK client round-trips the real router over TCP, and capsule-server/tests/binary.rs, because
the properties it proves — that the binary binds, reports its port, and drains to exit 0 on
SIGTERM — belong to a process rather than to a router.
To read the served contract without running anything:
mise run openapi-kynos # regenerate capsule-server/openapi.jsonNothing here needs a container
Section titled “Nothing here needs a container”No test in the workspace starts a container, so mise run test-rust has no podman prerequisite
and cannot leak one. mise run serve-deps is the only task that starts anything, and it is never
a dependency of another task. (The containers nextest group is kept, empty, for the first real
adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the
expensive way to learn it.)
Git hooks
Section titled “Git hooks”mise run hooks-install installs hk from hk.pkl. Every hook step delegates to a mise task, so the hook and the CI job run identical commands:
- pre-commit — auto-fixing formatters and linters, scoped by glob to the files actually staged, with the fixes re-staged into the commit.
- pre-push — read-only checks plus tests over the range being pushed, including
check-commits(convco).
mise run hooks-uninstall removes them.
Known-broken local gates
Section titled “Known-broken local gates”Two lanes cannot be run on a developer machine today. Both are CI-only; do not treat a local failure in either as a regression you introduced.
format-swift/format-check-swift— swiftformat 0.55 is SIGKILLed on macOS dev hosts, so the Swift formatting tasks cannot complete locally. The Swift tasks self-skip entirely off macOS.- The Kotlin lanes (
format-kotlin,format-check-kotlin,lint-kotlin,lint-check-kotlin,test-kotlin,build-kotlin) — the root Gradle build fails on recent JDKs, so./gradlewdoes not run locally. Kotlin and Android changes are verified in CI only. Note that the i18n half of the Compose gate deliberately does not go through Gradle:i18n-guardcovers web, Swift, and Compose from Rust precisely so it stays locally runnable.