Tool-decision truth layer (assay.tool_decision_truth.v0)¶
Status: experimental v0 primitives and conformance vectors. Field names, helper names, and digest surfaces may change before this is promoted out of experimental. This page documents the current claim ceiling for the public primitives; it does not mark the broader design as complete.
Why this exists¶
Assay policies say what tool calls and argument shapes are declared. An MCP proxy can observe a specific tool call. The tool-decision truth layer binds those two sides together so a consumer can ask one narrow question:
Did this observed tool decision match the declared policy constraints that Assay can evaluate?
The answer is a deterministic lattice verdict over declared and observed data:
The layer is intentionally narrower than enforcement. It records and recomputes a decision comparison. It does not forward, block, or authorize the tool call.
Claim¶
Given a declared policy digest, an observed-input digest, and optional evidence for declared constraints such as approval, scope, class, and redaction, Assay can deterministically recompute:
- a per-decision verdict:
match,incomplete,mismatch, orinvalid; - a run verdict as the lattice maximum over ordered decisions;
- a content digest for the carrier record; and
- a recipe row that cites the carrier by content digest and fails closed on tampering.
A match means every declared constraint relevant to that decision was evaluated or proven not applicable inside this experimental surface. It does not mean the tool call was safe, compliant, or semantically correct.
Non-claims¶
- does not prove runtime truth outside the observed MCP/tool-call boundary;
- does not infer intent, maliciousness, or user authorization;
- does not prove provider-side side effects happened or persisted;
- does not certify safety, compliance, or policy quality;
- does not replace enforcement and does not make an allow/deny decision in the action path;
- mints carriers as evidence only; the live producer takes no action on the verdict and never proves provider-side execution or side effects;
- does not claim complete coverage when required evidence is absent;
- does not expose raw tool arguments, secrets, tokens, or key material;
- does not provide an OTLP exporter or live telemetry path; the experimental OTel projection is a lossy view over verified evidence;
- does not yet emit into a production pack; the import path binds the carrier and recipe row into an evidence bundle and the verifier checks them, but writing into a production pack is a separate follow-up;
- does not provide a stable external schema until the experimental marker is removed.
Three zones¶
The carrier separates identity, provenance, and classification so replay identity stays stable:
| Zone | Fields | In decision identity? |
|---|---|---|
| observed-input identity | tool_name, args_digest, order, observed_input_digest | yes, through observed_input_digest |
| observation provenance | source_class, call_id, result_status, identity_state, key_id | no |
| classification output | declared_policy_digest, decision_identity, decision_verdict, declared_ref | declared digest is part of identity; verdict is not |
decision_identity is the pair (observed_input_digest, declared_policy_digest). It is the stable join key for the logical decision. It is not the full carrier content digest.
Declared side¶
McpPolicy::declared_constraint_digest_experimental() projects the declared constraint surface:
version;- tool allow/deny lists;
- class, approval, scope, and redaction lists;
- per-tool
schemas; and enforcement.
Operational knobs such as runtime monitors, limits, discovery, signatures, and tool pins are outside this digest. Set-like fields are sorted by canonical bytes so reordering does not move the digest. Legacy constraints are normalized into the same view used by the verdict gate, and an explicit schema takes precedence over a migrated legacy constraint for the same tool.
Known limitation: schema normalization is flat v0. Top-level required and direct properties.*.enum arrays are normalized; nested schema structures are not recursively normalized yet.
Observed side¶
args_digest(args, key, key_id) drops secret-like argument keys recursively, then computes a domain-separated keyed digest. The output includes the key id:
The raw arguments are never stored in the carrier. Secret-like keys are dropped rather than hashed, because hashing low-entropy or known-format secrets can still invite recovery or correlation.
observed_input_digest(tool_name, args_digest, order) hashes only:
Changing the tool name, non-secret argument content, or order changes the observed-input digest. Changing provenance fields does not.
Verdict gate¶
decision_verdict(...) evaluates the declared policy against one observed decision. It uses the same normalized declared view as the declared digest.
The gate evaluates these axes:
- tool name allow/deny, using the policy engine's
*pattern semantics; - per-tool argument schema;
- identity state:
present,absent,required_missing, orinvalid; - tool class allow/deny;
- declared approval;
- declared scope; and
- declared redaction.
The load-bearing rule is conservative: if a declared constraint applies but the caller did not provide enough evidence to evaluate it, the axis is incomplete, never match. Malformed declared schemas are invalid, not panics and not missing evidence.
run_verdict(decision_verdicts, orders) computes the lattice maximum across a run. Duplicate orders or mismatched verdict/order lengths are invalid.
Carrier shape¶
Example carrier, shortened:
{
"schema": "assay.tool_decision_truth.v0",
"tool_name": "deploy",
"args_digest": "hmac-sha256:fixture-kid-v0:...",
"order": 0,
"source_class": "authoritative_boundary",
"call_id": "deploy_match",
"result_status": "ok",
"identity_state": "present",
"key_id": "fixture-kid-v0",
"declared_ref": null,
"decision_verdict": "match",
"observed_input_digest": "sha256:...",
"declared_policy_digest": "sha256:...",
"decision_identity": {
"observed_input_digest": "sha256:...",
"declared_policy_digest": "sha256:..."
}
}
Allowed carrier vocabularies are append-only:
| Field | Current values |
|---|---|
source_class | authoritative_boundary, reported_trace, inferred |
result_status | ok, error, n/a |
identity_state | present, absent, required_missing, invalid |
The carrier builder rejects unknown values rather than minting a label a consumer might trust.
Live producer¶
An opt-in producer can mint these carriers during a real MCP session, so the contract is exercised by an actual policy evaluation at the boundary instead of being authored after the fact. It is experimental and evidence-only.
assay mcp wrap --tool-decision-truth-out carriers.ndjson -- <server> enables it. Each evaluated tool call appends one carrier as a single line to that file. The sink is append-only and is kept separate from the decision log on purpose: a decision-log entry is operational output that a policy decision happened, while a carrier is a content-addressed evidence record with its own key semantics and claim ceiling. Folding them into one stream would blur those two records.
The producer is keyed. It requires two environment variables:
ASSAY_TDT_HMAC_KEY: the HMAC key forargs_digest;ASSAY_TDT_HMAC_KEY_ID: the key id recorded in the digest prefix.
It fails closed at startup, before the wrapped server is spawned, if the producer is enabled but any of three things is wrong: the key is missing or empty; the key id is missing, empty, or contains characters outside [A-Za-z0-9._-]; or the sink path cannot be opened for append. A half-configured producer that would mint nothing is a startup error, not a warning. The key is read once, held in memory only, removed from this process's environment before the wrapped child is spawned so the child cannot inherit it, and never logged or persisted.
At the boundary the producer records provenance honestly:
source_classisauthoritative_boundary;result_statusisok, which describes the policy-evaluation event itself, not a claim about the tool's side effects;identity_stateispresentorabsent, from the runtime identity at this seam;- only the keyed
args_digestis written, never raw arguments.
The proxy does not observe class membership, approval, scope, or redaction at this seam, so any declared constraint of that kind resolves to incomplete, never a guessed match. A policy that allows a tool but declares no argument schema for it therefore mints a carrier with an incomplete verdict, which is the honest result.
Producer non-claims:
- it takes no action on the verdict; it never forwards, blocks, or alters the policy-evaluated tool call request. A carrier may be minted for a policy-evaluated call even when the proxy then denies forwarding it; the carrier records the evaluated tool-decision input, not provider-side execution;
- it is not enforcement and not a consumer; acting on a verdict is a separate concern this producer does not address;
- it stays experimental and opt-in, and is off unless the flag and the key material are supplied.
Pack recipe-row primitive¶
The recipe-row helper binds a carrier to an Evidence Pack row without defining a new pack version. It uses two different digests:
| Digest | Meaning |
|---|---|
decision_identity_digest | digest of (observed_input_digest, declared_policy_digest); stable logical join key |
carrier_content_digest | digest of the full canonical carrier; citation target for the row |
The row cites the carrier by carrier_content_digest and carries decision_identity_digest as a join key. Verification recomputes both from the supplied carrier, checks the row envelope (type, schema, canonicalization, digest_subject), checks the run verdict vocabulary, and rejects a row that claims a less severe verdict than the carrier it cites.
Current scope: the primitive and verifier exist. A production pack-emission path that writes this row into real packs is a separate follow-up.
Conformance vectors¶
The committed vectors live at:
The test generator and reproducer live at:
The vectors cover:
- verdict cases for
match,incomplete,mismatch, andinvalid; - pattern deny matching;
- missing argument capture;
- absent, missing, and invalid identity states;
- approval evidence present, absent, and denied;
- malformed schema handling;
- duplicate-order and arity run-lattice failures;
- real keyed carriers with no raw arguments;
- positive pack rows over real carriers; and
- negative pack rows for tampered verdicts, foreign recipe/envelope values, and malformed digests.
Regenerate intentionally with:
The normal test path recomputes the fixture from committed bytes and fails if the fixture drifts from the current implementation.
Current public status¶
Green:
- contract primitives;
- runtime helper behavior;
- declared digest and verdict-gate coherence;
- carrier-content pack-row primitive;
- fail-closed verifier;
- conformance vectors with recompute-from-bytes guard;
- import, verify, and OTel projection over a verified evidence bundle; and
- live carrier producer, opt-in and evidence-only.
Open:
- OTLP export or a live telemetry path;
- production pack-emission path; and
- promotion from experimental names to a stable public schema.