docs/jev-decision-receipts.md
Rama main · réplica de lectura del repositorio original.
# 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:<entity>`, key
`decision:<request_id>`, `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:<entity>`
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:<entity>`:
```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__::<entity>` (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 `<entity>`, never `__jev__::<entity>`. A request that names
`__jev__::<entity>` 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:<entity>` / `decision:<request_id>`
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.