Crux Daemon · 12. Sessions, cases and handoffs

Three stores carry what an agent is doing rather than what it knows: sessions (an opaque JSON blob per agent), cases (a task → action → outcome triple), and handoffs (a package one agent mints and another accepts). All three are journalled like the fact store. None of them fsyncs. And a handoff package, despite the word "signed" in its API, is authenticated by a symmetric MAC; it is not a credential and must not be treated as one.

This chapter is reference. The fact store beneath sessions and cases is chapter 10; the scopes named on the HTTP routes are defined in chapter 7; the flags are in chapter 5; defects are in chapter 16.

12.1 Sessions

SessionState (session_store.rs:29):

FieldTypeLine
session_idString:31
stateserde_json::Value:32
updated_atDateTime<Utc>:33
total_tokensusize:34
expires_atOption<DateTime<Utc>>:36
archivedbool:42
archived_atOption<DateTime<Utc>>:44
archive_reasonOption<String>:46
actorOption<String>:62

state is an arbitrary JSON Value. The daemon imposes no schema on it. Whatever your agent puts there comes back byte-for-byte.

Persistence, and three things it does not have

The shape is the same as the fact store: <data_dir>/sessions.jsonl, events store and delete (session_store.rs:21), replayed at boot with unparseable lines skipped (:115). But three properties differ from the fact store, and all three matter operationally.

No fsync. append_journal (:104) opens in append mode, writes a line and returns. There is no append_journal_durable counterpart at all, the fact store's durable path has no equivalent here. A save_session that returned success can be lost to a power failure.

No torn-tail repair. The fact store quarantines an unterminated tail before every append (fact_store.rs:497). The session store does not. A crash mid-append leaves an unterminated line in sessions.jsonl that will simply be skipped on the next replay, the session it carried is gone, silently, and the daemon boots healthy.

No size limit. There is no cap on state. total_tokens is a recorded number, not a ceiling. The only bound in play is the daemon-wide CORECRUXD_MAX_REQUEST_BODY_BYTES. CORECRUXD_SESSION_TOKEN_BUDGET limits the token-*accounting* accumulator (token_accounting.rs:96), not the stored session document. An agent that writes a growing blob to the same session id every turn will grow sessions.jsonl without limit, and every restart will replay all of it.

At 3am, the practical consequences are: session loss after an unclean shutdown is expected rather than exceptional; a truncated sessions.jsonl produces no error, only missing sessions; and boot time is linear in the file's size, so a runaway session is a slow-start problem before it is a disk problem.

Lifecycle

OperationSourceSemantics
put / put_with_actor:143, :151create or replace; mutates then appends
try_put variants:175, :185append first, propagate the error, prefer these
delete / try_delete:234, :252hard removal from the map plus a delete event, not a tombstone
set_archived / try_set_archived:276, :288the soft, reversible hide: preserved in full, excluded from default listings (:37)
reap_expired / try_reap_expired:357, :373drops sessions past expires_at

Use set_archived when you mean "hide this"; delete is irreversible.

Scoping

MCP sessions are namespaced per agent. scoped_session_id produces __agent_session::{agent}::{logical_id} (scope.rs:123), and visible_session_for_agent (scope.rs:135) returns the logical id only to the owning agent.

One asymmetry to know about: an unscoped session id is visible only when the caller has no agent identity at all (scope.rs:139). So an authenticated agent cannot see legacy unscoped sessions, and turning on agent tokens will appear to hide pre-existing sessions.

actor is None for anonymous callers by design (:56).

Status: SHIPPED.

12.2 Cases: procedural memory

A case is a (task, action, outcome) triple recording what an agent did and how it turned out, the procedural counterpart to declarative facts (case_store.rs:6). The design lineage is case-based reasoning, adapted so a CPU-only daemon can reuse experience without fine-tuning (case_store.rs:16).

Case (case_store.rs:47):

FieldTypeLineNotes
case_idString:49
taskString:52the primary retrieval key
contextOption<String>:55
actionString:57
outcomeString:59
successbool:62drives "successful precedents only"
rewardf32:65clamped to [0,1] on store
tagsVec<String>:68extra match signal
source_receiptOption<String>:71e.g. the CROWN receipt of the run
created_atDateTime<Utc>:72
times_reusedu32:77in-memory only, not journaled (:73)

RecordCase (:86) defaults success = true and reward = 1.0.

Persistence is <data_dir>/cases.jsonl with record and delete events (:38), replayed at boot skipping bad lines (:153). Like sessions: append_journal (:142) does not fsync, and there is no torn-tail repair. delete (:203) removes the case outright.

