HTTP API · 1. Facts and memory
Forty-nine of the daemon's 347 route registrations read or write the memory substrate directly. This chapter documents all of them.
Planes covered: Facts · Substrate (entities, edges, kinds) · Relations graph · Memory (import, auto-capture, engrams) · Cases · Append and local prose ingest · Result-envelope import · Features lens · Tenant sync.
Read chapter 0 first for the auth model, the request-field notation (req, opt, dflt), the error shape and the ingress limits. This chapter does not repeat them.
Two things to know before you write anything:
- A fact write is deliberate. Nothing on this plane captures automatically in the default configuration. The auto-capture routes in §1.6 are gated behind
CORECRUXD_AUTO_CAPTURE, which is off by default, and even when on they produce candidates that a human or an agent must promote. - Deletion is a tombstone. Fact deletes write a superseding record rather than removing a row, so history remains reconstructable. Reads take the latest fact per entity-and-key pair.
1.1 Facts
The core key-value-with-provenance store. Nine registrations.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
GET | /v1/facts | facts.rs:639 query_facts | any-of query:read, admin:read · Read | query: query (String, opt), entity (String, opt), entity_prefix (String, opt), top_k (usize, opt), token_budget (usize, opt), min_effective_confidence (f32, opt), as_of (String, opt) | keys facts, total_tokens | 400, 401, 503 | - | R |
PUT | /v1/facts | facts.rs:453 put_fact | any-of facts:write, admin:write · Write | body StoreFact: entity (String, req), key (String, req), value (String, req), tenant_hash (String, dflt default_tenant_hash), source_receipt (String, opt), confidence (f32, dflt default_confidence), private (bool, dflt), horizon_class (HorizonClass, dflt), actor (String, dflt) | 201, fact record | 401 | - | W · facts |
PUT | /v1/facts/bulk | facts.rs:483 put_facts_bulk | any-of facts:write, admin:write · Write | body: array of StoreFact as above | 201, keys facts | 400, 401, 500 | - | W · facts |
GET | /v1/facts/entity/{entity} | facts.rs:604 get_facts_by_entity | any-of query:read, admin:read · Read | path entity | keys facts | 401 | - | R |
GET | /v1/facts/export | facts.rs:735 export_facts | any-of query:read, admin:read · Read | query: since (String, opt), cursor (String, opt), limit (u32, opt) | keys exported_at, facts, has_more, next_cursor | 401 | - | R |
GET | /v1/facts/list | facts.rs:805 list_facts | any-of query:read, admin:read · Read | query: cursor (String, opt), limit (usize, opt), include_reserved (String, opt), include_superseded (String, opt), entity_prefix (String, opt), q (String, opt), as_of_unix_ms (i64, opt) | keys facts, has_more, limit, next_cursor, total_nondeleted, total_visible | 400, 401 | - | R |
GET | /v1/facts/{factId} | facts.rs:527 get_fact | any-of query:read, admin:read · Read | path factId | Fact record | 401, 404 | - | R |
DELETE | /v1/facts/{factId} | facts.rs:561 delete_fact | any-of facts:write, admin:write · Write | path factId | keys deleted | 401, 404, 500 | - | W · facts |
POST | /v1/facts/aggregate | facts.rs:712 post_aggregate | any-of query:read, admin:read · Write | body AggregateRequestV1: op (AggregateOp, req), entity (String, dflt), key (String, dflt), query (String, dflt), as_of (DateTime, dflt), token_budget (usize, dflt) | Aggregate result | - | , | R, mounted as a write-class POST |
The scope correction you may be carrying forward. The repository's own docs/api-reference.md documents PUT /v1/facts, PUT /v1/facts/bulk and DELETE /v1/facts/{factId} as needing query:read. They are writes. require_fact_write_ctx requires any-of facts:write, admin:write (facts.rs:138), and the route-auth contract agrees. A token provisioned from that older document will work today under the default shadow mode and start failing the moment an operator sets CORECRUXD_ROUTE_AUTH=enforce. Provision write scopes for writes.
POST /v1/facts/aggregate is the mirror-image oddity: it reads, its handler checks read scopes, but because it is a POST the route-auth contract classifies it Write. Under enforce a caller therefore needs a write-class scope to run a read. That is the contract as shipped.
Privacy on the fact write path
PUT /v1/facts/bulk rejects private: true with a 400. Separately, fact_privacy::enforce forces private = true for reserved entity prefixes on every fact write path (mod.rs:440), including the ones that did not ask for it. A fact you wrote as public can come back marked private if its entity prefix is reserved. Check the stored record, not your request body.
1.2 Substrate: entities, edges and kinds
A typed object graph over the same store. Ten registrations.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
GET | /v1/entities | entities.rs:58 list_entities | any-of facts:read, admin:read · Read | query: kind (String, opt), limit (usize, opt), include_deleted (bool, dflt) | keys count, entities | - | , | R |
GET | /v1/entities/{kind}/{id} | entities.rs:43 get_entity | any-of facts:read, admin:read · Read | path kind, id | keys entity | 404 | - | R |
PUT | /v1/entities/{kind}/{id} | entities.rs:77 put_entity | any-of facts:write, admin:write · Write | path kind, id; body: payload (Value, req) | keys entity | 400 | - | W |
DELETE | /v1/entities/{kind}/{id} | entities.rs:114 delete_entity | any-of facts:write, admin:write · Write | path kind, id | keys entity | 404 | - | W |
GET | /v1/entities/{kind}/{id}/history | entities.rs:100 get_entity_history | any-of facts:read, admin:read · Read | path kind, id | keys count, versions | - | , | R |
GET | /v1/edges | entities.rs:164 list_edges | any-of facts:read, admin:read · Read | query: from_kind (String, opt), from_id (String, opt), to_kind (String, opt), to_id (String, opt), edge_kind (String, opt), limit (usize, opt), include_deleted (bool, dflt) | keys count, edges | - | , | R |
PUT | /v1/edges | entities.rs:187 put_edge | any-of facts:write, admin:write · Write | body: from_kind (String, req), from_id (String, req), edge_kind (String, req), to_kind (String, req), to_id (String, req), payload (Value, dflt) | keys edge | 400 | - | W |
DELETE | /v1/edges | entities.rs:211 delete_edge | any-of facts:write, admin:write · Write | body: from_kind (String, req), from_id (String, req), edge_kind (String, req), to_kind (String, req), to_id (String, req) | keys edge | 404 | - | W |
GET | /v1/kinds | entities.rs:236 list_kinds | any-of facts:read, admin:read · Read | - | keys count, kinds | - | , | R |
GET | /v1/kinds/{kind} | entities.rs:246 get_kind | any-of facts:read, admin:read · Read | path kind | keys registration | 404 | - | R |
DELETE /v1/edges takes a body, not query parameters. That is unusual and it is deliberate: an edge is identified by a five-part composite key.
1.3 Relations graph
A numeric artifact-id graph with confidence-weighted edges. Four registrations.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
GET | /v1/relations | relations.rs:126 get_relations | admin:read · Read | query: tenant_id (String, req), from_id (u32, req) | keys edges, from_id, to_id, edge_type, confidence, created_at_micros, updated_at_micros, tenant_id | 400 | - | R |
POST | /v1/relations | relations.rs:76 post_relation | any-of facts:write, admin:write, tenant-bound · Write | body: tenant_id (String, req), from_id (u32, req), to_id (u32, req), edge_type (String, req), confidence (f32, dflt default_confidence), created_at_micros (i64, dflt), updated_at_micros (i64, dflt) | Relation record | 400, 500 | - | W |
GET | /v1/relations/incoming | relations.rs:166 get_incoming_relations | admin:read · Read | query: tenant_id (String, req), to_id (u32, req), edge_type (String, dflt), cursor (String, dflt), limit (usize, dflt) | keys edges, from_id, to_id, edge_type, confidence, cursor, next_cursor, limit, created_at_micros, updated_at_micros, tenant_id | 400 | - | R |
POST | /v1/relations/expand | relations.rs:227 post_expand | admin:read · Write | body: tenant_id (String, req), seed_artifact_ids (Vec u32, req), edge_types (Vec String, dflt), max_hops (u32, dflt default_max_hops), budget (usize, dflt default_budget), min_confidence (f32, dflt) | keys artifacts, artifact_id, hop_distance, score, stats, edges_traversed, edge_types_used, hops_used, budget_remaining | 400 | - | R, mounted as a write-class POST |
1.4 Append and local prose ingest
Two registrations. Both raise the ingress body limit; see chapter 0 §0.8.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/append | append.rs:36 post_admin_append | admin:write · Write | body: tenant_id (String, req), stream_type (String, req), stream_id (String, req), expected_next_seq (u64, dflt), events (Vec AppendEventBody, req) | keys appended, stream_id | 400 | - | W |
POST | /v1/local/ingest | local_ingest.rs:200 post_local_ingest | admin:write, tenant-bound · AdminWrite | body: tenant_id (String, req), corpus_id (String, req), documents (Vec LocalIngestDocument, req), semantic_profile (SemanticProfile, dflt) | keys ingested, documents, frame_count, segment_seq, sealed, receipt_id, dense_dim, dense_vectors, capability, code, max_text_bytes, max_texts, max_total_text_bytes | 413, 422, 500 | CORECRUXD_LOCAL_INGEST, default on | W · receipt |
/v1/local/ingest serialises on a process-wide mutex (mod.rs:369) so two concurrent seals cannot race on the shard's exclusive storage handle. Concurrent callers queue; they do not fail. A long ingest can therefore make a second ingest appear to hang until the router's 30-second timeout returns 408.
The 413 here is the ingest's own size accounting (max_text_bytes, max_texts, max_total_text_bytes come back in the response so you can see which cap you hit), distinct from the ingress-layer 413.
1.5 Result-envelope import
One registration. Verifies a platform signature and a BLAKE3 content hash before importing.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/result-envelope/import | result_envelope.rs:96 post_result_envelope_import | facts:write, tenant-bound · Write | body ResultEnvelope: schema_version (String, req), job_id (String, req), tenant_id (String, req), payload (EnvelopePayload, req), blake3_content_hash (String, req), platform_signature (PlatformSignature, req), passport_fpr (String, dflt), credit_spend_receipt (String, dflt), companion_artifacts (Vec CompanionArtifact, dflt) | keys status, imported_at, at, job_id, tenant_id, artefact_id, blake3_content_hash, companion_artifacts, counts, credit_spend_receipt, edges, entities, key_id, purpose_tag, reason, schema, sealed, size_bytes | 400, 403, 409, 500 | - | W · receipt, facts |
409 means the envelope has already been imported. 403 means the platform signature did not verify against the configured key. Neither is retryable without changing the request.
1.6 Memory: import, auto-capture and engrams
Eight registrations. Four of them are gated off by default.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/memory/import | memory_import.rs:79 post_memory_import | any-of facts:write, admin:write, tenant-bound · Write | body: tenant_id (String, req), pack (CruxPack, req), dry_run (bool, dflt), principal_map (map String to String, dflt) | ok, dry_run, pack_hash, pack_passport_fpr, imported_facts, collisions_superseded, skipped_duplicate_facts, imported_sessions, skipped_sessions, private_facts | 400, 401, 403, 404, 500 | CRUX_MEMORY_IMPORT, default off | W · facts |
POST | /v1/memory/extract | memory_capture.rs:100 post_extract | any-of facts:write, admin:write · Write | body: text (String, req), session_id (String, dflt), profile (String, dflt), session_date (String, dflt) | schema, extracted, written, skipped_existing, candidates | 500 | CORECRUXD_AUTO_CAPTURE, default off | W · receipt, facts |
GET | /v1/memory/candidates | memory_capture.rs:183 get_candidates | any-of query:read, admin:read · Read | query: status (String, dflt) | keys candidates, count, schema | 400 | CORECRUXD_AUTO_CAPTURE, default off | R |
POST | /v1/memory/candidates/{id}/promote | memory_capture.rs:226 post_promote | any-of facts:write, admin:write · Write | path id; body: reviewer (String, dflt), auto_threshold (f32, dflt) | keys candidate_id, promoted_fact_id, schema, status | 404, 409, 422, 500 | CORECRUXD_AUTO_CAPTURE, default off | W · facts |
POST | /v1/memory/candidates/{id}/reject | memory_capture.rs:276 post_reject | any-of facts:write, admin:write · Write | path id; body: reason (String, req) | keys candidate_id, schema, status | 404, 409, 500 | CORECRUXD_AUTO_CAPTURE, default off | W · facts |
GET | /v1/engrams | engrams.rs:71 list_engrams | any-of query:read, admin:read · Read | query: intent_bucket (String, dflt) | keys engrams, total, id, name, version, enabled, intent_bucket, query_pattern, capability_class_min, capability_class_max, generated_class, inherited_reason, policy_hash, prompt_hash, source_chunk_hashes, source_chunk_set_hash, created_at_unix_ms, schema | - | , | R |
POST | /v1/memory/engrams/resolve | engrams.rs:166 resolve_engrams | any-of query:read, admin:read · Write | body: names (Vec String, dflt), tenant_id (String, dflt), tenant_id_camel (String, dflt), agent_id (String, dflt), agent_id_camel (String, dflt), manifest_hash (String, dflt), model_id (String, dflt), model_id_camel (String, dflt) | keys engrams, name, version, content, engram_set_hash, manifest_hash, manifest_status, applicable_why, generated_class, inherited_reason, policy_hash, prompt_hash, receipt_id, receipt_linkage, source_chunk_hashes, source_chunk_set_hash, agent_id, tenant_id, schema | 403, 422 | - | W · receipt |
POST | /v1/memory/session-init | engrams.rs:122 memory_session_init | any-of sessions:read, query:read, admin:read · Write | body: tenant_id (String, dflt), tenant_id_camel (String, dflt), agent_id (String, dflt), agent_id_camel (String, dflt), model_id (String, dflt), model_id_camel (String, dflt) | keys body, capability_class, engram_manifest, hash, passport_id, schema, session_procedure, session_procedure_hash | - | , | W |
Auto-capture is gated extraction, not ambient capture. With CORECRUXD_AUTO_CAPTURE on, POST /v1/memory/extract produces candidates from text you hand it. Candidates are inert until promoted through /promote, and a rejection keeps the audit trail rather than deleting it. With the flag off, the default, all four routes return 404.
The tenant_id and tenant_id_camel pairs on the engram routes exist because both tenant_id and tenantId are accepted on the wire. Send one.
1.7 Cases (procedural memory)
Two registrations. A case is a task-action-outcome record with a reward, used for retrieval of prior procedure.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/cases | cases.rs:61 record_case | any-of facts:write, admin:write · Write | body: task (String, req), action (String, req), outcome (String, req), context (String, dflt), success (bool, dflt default_success), reward (f32, dflt default_reward), tags (Vec String, dflt), source_receipt (String, dflt) | keys case | 400 | - | W |
POST | /v1/cases/retrieve | cases.rs:78 retrieve_cases | any-of query:read, admin:read · Read | body: task (String, req), top_k (usize, dflt default_retrieve_top_k), only_success (bool, dflt) | keys cases | - | , | R |
The case store is injected as an axum extension at the innermost layer (mod.rs:1542), which is why these two routes are the only ones that need it.
1.8 Features lens (capability registry)
Seven registrations. A registry of capabilities across a portfolio, with dependency edges and audit records.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
GET | /v1/features/capabilities | features.rs:84 list_capabilities | any-of facts:read, admin:read · Read | query: system (String, opt), maturity (String, opt), audit (String, opt), promise (u64, opt), search (String, opt) | keys count, items | - | , | R |
GET | /v1/features/capabilities/{id} | features.rs:98 get_capability | any-of facts:read, admin:read · Read | path id | Capability record | 404 | - | R |
GET | /v1/features/capabilities/{id}/tree | features.rs:113 get_dependency_tree | any-of facts:read, admin:read · Read | path id | keys root, upstream, downstream | 404 | - | R |
GET | /v1/features/capabilities/analysis/gaps | features.rs:185 analysis_gaps | any-of facts:read, admin:read · Read | - | Gap analysis | - | , | R |
GET | /v1/features/capabilities/analysis/promises | features.rs:194 analysis_promises | any-of facts:read, admin:read · Read | - | Promise analysis | - | , | R |
GET | /v1/features/capabilities/analysis/coverage | features.rs:203 analysis_coverage | any-of facts:read, admin:read · Read | - | Coverage analysis | - | , | R |
POST | /v1/features/capabilities/{id}/audit | features.rs:219 post_audit | any-of facts:write, admin:write · Write | path id; body: status (String, req), auditor (String, opt), notes (String, opt) | keys status, auditor, last_audited, notes | 400, 404 | - | W |
The three analysis/* paths are registered before {id}, so a capability whose id is literally analysis is unreachable. Do not mint one.
1.9 Tenant sync (peer to peer)
Six registrations. This is the daemon-to-daemon replication surface for a single tenant's collections.
| Method | Path | Handler | Auth | Request | Success response | Errors | Gate | R/W |
|---|---|---|---|---|---|---|---|---|
POST | /v1/sync/handshake/nonce | sync.rs:318 post_handshake_nonce | public · Public | - | keys nonce, ttl_seconds | 404, 500 | - | W |
GET | /v1/sync/tenants/{tenantId}/manifest | sync.rs:435 get_tenant_manifest | facts:read, tenant-bound · Read | path tenantId; query: tenant_category (String, opt), owner_id (String, opt), membership_epoch (u64, opt), role_grants (String, opt) | Manifest envelope | - | , | R |
GET | /v1/sync/tenants/{tenantId}/collections/{collection} | sync.rs:461 get_tenant_collection | facts:read, tenant-bound · Read | path tenantId, collection; query: cursor (String, opt), limit (usize, opt), include_content (bool, dflt) | Collection page | 400 | - | R |
POST | /v1/sync/tenants/{tenantId}/promotions/preview | sync.rs:487 post_promotion_preview | facts:read, tenant-bound · Write | path tenantId; body: allowlist (Vec String, dflt), include_content (bool, dflt), confirm_hash (String, dflt), records (Vec SyncCollectionRecord, dflt) | Preview envelope | - | , | R, mounted as a write-class POST |
POST | /v1/sync/tenants/{tenantId}/promotions/confirm | sync.rs:504 post_promotion_confirm | facts:write, tenant-bound · Write | path tenantId; body: allowlist (Vec String, dflt), include_content (bool, dflt), confirm_hash (String, dflt), records (Vec SyncCollectionRecord, dflt) | keys applied_count, record_hash, schema, tenant_id | 412 | - | W · facts |
POST | /v1/sync/tenants/{tenantId}/offboard | sync.rs:550 post_tenant_offboard | facts:write, tenant-bound · Write | path tenantId; body: membership_epoch (u64, dflt) | Offboard result | 500 | - | W · receipt, facts |
The 412 on confirm is the whole point of preview. You call preview, you get a confirm_hash over the exact record set, you send it back with confirm. If the local state moved in between, confirm returns 412 and applies nothing. Do not retry confirm with the same hash, re-run preview.
POST /v1/sync/handshake/nonce is Public by contract and returns a nonce with a 120-second TTL (mod.rs:154). It is a write in the sense that it records the nonce; it discloses nothing.
When CORECRUXD_SYNC_MUTUAL_AUTH=1, default off, route-auth skips its scope check on the five /v1/sync/tenants/* templates entirely, because the handlers authorize them with an Ed25519 peer handshake instead. See chapter 0 §0.5.
1.10 Failure modes on this plane
| Symptom | Most likely cause |
|---|---|
403 with missingAnyScope naming write scopes on a PUT /v1/facts | A token provisioned from the old api-reference.md, which documented fact writes as query:read. See §1.1. |
| A fact you wrote as public reads back private | fact_privacy::enforce forces private = true for reserved entity prefixes on every write path (mod.rs:440). |
400 on PUT /v1/facts/bulk with a body that works on PUT /v1/facts | The bulk route rejects private: true. |
404 on /v1/memory/extract or /v1/memory/candidates | CORECRUXD_AUTO_CAPTURE is off. It is off by default. |
404 on /v1/memory/import | CRUX_MEMORY_IMPORT is off. It is off by default. |
408 on a second /v1/local/ingest while a first is running | The ingest mutex serialises seals; the second call waited past the 30-second router timeout. |
412 on promotions/confirm | Local state changed after preview. Re-run preview for a fresh confirm_hash. |
413 on /v1/append at well under 64 MiB | You are hitting the global 16 MiB cap, which means the request did not match the raised-limit path list exactly. Check for a trailing slash or a query string. |
Sources
- crates/corecruxd/src/http/facts.rs:130,
require_fact_read_ctx, any-ofquery:read,admin:read - crates/corecruxd/src/http/facts.rs:138,
require_fact_write_ctx, any-offacts:write,admin:write - crates/corecruxd/src/http/entities.rs:48, substrate read scopes
- crates/corecruxd/src/http/entities.rs:83, substrate write scopes
- crates/corecruxd/src/http/relations.rs:86, relations write, tenant-bound
- crates/corecruxd/src/http/local_ingest.rs:213, local ingest tenant-bound
admin:write - crates/corecruxd/src/http/result_envelope.rs:117, envelope import tenant-bound
facts:write - crates/corecruxd/src/http/memory_import.rs:104, memory import scopes
- crates/corecruxd/src/http/memory_capture.rs:108, extract scopes
- crates/corecruxd/src/http/engrams.rs:127, session-init scopes
- crates/corecruxd/src/http/cases.rs:50, case write and read scopes
- crates/corecruxd/src/http/features.rs:89, features lens scopes
- crates/corecruxd/src/http/sync.rs:417, sync read and write scopes, tenant-bound
- crates/corecruxd/src/http/mod.rs:369, local-ingest serialisation mutex
- crates/corecruxd/src/http/mod.rs:440,
fact_privacy::enforce - crates/corecruxd/src/http/ingress.rs:54, the four 64 MiB routes

