# Jev decision receipts cookbook [Jev](https://docs.typesafe.ai/) (TypeSafe AI's System One model) answers typed questions (Choice / Score / Noul) about a `state` in a few hundred milliseconds, and keeps nothing. Agents call it at decision points: guardrails, routing, escalate-to-human. This cookbook gives each of those calls a record you can verify later, using `crux_adapters.jev.decide` and a local Crux daemon. ## What you get One `decide(...)` call does four things: 1. **Builds the state from Crux.** `GET /v1/context` for your entity, bounded by `token_budget`, becomes `state.trusted_context`. Anything you pass as `untrusted=` (tool output, email, web pages, the proposed action) goes into `state.untrusted_input` and nowhere else. 2. **Calls Jev once** (`POST /v1/systemone`). 3. **Mints a signed CROWN `model_invocation` receipt.** The daemon signs (Ed25519) a body binding: - `prompt_hash`: `digest({state, questions})` - `retrieval_set_hash`: `digest([[item id, digest(text)], ...])` for the retrieved context, where `digest(text)` hashes the text as a JSON string literal, quotes included (see [Canonical hashing](#canonical-hashing)) - `output_hash`: `digest({model, answers})` - the model alias and exact `model_version`, Jev's `x-typesafe-request-id` as `provider_request_id`, and a `provider` label (`typesafe`, or what you pass as `decide(..., provider=...)`) 4. **Stores the decision as a fact**: entity `jev:`, key `decision:`, `source_receipt` set to the receipt id. The fact value holds the answers, the evidence list and the three hashes, so you can query past decisions per entity and recompute two of the hashes from the fact alone. ### Canonical hashing Every hash is `digest(value)`, which is exactly: ```python "sha256:" + hashlib.sha256( json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False) .encode("utf-8") ).hexdigest() ``` - **Key order is preserved, not sorted.** This is *not* RFC 8785 / JCS, which sorts keys: Jev reads key order, so reordering a Choice's options is a different prompt and must hash differently. Sorting would erase exactly that. - **Compact:** no whitespace. Non-ASCII is written as raw UTF-8, not `\u` escapes. Strings get only JSON's mandatory escapes: `\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, and `\u00XX` (lower-case hex) for other control characters below U+0020. `/` and U+007F are not escaped. - **Numbers:** integers in plain decimal, up to Python's integer-string limit (4300 digits by default, `sys.set_int_max_str_digits`); a longer integer is refused (`ValueError`) rather than hashed. Floats as Python's `repr`: shortest round-trip digits, always with a `.` or exponent (`1.0`, `0.1`, `2e-05`, `1e+16`, `-0.0`), exponent form when the exponent is below -4 or at least 16. `1` and `1.0` hash differently. NaN and infinities are refused (`ValueError`). - **Strings are hashed as JSON too.** An evidence digest is over `"text"`, quotes and escapes included, not over the raw text: `digest("approver: dana")` is `sha256` of the 16 bytes `"approver: dana"`. A verifier in another language must parse the stored JSON into an **order-preserving** map, keep each number's integer-or-float identity, and re-serialise by the rules above; JavaScript's `JSON.stringify` differs on floats (`1.0` becomes `1`, `2e-05` becomes `0.00002`), and a plain `JSON.parse` has already lost `1.0` vs `1`. The adapters' tests pin a golden vector (`tests/test_jev.py`, `test_golden_vector`) so any drift in this serialiser fails CI. ## What a receipt proves, and what it does not A receipt proves that this daemon, with this key, recorded that a decision was made with these inputs and outputs: which model version, which request id, which retrieved evidence, which answers. Change one byte of the body and verification fails. It does **not** prove: - **when the decision was made, offline.** `created_at`, `started_at` and `completed_at` in the signed body are supplied by the minting caller, so they are signed but not attested by the daemon. The online `/v1/receipts/{id}/verification` places the receipt in the daemon's observation chain; `crux-jev verify` and the openssl recipe below do not. - **that the decision was correct.** A confident wrong answer gets a valid receipt too. - **that the agent acted on it.** The receipt records the verdict, not what happened next. Record the action separately if you need that. - **that Jev produced it.** The daemon never talks to Jev. It signs the hashes your process reports. `provider_request_id` is what lets you reconcile a receipt against TypeSafe's own logs. The offline stub below is the proof: its canned answers get a valid receipt, with `model_version: offline-stub` (and `provider: offline-stub`, because the example says so: the provider label is the caller's claim too). - **what the state said, from the fact alone.** The fact stores digests of the trusted context, not the text, and does not store `untrusted_input`. To re-check `prompt_hash` later, or replay a state against a newer model, keep `decision.state` yourself or opt in with `store_state=True` (see [Replaying a decision](#replaying-a-decision-against-a-new-model-version)). ## Setup **Check it first.** `crux-jev doctor` runs every check below without spending a Jev call, and names the fix for each one that fails: the configuration `crux-jev` reads, the two daemon flags, the token's read scopes, the published signing key and the pinned keyring. Exit 0 means ready. **Daemon.** Two flags, both off by default. Set them in the daemon's environment (its systemd unit, launchd plist or `brew services` file): ```bash export CORECRUXD_STREAM_RECEIPTS=1 # POST /v1/mediation/receipts signs model_invocation drafts export CORECRUXD_CONTEXT_SURFACE=1 # GET /v1/context; 404s when off, so off cannot look empty corecruxd ``` or in its `config.yaml` (`$XDG_CONFIG_HOME/crux/config.yaml`, or `CORECRUXD_CONFIG_PATH`; the environment wins where both are set): ```yaml daemon: stream_receipts: true context_surface: true ``` `GET /v1/version` reports both under `capabilities`, which is what `doctor` reads. **Auth.** The packaged default is `CORECRUXD_AUTH_MODE=dev_scopes`, where the bearer token *is* the comma-separated scope list. Under it, the token `crux-jev` needs is the literal string `query:read,facts:write,sessions:write,receipts:read`. An `rcxct_…` token written by `corecruxctl login` is read as one unknown scope and gets 403. Under `jwt_hs256`, mint a token with exactly the four scopes below for tenant `local`. With auth on, the token needs these scopes: | Scope | Used by | |---|---| | `query:read` | `GET /v1/context`, `GET /v1/facts*`, `GET /v1/observations/aggregate` | | `facts:write` | the `/v1/mediation/*` route gate, `PUT /v1/facts` | | `sessions:write` | the mediation receipt handler | | `receipts:read` | `GET /v1/receipts/{id}/verification` | An MCP agent token accepted over HTTP (`CORECRUXD_HTTP_ACCEPT_AGENT_TOKENS=1`) with the default `CORECRUXD_AGENT_TOKEN_HTTP_SCOPES` carries all four; before this release the default set lacked `receipts:read`, so such a token could mint a receipt but not verify it. That default set also carries `admin:read` and `admin:write`, so for a token that only records decisions, narrow it: `CORECRUXD_AGENT_TOKEN_HTTP_SCOPES=query:read,facts:write,sessions:write,receipts:read`. A token confined to one tenant (`CORECRUXD_AGENT_PASSPORTS=1`) can verify and list the receipts minted for its own passport even though they are signed under tenant `local`; it cannot see anyone else's. Fact writes from a passport-bearing token also need that passport registered with a category. To try this locally without setting any of that up, the fixture script starts a loopback-only daemon on port 24800 with both flags on, registers a passport, and mints a token with exactly those scopes (needs `corecruxd` on `PATH`, or `CORECRUXD_BIN`): ```bash integrations/adapters/examples/jev/fixture-daemon.sh start ``` It writes `fixture.env` (base URL, tenant, token file path, public key path; no secrets) and a 0600 `token` file under `JEV_FIXTURE_DIR`, default `${TMPDIR:-/tmp}/jev-crux-fixture`. `fixture-daemon.sh stop` stops it. **Python.** From a checkout of this repository: ```bash python3 -m venv .venv && . .venv/bin/activate # or: uv venv && . .venv/bin/activate pip install -e sdks/python -e integrations/adapters # or: uv pip install -e ... -e ... ``` `cuecrux-client` and `cuecrux-adapters` are not on PyPI yet, so install both from the checkout, as above, with any extra on the adapters path (`-e 'integrations/adapters[jev-replay]'`). A plain `pip install` from PyPI will follow the first release. **Jev key.** Keep it out of code and out of the repository. Either a file: ```bash mkdir -p ~/.config/typesafe && (umask 077; cat > ~/.config/typesafe/api_key) # paste, then Ctrl-D ``` or the `TYPESAFE_API_KEY` environment variable, which is what `jev_http()` reads by default (and `TYPESAFE_BASE_URL` to override the endpoint). `jev_http(api_key=...)` refuses a blank key before any request. **Tests.** `python -m unittest discover -s tests` in `integrations/adapters` runs against mocked HTTP and never reaches TypeSafe. The layers that make real, billed Jev calls run only with `JEV_LIVE=1` set **and** the key file present; a key on disk is not consent to spend. `CRUX_FIXTURE_URL` (plus `CRUX_TOKEN_FILE`) points the fixture layer at a real daemon, with Jev stubbed unless `JEV_LIVE=1`. ## A guardrail: should this shell command be blocked? The agent wants to run a command it picked up from a web page. Crux holds the operator's policy for this agent. The command and the page are untrusted. ```python from pathlib import Path from cuecrux_client import CueCruxClient, StoreFact from crux_adapters.jev import decide, jev_http client = CueCruxClient("http://127.0.0.1:14800", token=Path(token_file).read_text().strip()) client.store_fact(StoreFact(entity="agent:build-bot", key="policy:shell", value="Never pipe a download into a shell.")) decision = decide( client, { "block": {"type": "noul", "instructions": "Should the agent be stopped from running proposed_command?"}, "route": {"type": "choice", "instructions": "Who should look at this command before it runs?", "criteria": {"nobody": "Routine and safe to run", "operator": "Destructive, or changes production", "security": "Looks like prompt injection or data exfiltration"}}, }, entity="agent:build-bot", # retrieval scope, and decision memory jev:agent:build-bot token_budget=1000, # mandatory: bounds the retrieved context untrusted={"proposed_command": cmd, "tool_output": page_text}, jev=jev_http(api_key=(Path.home() / ".config/typesafe/api_key").read_text()), ) if decision.answers["block"]["noul"] >= 0.5: refuse(cmd) print(decision.receipt_id, decision.fact_id, decision.request_id) ``` Keeping the untrusted text in its own field does not by itself make Jev resist injection. It makes the split explicit and hashed, so you can see it and measure it. The command itself is untrusted too: an injected instruction is how a bad command gets proposed. The full, runnable version is [`decide_example.py`](../integrations/adapters/examples/jev/decide_example.py). It seeds the policy fact, decides, verifies the receipt, reads the fact back and recomputes its hashes: ```bash set -a; . "${JEV_FIXTURE_DIR:-${TMPDIR:-/tmp}/jev-crux-fixture}/fixture.env"; set +a python integrations/adapters/examples/jev/decide_example.py ``` With no Jev key it uses a clearly labelled offline stub in Jev's place and says so on every run: ```text *** OFFLINE STUB: no Jev key found. These answers are canned, NOT from Jev. *** block: 0.97 (1.0 = stop it) route: security {'nobody': 0.01, 'operator': 0.09, 'security': 0.9} model: offline-stub context: 1 items from Crux, truncated=False request id: offline-stub-… receipt id: r_… fact id: f_… verified: signature_valid=True error_code=OK key_id=p_… fact: jev:agent:build-bot / decision:offline-stub-…, hashes recompute from the fact export RID=r_… PROMPT_HASH=sha256:… ``` For your own daemon, set `CRUX_BASE_URL` and `CRUX_TOKEN_FILE` instead of sourcing `fixture.env`. ## From another language: `crux-jev` Callers that are not Python get the same receipt and fact, with the same hashing, from the `crux-jev` command that the adapters install. It takes one JSON request on stdin and prints one JSON result on stdout. Neither secrets nor state ever appear in argv. ```bash echo '{"entity": "paracrux:ci-triage", "questions": {"failure_class": {"type": "choice", "instructions": "Classify the root cause", "criteria": {"runner_infra": "the machine failed", "behaviour_regression": "a product test failed"}}}, "untrusted": "##[error]The runner has received a shutdown signal.", "token_budget": 400}' | crux-jev ``` The result carries `recorded`, `answers`, `model_version`, `request_id`, `receipt_id`, `fact_id`, the three hashes and `context_items`. Exit codes: | Code | Meaning | |---|---| | 0 | recorded | | 3 | Jev answered but the decision was **not** recorded; the answers are still printed, so a guardrail must fail closed | | 2 | bad request or missing configuration | | 1 | retrieval or Jev failed before any answer | Configuration is read from the environment, then from files, so that it survives sandboxes (such as Codex) that strip variables named like `*KEY*` or `*TOKEN*`: | Setting | Sources, first match wins | |---|---| | Daemon URL | `CRUX_BASE_URL`, then `CRUX_HTTP_URL`, then `CRUX_HTTP_URL` in the env file | | Daemon token | the file named by `CRUX_TOKEN_FILE`, then `CRUX_AGENT_TOKEN`, then `CRUX_AGENT_TOKEN` in the env file | | Jev key | `TYPESAFE_API_KEY`, then the file named by `JEV_CREDENTIAL_FILE`, then `~/.config/typesafe/api_key` | The env file is `CRUX_ENV_FILE`, or `~/.config/cuecrux/env` if that is unset. Run `crux-jev --help` for the full request schema. ### The measured state layout: `state_layout="split-v1"` `decide(..., state_layout="split-v1", untrusted_source="ci-log")` (or `"state_layout": "split-v1"` in a `crux-jev` request) sends Jev this state: ```json {"policy": "…", "trusted_context": […], "untrusted_inputs": {"source": "ci-log", "content": …}} ``` This is the `split` arm of the AuditCrux injection benchmark (run `jev-injection-live-20260928T212018Z`: corpus `fixture-jev-injection-v1`, `jev-1.13.0`, 50 attack and 15 control cases). The injections tested flipped 18% of verdicts with the flat layout and none with `split-v1`. None of the 15 benign controls was over-blocked. Most of that gain comes from the policy line, `UNTRUSTED_POLICY_V1`, which is byte-identical to the benchmark's text. Edit it and the result no longer applies, so re-measure first. The default layout is still `v0` (`{trusted_context, untrusted_input}`), so existing prompt hashes and behaviour do not change under current callers. Use `split-v1` whenever the untrusted input could carry instructions. ### Verifying without trusting the daemon: `crux-jev verify` `crux-jev verify` checks receipts using only a pinned public key. It needs no `receipts:read`, and it never asks the daemon to vouch for its own records. It uses the `jev-verify` extra (`blake3` and `cryptography`). ```bash crux-jev pin-key --from-pem daemon.pub.pem # out of band: the stronger option crux-jev pin-key # or fetch it: trust on first use, from the public # GET /v1/receipts/signing-keys (no token needed; # older daemons: /v1/admin/version, admin:read) crux-jev verify --entity paracrux:ci-triage r_… r_… crux-jev verify --actor passport:ci-bot r_… # also require the minting passport ``` The keyring is `CRUX_RECEIPT_KEYRING`, or `~/.config/cuecrux/receipt-keyring.json` if that is unset. It uses `corecruxctl`'s Ed25519 keyring v1 format, so the same file works with `corecruxctl receipts verify-stream-receipt --keyring`. `pin-key` refuses to replace a pinned key with a different one unless you pass `--replace`, because a changed signing key is exactly what a pin exists to catch. For each receipt, `verify` checks all of the following: - exactly one signed body claims the id; - the Ed25519 signature verifies with a pinned key; - the key id is BLAKE3-bound to that key; - the body binds the id, `kind: model_invocation`, the schema and the tenant (`local` unless `--tenant` says otherwise); - with `--actor`, the body names that `actor_passport`. Without it, any caller who can mint on the same daemon produces receipts that pass; the result reports `actor_passport` either way. With `--entity`, it also checks that every decision fact under `jev:` naming the receipt matches it: each one's output hash recomputes from its answers, and its hashes and request id equal the signed ones. An edited version stored after a good one fails the receipt. Exit codes: 0 when everything verifies, 4 when any receipt fails, 2 on a usage or configuration error (or no keyring), 1 when the daemon cannot be reached. A second body under the same id makes the check fail closed, with "2 distinct bodies claim this id". `pin-key` exits 0 when the key is pinned (or already was), 4 when the daemon now signs with a different key (re-run with `--replace` only for an expected rotation), 2 on bad arguments, and 1 when the key cannot be fetched. ## Verifying a receipt **Online**, from the daemon that signed it. Stream receipts are minted under tenant `local`: ```bash crux() { curl -sf -H @<(printf 'Authorization: Bearer %s\n' "$(cat "$CRUX_TOKEN_FILE")") "$CRUX_BASE_URL$1"; } crux "/v1/receipts/$RID/verification?tenant_id=local" \ | jq '{signature_valid, error_code, payload_hash_matches: .integrity.payload_hash_matches, chain_position_checked: .binding.chain_position_checked, key_id: .signature.key_id}' ``` (The `crux` helper keeps the token out of the process list.) **Offline**, with only the signed body, the signature and the daemon's public key. On the free daemon `GET /v1/receipts/{id}` returns 501; stream receipts live in the mediation observation log: ```bash crux "/v1/observations/aggregate?kind=model_invocation&limit=500" \ | jq --arg r "$RID" '[.observations[] | select(.payload.receipt_id == $r) | .payload]' > claims.json # One distinct body, or stop: older daemons did not keep receipt ids unique. [ "$(jq '[.[].body_cbor_hex] | unique | length' claims.json)" = 1 ] \ && jq '.[0]' claims.json > receipt.json \ || echo "STOP: $RID is claimed by no body or by several distinct bodies" >&2 jq -r .body_cbor_hex receipt.json | xxd -r -p > body.cbor jq -r .sig.signature_hex receipt.json | xxd -r -p > sig.bin openssl pkeyutl -verify -pubin -inkey "$CRUX_DAEMON_PUBKEY_PEM" -rawin -in body.cbor -sigfile sig.bin grep -qaF "$PROMPT_HASH" body.cbor && echo "prompt_hash is in the signed body" ``` A minting caller may choose a stream receipt's `receipt_id`. Daemons from this release refuse a reused id (409 `RECEIPT_ID_CONFLICT`) and `/verification` answers 409 `RECEIPT_ID_AMBIGUOUS` rather than picking when a log still holds two bodies for one id; older daemons did neither, so one id could name two signed bodies. Keep the check above either way: refuse instead of picking. `openssl pkeyutl -rawin` needs OpenSSL 3. The fixture writes the public key as `daemon.pub.pem`. For another daemon, read it from the unauthenticated `GET /v1/receipts/signing-keys` as `.keys[0].publicKeyHex` (older daemons: `GET /v1/admin/version`, `admin:read`, as `.passport.public_key_hex`). Pin it at enrolment; a key fetched from the daemon that signed the receipt is discovery, not trust: ```bash { printf '302a300506032b6570032100'; printf '%s' "$PUBKEY_HEX"; } \ | xxd -r -p | openssl pkey -pubin -inform DER -out daemon.pub.pem ``` The receipt's `key_id` is `p_` followed by the first 32 hex characters of blake3 over the raw public key, so `b3sum` ties a key to a receipt. [`receipt-recipe.sh`](../integrations/adapters/examples/jev/receipt-recipe.sh) runs every one of these checks against the fixture, plus a negative control: a one-bit flip in the body must fail verification. **Not yet:** COSE/SCITT export does not support `model_invocation` receipts. `corecruxctl receipts export-cose` takes CROWN retrieval receipts only, so `verify-cose` cannot check these today. Use the Ed25519 check above. ## Past decisions for an entity Each decision is a fact under `jev:`: ```python import json from crux_adapters.jev import digest for fact in client.get_facts_by_entity("jev:agent:build-bot"): record = json.loads(fact.value) # Two of the three signed hashes recompute from the fact alone. assert digest({"model": record["model_version"], "answers": record["answers"]}) == record["output_hash"] assert digest(record["retrieved"]) == record["retrieval_set_hash"] print(fact.key, fact.source_receipt, record["model_version"], record["answers"]["block"]) ``` Compare those hashes with the ones in the signed body (`grep -aF` as above) to show the fact has not been edited since the receipt was signed. ## When the record fails If Jev answered but the receipt or the fact was not written, `decide` raises `DecisionNotRecorded` rather than returning, so an unrecorded decision cannot pass for a recorded one. The answers are not lost: ```python from crux_adapters.jev import DecisionNotRecorded try: decision = decide(client, questions, entity=entity, token_budget=1000, untrusted=untrusted) except DecisionNotRecorded as err: unrecorded = err.decision # .answers, .state, .request_id; .receipt_id if only the fact failed log.error("jev decision not recorded: %s", err) refuse(cmd) # a guardrail fails closed ``` The usual cause is a daemon without `CORECRUXD_STREAM_RECEIPTS=1`: the message says so. Answers Jev returns with a NaN or infinity cannot be hashed; that raises `DecisionNotRecorded` too, answers kept. Failures before Jev is called raise as usual, and nothing is recorded: a `CueCruxError` 404 from `GET /v1/context` means `CORECRUXD_CONTEXT_SURFACE` is off, and Jev errors arrive as `httpx.HTTPStatusError` once retries are spent. `jev_http` retries only the statuses that mean the request was not processed -- 408, 429, 503 and 529 -- up to 3 attempts, waiting what `retry-after-ms` / `Retry-After` (seconds or an HTTP-date) asks, or backing off 0.5s, 1s. A server that asks for more than 60s gets no retry: the error is raised at once. 500, 502, 504, timeouts and connection errors are never retried, because Jev may already have run the call, and billed it. To use the official `typesafe-sdk` client instead, wrap it in a callable with the same shape (request body in, `(response JSON, request id)` out) and pass it as `jev=`. ## Replaying a decision against a new model version Opt in per decision with `store_state=True`; `replay` then re-sends the stored request -- exactly its `model`, `state` and `questions`, the fields the receipt covers -- and shows whether the verdict moved. It needs BLAKE3, which the standard library lacks: the `jev-replay` extra (the `blake3` package), from the checkout `pip install -e sdks/python -e 'integrations/adapters[jev-replay]'`. Without it `replay` raises `ImportError` before it reads anything, rather than skip the check. ```python from crux_adapters.jev import TamperedRequest, decide, replay decision = decide(client, questions, entity=entity, token_budget=1000, untrusted=untrusted, store_state=True) ref = decision.request_id or decision.invocation_id # the fact key falls back when Jev sent no request id result = replay(client, entity, f"decision:{ref}", model="jev-latest") if result.changed: print(result.old_model_version, "->", result.new_model_version) print(result.old_answers, "->", result.new_answers) ``` Before calling Jev, `replay` checks the stored request against the receipt's **signed body**, not against the copies of the hashes in the facts (anyone who can write facts could rewrite both facts to agree with each other): 1. Both facts name the same `source_receipt`. 2. The daemon reports that receipt's signature valid (`GET /v1/receipts/{id}/verification`: `signature_valid` and `error_code: OK`). 3. The signed body is read from `GET /v1/observations/aggregate?kind=model_invocation`. On older daemons anyone with `sessions:write` could add records of that kind to the listing (current daemons refuse the stream-receipt kinds on the generic observation route with 422 `RESERVED_OBSERVATION_KIND`), so nothing in a listed record is taken on trust: `replay` hashes the listed `body_cbor_hex` bytes with BLAKE3 itself and requires them to equal the `payload_hash` the daemon just verified. Every listed record claiming the receipt id must carry the same body: two distinct bodies raise `TamperedRequest` rather than pick one. That fails closed, so a junk record claiming the id blocks replay of the genuine decision. The CBOR is decoded by a strict decoder in `crux_adapters.jev`. The listed `body_hash` and the unsigned hashes next to the body are ignored. 4. From that decoded body: `prompt_hash` must equal the stored `{state, questions}` re-hashed, `model_id` the stored request's model, `provider_request_id` (or `invocation_id` when Jev sent none) the request id in the fact keys, and `output_hash` the decision fact's `{model_version, answers}`, so the old answers `replay` reports are the signed ones. Any mismatch raises `TamperedRequest`. Replay writes nothing: no receipt, no fact. What is still trusted: the daemon itself, twice. Its `/verification` does the Ed25519 check (replay does not hold the public key), and it signs whatever hashes a caller with receipt-minting scopes (`facts:write` + `sessions:write`) sends, so such a caller can mint a matching receipt for forged facts. Current daemons refuse a reused receipt id; replay and the offline checks still refuse an id claimed by more than one body, for logs an older daemon wrote. For a check that trusts only the public key, use `crux-jev verify` (above) or the openssl recipe in [Verifying a receipt](#verifying-a-receipt). Replay only searches the daemon's newest 1000 `model_invocation` observations (the route's cap); an older receipt raises `LookupError` rather than being skipped. The stored request includes your `untrusted` input verbatim, in an ordinary fact under `__jev__::` (HTTP fact writes cannot be private), so anyone who can read the tenant's facts can read it. The `__` namespace keeps it out of undirected `/v1/context` recall (a `query=` or the default bundle), so a stored injection does not come back as trusted context in a later `decide`, which names ``, never `__jev__::`. A request that names `__jev__::` itself still gets it back. Leave `store_state` off for inputs you should not retain. ## Linking the receipt from your traces Put the receipt id on the span or trace you already emit, so a trace links to the signed record. No new dependency. OpenTelemetry: ```python from opentelemetry import trace span = trace.get_current_span() span.set_attribute("crux.receipt_id", decision.receipt_id) span.set_attribute("crux.fact_id", decision.fact_id) span.set_attribute("jev.request_id", decision.request_id or "") span.set_attribute("jev.model_version", decision.model_version) ``` Langfuse (Python SDK v3): ```python from langfuse import get_client get_client().update_current_span(metadata={ "crux_receipt_id": decision.receipt_id, "crux_fact_id": decision.fact_id, "jev_request_id": decision.request_id, "jev_model_version": decision.model_version, }) ``` ## Jev through LangChain (`langchain-typesafe`) If your agent reaches Jev through [`langchain-typesafe`](https://pypi.org/project/langchain-typesafe/) (`TypeSafeClassifier`, or the `ModelRouterMiddleware` and `AutoModeMiddleware` that each build one), you do not call `decide`. Pass a callback handler in the run config instead; LangChain hands it to every Jev call in the run, the middleware's included: ```bash # From the checkout until the packages are on PyPI. pip install -e sdks/python -e 'integrations/adapters[jev-langchain]' # langchain-typesafe[experimental], tested on 0.0.1a3 ``` ```python from crux_adapters.jev_langchain import JevReceiptHandler handler = JevReceiptHandler(client, entity="agent:build-bot") agent = create_agent(model, tools=[read_file, delete_file], middleware=[ModelRouterMiddleware(...), AutoModeMiddleware(tools=[delete_file])]) agent.invoke({"messages": [...]}, config={"callbacks": [handler]}) ``` Each Jev call gets the same receipt and `jev:` / `decision:` fact as `decide` produces, with these differences: - **Crux did not build the state, LangChain did.** `retrieval_set_hash` is null in the receipt draft (so the signed body has none) and in the fact, and the fact has no `retrieved` list. The receipt makes no claim about Crux retrieval. - `prompt_hash` covers `{state, questions}` as the classifier sends them, with LangChain messages already converted to role/content JSON. `output_hash` covers `{model, answers}` as the classifier parsed them, so fields Jev adds that `langchain-typesafe` does not model are not covered. - `invocation_id` is the LangChain run id, so a LangSmith trace and its receipt share an id. - No replay: nothing is stored under `__jev__::`. - **Python 3.10, async agents:** the middleware calls the classifier's `ainvoke` without a config, and asyncio on 3.10 cannot carry LangChain's callbacks into it, so those decisions are **not recorded** and nothing says so. Use Python 3.11+ or a sync run. A direct `classifier.ainvoke(request, config={"callbacks": [handler]})` is recorded on 3.10 too. If the receipt or the fact cannot be written, the handler raises `DecisionNotRecorded` out of the classifier call. `AutoModeMiddleware` treats that like any classifier failure: the tool does not run. Set `handler.raise_error = False` to log the failure and let the agent continue without a record.