Retrieval is lexical Jaccard, not semantic

retrieve_similar (:242) scores each case by Jaccard overlap between the query's word tokens and the union of the case's task tokens and tags tokens (:289):

tokenize(text) = lowercased alphanumeric runs of length >= 2       case_store.rs:281
similarity     = |query ∩ case_terms| / |query ∪ case_terms|       case_store.rs:288

Ranking is similarity descending, then reward descending, then created_at descending. Zero-overlap cases are excluded, and only_success filters failures. Retrieval is pure; it never mutates (:238), and mark_reused (:266) is the separate, in-memory-only reuse counter.

The module doc calls retrieval "embedding-ready" (:21). It is lexical Jaccard today and no embedding path is wired for cases. There is also no similarity threshold, so any single shared token of two or more characters produces a hit. If your task descriptions share boilerplate ("update the", "run the"), expect low-relevance matches near the tail of the result set, filter on the returned similarity yourself.

HTTP surface

RouteScopes (any of)Body
POST /v1/casesfacts:write, admin:write (cases.rs:49)a RecordCase
POST /v1/cases/retrievequery:read, admin:read (cases.rs:55){task, top_k, only_success} (cases.rs:30)

top_k defaults to 5 and is capped at MAX_RETRIEVE_TOP_K = 100 (cases.rs:46); only_success defaults to false. One implementation note that matters if you are reading the source: the CaseStore is not a field on AppState; it is injected via an axum Extension layer to avoid churning roughly 25 AppState construction sites (cases.rs:8).

Status: SHIPPED, journal, replay, routes and scope checks are all real. Retrieval is lexical.

12.3 Handoffs

create_handoff and accept_handoff are MCP-only, dispatched at mod.rs:2935. There are no handoff routes on the HTTP coordination surface: corecruxd/src/http/coord.rs serves exactly two routes, GET /v1/coord/active and POST /v1/coord/announce (coord.rs:6).

The package format

HandoffPackage (handoff.rs:61):

FieldTypeLine
session_idString, the logical id:64
session_stateOption<Value>:65
factsVec<Fact>:66
created_atRFC 3339 string:67
source_agentString:68
target_agentOption<String>:70
messageOption<String>:71
work_idsVec<String>:76
task_recordOption<TaskRecord>:82

task_record is placed last deliberately, so a package without it is byte-identical to a pre-task_record package. TaskRecord (:36) carries requester, desired_outcome, sources, acceptance_criteria, boundaries and blocker_rule, all skip-if-empty, so an all-empty record serialises to {}.

The wire envelope is SignedHandoff (:86):

FieldContents
payload_b64base64 of the package JSON
content_hashBLAKE3 hex over the raw payload bytes
signature_b64the MAC (see below)
signature_algthe constant "blake3-mac-v1" (:23)

The tool returns this as a JSON string inside a text content block (tools/handoff.rs:69) and accepts it back as a string parameter (tools/handoff.rs:96).

What travels, and what does not

Bundled:

  • Session state, read from the scoped stored id __agent_session::<agent>::<logical> (handoff.rs:152).
  • Facts, only with include_facts: true, default false (mod.rs:1138). collect_relevant_facts (handoff.rs:286) keeps non-deleted, non-private, source-agent-visible facts whose id is referenced in the session-state JSON, or whose entity equals the session id, or which live under __decisions__::<session_id>. Fact ids are harvested by scanning every JSON string for an f_ prefix (handoff.rs:375) and sorted by (stored_at, fact_id) for determinism.
  • Work ids (handoff.rs:324): a BTreeSet union of session-state strings starting w_ or execplan: and the trailing segment of any bundled __work__:: fact.
  • Task record, verbatim from the caller.

Not bundled: receipts, artefacts, the entity/edge substrate, private facts, and file paths other than free text the caller places in TaskRecord::sources or the opaque state blob.

The signature is a symmetric MAC

compute_mac is blake3::keyed_hash(handoff_key, payload_bytes) (handoff.rs:282), documented at handoff.rs:8.

This is symmetric. The verifier holds the same secret that mints packages. It proves "minted by a server holding this key"; it does not prove "minted by this named agent". There is no public key, no asymmetry and no non-repudiation. Anyone with the key can mint a package claiming any source_agent.

Key derivation (dispatch.rs:370):

  • If CRUX_MCP_HANDOFF_SECRET is set, the key is blake3::hash(secret_bytes), a bare hash of the environment string, with no salt, no KDF stretching and no domain-separation tag.
  • If unset, the key is derived from rand::rng().fill_bytes, fresh random material per process.

