Contributing · 6. Recipes

Every recipe here has a step that people forget, and a named test that fails when they do. Those are called out per step, because a green local build followed by a red CI is the worst way to learn a wiring rule.

These are how-to guides. They assume you can build and test the workspace (chapters 1 and 4).

6.1 Add an HTTP route

Where the router is built

One function: router_with_route_auth (http/mod.rs:462), called by the public router() at mod.rs:455. The chain starts with Router::new() and the .route(...) calls run for roughly a thousand lines, /healthz early, /v1/auth/whoami shortly after, the projections-entity routes in the middle.

Handler modules are declared in a flat mod <name>; block at the top of mod.rs, 71 module declarations against 77 .rs files in crates/corecruxd/src/http/, the remainder being mod.rs, tests.rs (declared separately at mod.rs:1929) and helper modules. Two are feature-gated behind hosted-surfaces. Handlers are pub(super) async fn.

Three sub-routers are merged rather than inlined, near the end of the chain:

Sub-routerroutes() at
http/observe_audit.rsobserve_audit.rs:134
http/orchestrators.rsorchestrators.rs:59
http/punchcards.rspunchcards.rs:56

The layering order matters and is deliberate: extensions, then presence, then quota, then the route-auth middleware, then .with_state(state), then the console merge; that merge happens after with_state so the static console sits outside the /v1/* auth contract, then the panic catcher, a 30 s timeout, traceparent and request-id layers.

The steps

1. Write the handler. crates/corecruxd/src/http/<name>.rs. Start it with the four-line Apache-2.0 header (5.5). Return errors through problem_response so the route speaks RFC 7807 like every other route.

If the file is new, add mod <name>; to the block at the top of crates/corecruxd/src/http/mod.rs.

2. Register the route. Add the .route("...", get(...)) call in router_with_route_auth.

3. Classify it for auth. Add a match arm to classify_route (route_auth.rs:75), which returns a class, the accepted scopes and an optional feature gate.

Skip this and route_auth_matrix_is_complete fails (route_auth.rs:814).

4. Add it to the route manifest. crates/corecruxd/src/http/openapi.rs holds const ROUTES: &[RouteEntry] (openapi.rs:142), a complete declarative manifest, one row per mounted /v1/* and health route, carrying path, methods, tag, auth and summary. This is the actual source of truth a new route must be added to. It drives the drift gate and the generated console fetch client.

Skip this and route_manifest_matches_router fails (route_spec_drift.rs:757), which diffs the router's mounted set against the manifest.

5. If the console should call it, add an entry to GATED_MUTATIONS (route_spec_drift.rs:106) for writes, or READ_POST_ROUTES (route_spec_drift.rs:228) for curated read-POSTs, then regenerate the client:

cargo test -p corecruxd --test route_spec_drift -- --ignored regen_api_js

Skip the regeneration and generated_api_js_is_in_sync fails (route_spec_drift.rs:784).

6. Optionally annotate for OpenAPI schema richness. There are two independent layers here and they are easy to confuse:

LayerWhat it isRequired?
#[derive(OpenApi)] pub(super) struct ApiDoc (openapi.rs:26)Hand-curated utoipa covering only a subset, health, facts, sessions, events, query, receipts. Per-handler annotation looks like #[utoipa::path(put, path = "/v1/facts", tag = "Facts", ...)]Optional. Adds schema detail
const ROUTES (openapi.rs:142)The complete declarative manifestMandatory. Step 4

7. Write tests. Inline in crates/corecruxd/src/http/tests.rs, and black-box in crates/crux-integration-tests/tests/daemon.rs if the route is worth an end-to-end check.

Run the gate before you push

cargo test -p corecruxd route_auth
cargo test -p corecruxd --test route_spec_drift

The scope-safety sweeps you must satisfy

The route_auth test module is unusual: it does not just check your arm exists, it sweeps every classified route for scope-safety mistakes.

TestAsserts
route_auth_matrix_is_complete (route_auth.rs:814)Every scanned method-and-path pair has a classify_route result
route_auth_scope_contracts (route_auth.rs:824)Exact class and scopes for a curated list of routes
write_class_routes_do_not_accept_read_only_scopes (route_auth.rs:961)No write route accepts a read-only scope
admin_write_routes_do_not_accept_admin_read_onlyNo admin write route accepts admin:read alone
feature_gated_write_routes_have_write_scopeA feature-gated write route still demands a write scope
http_boundary_contracts (route_auth.rs:1079)Smoke-pins three specific routes

If you add a mutating route under a read-class prefix, classify it ahead of the prefix rule so a read token can never authorise the mutation. The /v1/studio/library/ POST carve-out is the pattern to copy.

The latent hole you should know about

route_auth_matrix_is_complete works by scanning router source text. router_routes() (route_auth.rs:798) reads mod.rs, observe_audit.rs, orchestrators.rs and punchcards.rs via include_str! and regex-extracts every .route("...", get/post/...) call.

If you add a fourth merged sub-router without also adding its filename to that include_str! list, its routes are invisible to the completeness gate. They would mount unclassified and the test would still pass. This is a real hole. If you merge a new sub-router, edit router_routes() in the same commit.

6.2 Add an MCP tool

The two mandatory registration points, and two optional ones

Everything lives in crates/crux-mcp/src/tools/mod.rs unless stated.

PointWhereMandatory?
The cataloguelist_tools_with_flags (mod.rs:156), a vec![] literal. list_tools() at mod.rs:131 is a thin wrapperYes
The dispatch armcall_tool (mod.rs:2869), one match arm per tool, falling through to METHOD_NOT_FOUNDYes
The tier surfaceTOOL_SURFACE (tool_surface.rs:23)Only for HostedGated tools
The envelope opt-intool_emits_envelope (mod.rs:99), paired with an arm in crate::envelope::build_envelope_for_toolOptional

ToolDefinition is { name, description, input_schema: Value }. The description and the JSON Schema are hand-written Rust literals inline in the catalogue. There is no separate schema file to edit and no code generation.

tool_tier(name) (tool_surface.rs:208) defaults an unlisted name to ToolTier::Local. So a plain local tool works without a TOOL_SURFACE entry, verified: reuse_check and engram_resolve are both absent and rely on the default. A tool that needs HostedGated must be added explicitly.

The canonical module shape

crates/crux-mcp/src/tools/reuse.rs is about 215 lines and is the model. Copy its structure:

ElementLineWhy it matters
pub const FEATURE_FLAG_ENV: &str = "CORECRUXD_FEATURE_REUSE_CHECK";reuse.rs:31The flag name is a public constant, not a string literal scattered around
pub fn reuse_check_enabled() -> boolreuse.rs:37Default off. New tools ship default-off
The disabled-path error carries the flag namereuse.rs:50The message literally tells the caller which variable to set
pub async fn handle_reuse_check(args, ctx)reuse.rs:55Checks the flag, then delegates
async fn handle_inner(args, ctx)reuse.rs:64A flag-free core, so tests drive it directly and avoid environment-variable races. Copy this split; it is why the tool's tests do not need #[serial]
#[cfg(test)] mod testsreuse.rs:200Tests at the bottom of the module

The return value is the standard MCP envelope:

{"content": [{"type": "text", "text": "<a JSON string>"}]}

Upstream JSON-RPC routing happens in crates/crux-mcp/src/dispatch.rs; you do not touch it.

The step that catches everyone

Bump TOOL_COUNT. It is const TOOL_COUNT: usize = 118; at mod.rs:3108, inside the crate's #[cfg(test)] mod tests. Three tests consume it:

TestFails how
list_tools_returns_expected_countCount mismatch
tool_names_uniqueRuns over the same list; a duplicated name fails here
The passport-gated surface assertionAsserts the gated list length is TOOL_COUNT + 1

mcp_tools_list in crates/crux-integration-tests/tests/daemon.rs is not an exact-count test; it asserts a lower bound of 35, because the integration daemon deliberately gates some config-dependent tools off (the sync tools, with no remote configured). It does not need bumping per tool.

The order to work in

1. crates/crux-mcp/src/tools/<name>.rs        new module: flag + wrapper + handle_inner + tests
2. crates/crux-mcp/src/tools/mod.rs           mod declaration
3. crates/crux-mcp/src/tools/mod.rs           catalogue entry in list_tools_with_flags
4. crates/crux-mcp/src/tools/mod.rs           call_tool match arm
5. crates/crux-mcp/src/tools/mod.rs           bump TOOL_COUNT
6. crates/vaultcrux-local/src/tool_surface.rs only if HostedGated
cargo test -p crux-mcp

6.3 Add a config flag

Everything is in crates/corecruxd/src/config.rs, 2,563 lines. Its module doc states the job: parse CORECRUXD_* environment variables into a typed Config at startup.

The parsing helpers

HelperLineBehaviour
env_string(key)config.rs:734Trims; empty becomes None
env_bool(key)config.rs:738Standard boolean parse
env_default_on(key)config.rs:744Default-on flags. Only an explicit 0 or false, case-insensitive, disables
env_csv(key)config.rs:753Comma-separated list

The namespace is CORECRUXD_*. A few legacy variables dual-read a bare CRUX_* name first, CRUX_PASSPORT_CLAIM_ENDPOINT, CRUX_OPERATING_MODE, CRUX_ENABLED_PRO_SERVICES. Do not add new CRUX_* names.

Four places, one file

1. The struct field. pub struct Config { ... } at config.rs:326.

2. The parse and default, inside load_config() at config.rs:793. The canonical shape is:

env_string("CORECRUXD_X")
    .or(file_config.section.field.clone())
    .unwrap_or(default)

File config comes from CORECRUXD_CONFIG_PATH pointing at YAML, parsed into FileConfig (config.rs:644). Environment always wins over file.

3. The struct literal. The Config { ... } assembled at config.rs:1128. Miss this and it will not compile, which is the friendliest of the four failure modes.

4. Cross-field validation, if applicable. validate_embedding_selection (config.rs:609) is the only cross-field validator. It is called from main.rs at boot and aborts startup on Err. Add mutual-exclusion checks there, not scattered through the parse.

Then, two more things

Write a unit test in the module's own #[cfg(test)] mod tests. This is not optional in practice: config.rs carries a 90% region-coverage floor (ci.yml:457) and it is not in COVERAGE_IGNORE_REGEX, so every new default-handling branch is in scope. If your test mutates the environment, mark it #[serial].

Document it in config.example.env. This is convention only; nothing enforces it. Verified: the file is 436 lines, hand-maintained, with no generator. Every reference to it from code or scripts asserts only that the file exists and is copied into a release tarball, never that its contents match config.rs. Today config.rs has about 150 environment lookups and config.example.env documents about 98 distinct names. Do not add to that gap.

If the flag needs runtime wiring beyond Config, it is consumed as state.config.<field>, or held in a keep-alive tuple in crates/corecruxd/src/main.rs.

cargo test -p corecruxd config

6.4 Add a storage or on-disk artifact type

Read this section before you write any code. Missing one of three wiring points produces a bug that only appears on the next daemon restart, in the form of your data being quarantined.

This change is on the "Ask first" list at AGENTS.md:72. Open a discussion before you land it.

The three-place wiring rule

AGENTS.md:72 states it verbatim: "adding a new on-disk artifact type (update all three wiring points: storage allowlist, projection registry, load-at-startup)." The rule is real and verifiable, but it describes three mechanisms, not three identically named functions. Here is each one.

1. The storage allowlist. crates/corecrux-storage/src/lib.rs:1468:

let referenced_companion = [".ccxi", ".ccxv"].iter().any(|ext| { ... });

This sits inside the shard-open orphan sweep that runs at restart. A companion file whose extension is not in this array, or whose parent .ccxseg is gone, is moved to quarantine as orphan-{ts}-{name}.

This is the quarantine-on-restart bug class. Add a new companion type without extending this array, and the daemon quarantines every instance of it on its next restart. Nothing fails at write time. Nothing fails in your tests. It fails when someone restarts the daemon in production.

2. The projection registry. crates/corecrux-projections/src/meta.rs:

ElementLineWhat to do
Four const ..._MODULE_ID: &str valuesmeta.rs:18Add a new const MODULE_ID following the corecrux.projections.<name> naming
projection_module_registry, serialised as projectionModuleRegistrymeta.rs:177Persisted in projections-meta.json. Nothing to edit
current_projection_module_versions_at_v1(created_at)meta.rs:200The literal registration list. Add an entry to this vec!
record_current_projection_modules_v1(meta)meta.rs:219Reconciles current against persisted: stale entries become RetainedForReplay, new ones are appended and re-sorted. Nothing to edit

Skip this step and the new projection type is invisible to replay reconciliation.

3. Load at startup. crates/corecruxd/src/main.rs:774, inside the AppState construction. The comment there is explicit: reload sealed .ccxi companions when the storage layer builds them or when the local prose-ingest door is enabled, "otherwise local-ingest segments would not be served after a daemon restart."

if config.build_ccxi || config.local_ingest_enabled {
    let shards_dir = config.data_dir.join("shards");
    // ... idx.scan_and_load(&seg_dir) ...
    tracing::info!(total, "ccxi-indexes-loaded-at-startup");
}

scan_and_load is at index_manager.rs:176.

There are two shapes here, and which one you need depends on cost. The .ccxi index uses the eager scan above. The .ccxv dense companion uses a factory closure instead, at main.rs:1159, .with_dense_provider_factory({ ... }), because building a dense provider eagerly at boot is not something you want on every start. Choose the eager scan for cheap artefacts and the factory for expensive ones.

The minimum touch list

crates/corecrux-storage/src/lib.rs:1468        extension allowlist        always
crates/corecrux-projections/src/meta.rs:18     new const MODULE_ID        if projection-backed
crates/corecrux-projections/src/meta.rs:200    registration vec! entry    if projection-backed
crates/corecruxd/src/main.rs:774               eager scan at startup      cheap artefacts
crates/corecruxd/src/main.rs:1159              factory closure            expensive artefacts

Prove the restart path

A unit test is not enough here. Write a test that seals an artefact, restarts the storage layer, and asserts the file is not in quarantine. That is the failure this rule exists to prevent.

6.5 Add a test

Three homes. Pick by what you are testing.

HomeUse whenExample
Inline #[cfg(test)] mod tests at the bottom of the moduleYou are testing one module's logic. This is the defaultcrates/corecruxd/src/problem.rs
crates/<crate>/tests/<name>.rsYou need the crate's public API only, or the test is a matrix or a corpus runcrates/corecrux-segment/tests/corruption_matrix.rs
crates/crux-integration-tests/tests/<suite>.rsYou need a real running daemon over HTTP or gRPCcrates/crux-integration-tests/tests/daemon.rs

For the HTTP surface specifically, the inline home is crates/corecruxd/src/http/tests.rs, a single module of over 18,000 lines, declared at mod.rs:1929. Follow the surrounding patterns; there are hundreds of examples of the shape.

Five rules that will save you a red CI:

Mark environment-mutating tests #[serial]. There are 342 such references already. serial_test = "3" is a workspace dependency.

Better: avoid the environment entirely. Split a flag-checking public function from a flag-free handle_inner, as reuse.rs does, and test the inner one. See 6.2.

Never hardcode a port. The integration harness picks three unused ports per daemon for exactly this reason. A hardcoded port produces the false-green failure described in 4.12.

Use tempfile::tempdir() for state. One fresh directory per test.

If the test needs the network, an external service, or more than 35 seconds, mark it #[ignore] with a reason string. The existing ignore reasons are good models: they say what is needed and how to run it.

cargo test -p <crate> <filter>
cargo test --locked --workspace

Sources