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-router | routes() at |
|---|---|
http/observe_audit.rs | observe_audit.rs:134 |
http/orchestrators.rs | orchestrators.rs:59 |
http/punchcards.rs | punchcards.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_completefails (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_routerfails (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_syncfails (route_spec_drift.rs:784).
6. Optionally annotate for OpenAPI schema richness. There are two independent layers here and they are easy to confuse:
| Layer | What it is | Required? |
|---|---|---|
#[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 manifest | Mandatory. 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.
| Test | Asserts |
|---|---|
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_only | No admin write route accepts admin:read alone |
feature_gated_write_routes_have_write_scope | A 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, editrouter_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.
| Point | Where | Mandatory? |
|---|---|---|
| The catalogue | list_tools_with_flags (mod.rs:156), a vec![] literal. list_tools() at mod.rs:131 is a thin wrapper | Yes |
| The dispatch arm | call_tool (mod.rs:2869), one match arm per tool, falling through to METHOD_NOT_FOUND | Yes |
| The tier surface | TOOL_SURFACE (tool_surface.rs:23) | Only for HostedGated tools |
| The envelope opt-in | tool_emits_envelope (mod.rs:99), paired with an arm in crate::envelope::build_envelope_for_tool | Optional |
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:
| Element | Line | Why it matters |
|---|---|---|
pub const FEATURE_FLAG_ENV: &str = "CORECRUXD_FEATURE_REUSE_CHECK"; | reuse.rs:31 | The flag name is a public constant, not a string literal scattered around |
pub fn reuse_check_enabled() -> bool | reuse.rs:37 | Default off. New tools ship default-off |
| The disabled-path error carries the flag name | reuse.rs:50 | The message literally tells the caller which variable to set |
pub async fn handle_reuse_check(args, ctx) | reuse.rs:55 | Checks the flag, then delegates |
async fn handle_inner(args, ctx) | reuse.rs:64 | A 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 tests | reuse.rs:200 | Tests 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:
| Test | Fails how |
|---|---|
list_tools_returns_expected_count | Count mismatch |
tool_names_unique | Runs over the same list; a duplicated name fails here |
| The passport-gated surface assertion | Asserts 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
| Helper | Line | Behaviour |
|---|---|---|
env_string(key) | config.rs:734 | Trims; empty becomes None |
env_bool(key) | config.rs:738 | Standard boolean parse |
env_default_on(key) | config.rs:744 | Default-on flags. Only an explicit 0 or false, case-insensitive, disables |
env_csv(key) | config.rs:753 | Comma-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:
| Element | Line | What to do |
|---|---|---|
Four const ..._MODULE_ID: &str values | meta.rs:18 | Add a new const MODULE_ID following the corecrux.projections.<name> naming |
projection_module_registry, serialised as projectionModuleRegistry | meta.rs:177 | Persisted in projections-meta.json. Nothing to edit |
current_projection_module_versions_at_v1(created_at) | meta.rs:200 | The literal registration list. Add an entry to this vec! |
record_current_projection_modules_v1(meta) | meta.rs:219 | Reconciles 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.
| Home | Use when | Example |
|---|---|---|
Inline #[cfg(test)] mod tests at the bottom of the module | You are testing one module's logic. This is the default | crates/corecruxd/src/problem.rs |
crates/<crate>/tests/<name>.rs | You need the crate's public API only, or the test is a matrix or a corpus run | crates/corecrux-segment/tests/corruption_matrix.rs |
crates/crux-integration-tests/tests/<suite>.rs | You need a real running daemon over HTTP or gRPC | crates/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
- crates/corecruxd/src/http/mod.rs:455,
router - crates/corecruxd/src/http/mod.rs:462,
router_with_route_auth - crates/corecruxd/src/http/mod.rs:1929, the HTTP test module
- crates/corecruxd/src/http/route_auth.rs:75,
classify_route - crates/corecruxd/src/http/route_auth.rs:607,
route_auth_middleware - crates/corecruxd/src/http/route_auth.rs:798,
router_routes, the source-text scanner - crates/corecruxd/src/http/route_auth.rs:814,
route_auth_matrix_is_complete - crates/corecruxd/src/http/openapi.rs:26, the utoipa
ApiDocderive - crates/corecruxd/src/http/openapi.rs:142,
const ROUTES - crates/corecruxd/tests/route_spec_drift.rs:106,
GATED_MUTATIONS - crates/corecruxd/tests/route_spec_drift.rs:228,
READ_POST_ROUTES - crates/corecruxd/tests/route_spec_drift.rs:757,
route_manifest_matches_router - crates/crux-mcp/src/tools/mod.rs:156,
list_tools_with_flags - crates/crux-mcp/src/tools/mod.rs:2869,
call_tool - crates/crux-mcp/src/tools/mod.rs:3108,
TOOL_COUNT - crates/crux-mcp/src/tools/reuse.rs:55, the canonical tool handler split
- crates/vaultcrux-local/src/tool_surface.rs:208,
tool_tier, defaults toLocal - crates/corecruxd/src/config.rs:326,
struct Config - crates/corecruxd/src/config.rs:1128, the
Configstruct literal - crates/corecrux-storage/src/lib.rs:1468, the companion-extension allowlist
- crates/corecrux-projections/src/meta.rs:200, the projection registration list
- crates/corecruxd/src/main.rs:774, load-at-startup wiring
- crates/corecruxd/src/main.rs:1159, the dense-provider factory variant
- AGENTS.md:72, the three-place wiring rule, verbatim