The daemon builds one shared context (main.rs:1116) and each per-request context copies the same 32 bytes by value (server.rs:118).

With the secret unset, the default, handoff keys are process-local and rotate on every restart, and differ between replicas. A package minted before a restart fails with SignatureInvalid afterwards; a package minted on one replica cannot be accepted on another. config.example.env states this accurately at :194.

Accept-path checks, and what is absent

In order (handoff.rs:205):

  1. signature_alg == "blake3-mac-v1", else UnsupportedSignatureAlgorithm.
  2. Recomputed BLAKE3 equals content_hash, else HashMismatch.
  3. Recomputed MAC equals the supplied signature, else SignatureInvalid.
  4. If target_agent is named, the receiving agent must match exactly, else TargetAgentMismatch.

That is the complete list. What is not checked, and must not be assumed:

  • No expiry. created_at is written at handoff.rs:176 and never read in the accept path (handoff.rs:198). No TTL, no not_after, no clock check. A package minted a year ago is as valid as one minted a second ago, provided the key has not rotated.
  • No replay protection. No nonce, no jti, no consumed-package set. A valid package can be accepted an unlimited number of times, and each accept re-writes the session and re-stores every bundled fact (handoff.rs:243), minting a new fact version each time.
  • Not a constant-time comparison. The MAC check (handoff.rs:222) is an ordinary != on a Vec<u8> against a [u8;32]. The practical impact is low, the verifier holds the key anyway, but no security document should claim constant-time verification here.

A handoff package is not a signed credential. It is a transport envelope with an integrity check that only a holder of the server's own key can produce or verify. Treat it as "this came from my daemon and has not been altered in transit", never as "this proves agent X authored this", and never as something safe to accept more than once.

Storage, and what is lost on accept

Handoffs are never persisted server-side. The source states it: "handoffs are client-held, never server-persisted" (orchestrators.rs:732). The orchestrator resolver echoes such members as {type, ref, missing: true} (orchestrators.rs:754). If the client loses the string, the handoff is gone; there is nothing to re-fetch.

On accept, the session lands under __agent_session::<receiver>::<session_id> (handoff.rs:242) and the facts are re-stored (handoff.rs:261) with three fields flattened:

FieldValue on the receiving sideLine
tenant_hashhardcoded "default":262
horizon_classNone:269
actorNone:270

Tenant and actor attribution are lost across a handoff. Non-private entities are preserved verbatim (handoff.rs:258); the one backstop is that FactStore::store runs fact_privacy::enforce_global (fact_store.rs:1139), flipping any born-private-prefix entity back to private: true.

Telemetry, and the other handoff

maybe_emit_handoff_observation (tools/handoff.rs:170) is a no-op unless CORECRUXD_HANDOFF_OBSERVATIONS is truthy, default false (config.rs:1347). Its payload schema is crux.s1.handoff_observation.v1 (tools/handoff.rs:20).

Separately, POST /v1/workbench/handoff-v2 (workbench.rs:551) is a different, unsigned format. Its "receipt" (workbench.rs:864) has no signature field at all, only a receipt_id derived from a BLAKE3 prefix of the payload. It is entitlement-gated and returns 402 PAYMENT_REQUIRED unless the handoff:v2 Pro capability is enabled (workbench.rs:800). Do not confuse the two: the MCP handoff is MAC-authenticated, the HTTP v2 handoff is not authenticated at all.

12.4 Status summary

CapabilityStatus
Session store: journal, replay, archive, expiry reaping, per-agent scopingSHIPPED
Session fsyncNot present
Session torn-tail repairNot present, an unterminated line is skipped and its session lost
Session size limitNot present: total_tokens is recorded, never enforced
Case store: journal, replay, HTTP routes, scope checksSHIPPED
Case retrievalSHIPPED, lexical Jaccard; no embedding path, no similarity threshold
Case fsync / torn-tail repairNot present
create_handoff / accept_handoff (MCP)SHIPPED, unflagged
Handoff authenticationSymmetric BLAKE3 MAC: no public key, no non-repudiation
Handoff expiryNot present
Handoff replay protectionNot present, unlimited accepts
Handoff server-side persistenceNot present, client-held only
Handoff telemetryFLAG CORECRUXD_HANDOFF_OBSERVATIONS, default false
POST /v1/workbench/handoff-v2SHIPPED, entitlement-gated (402 without handoff:v2), and unsigned

Sources