LangGraph enforcement evidence and classification¶
What this page documents¶
How the LangGraph gateway classifies a run or a deployment into a governance level, what evidence that classification is computed from, and the two knobs — the stale threshold and the adapter version — that gate it, plus the HTTP surface that submits and reads that evidence.
The routes are registered by
src/zeroth/service/api/enforcement_api.py under both /v1/enforcement
and the compatibility prefix:
| Route | Purpose |
|---|---|
POST /enforcement/decisions |
Decide one tool call; one decision is stored per idempotency key. |
POST /enforcement/registrations |
Declare a deployment's governed tool inventory. |
POST /enforcement/attestations |
Record a run's signed start-of-run claims. |
POST /enforcement/heartbeats |
Report deployment liveness. |
GET /enforcement/deployments/{deployment_ref}/status |
Last-known deployment level. |
GET /enforcement/deployments/{deployment_ref}/runs/{correlation_id} |
The level provable for one run. |
All six require the ENFORCEMENT_REPORT permission, and every one
resolves tenant_id and principal_id from the authenticated
principal — never from the request body.
When to use¶
- You are building or auditing the evidence pipeline behind
CapabilityReporter,PersistedCapabilityEvidenceProvider, or the run-attestation contracts, and need to know which combination of facts actually producesenforced. - You are debugging why a run reports
observedwhen you expectedenforced— the checklist in Whatenforcedrequires is the fastest way to find the failing condition. - You are writing or reviewing policy/ops language that references a "stale" run or deployment and need the exact threshold.
When NOT to use¶
- You want to govern tool calls — that is Govern LangGraph tool calls, a different recipe. This page documents how a run's or a deployment's governance level is reported, not how an individual tool call is decided.
- You want to submit evidence from an adapter rather than understand
how it is judged. The three write routes above —
POST /enforcement/registrations,POST /enforcement/attestationsandPOST /enforcement/heartbeats— are the surface, and an adapter calls them directly: authenticate withX-API-Keyand POST the bodies defined insrc/zeroth/governance/enforcement_wire.py(InventorySubmission,AttestationSubmission,HeartbeatSubmission). There is no SDK evidence client today.zeroth.integrations.langgraph.HttpToolDecisionClientis not one: it posts to/v1/enforcement/decisionsand nothing else, so it can neither register an inventory, nor attest a run start, nor heartbeat.
The three levels, and why there is no "partial" level¶
GovernanceLevel (src/zeroth/contracts/langgraph_gateway/models.py:13) has
exactly three members:
admission— the run or deployment passed through the gateway, but nothing further is claimed. This is the default and the fail-closed floor: any missing, invalid, mismatched, or stale evidence collapses toadmission.observed— evidence exists and is fresh and valid, but it does not establish full tool-inventory coverage tied to a matching attestation. The gateway is watching the run; it is not claiming to have governed every tool call the run could make.enforced— every condition in Whatenforcedrequires held at evaluation time.
There is no fourth, "partial" level, and this is deliberate.
InventoryCoverage (src/zeroth/integrations/langgraph/_tool_types.py:69)
is a separate two-valued enum — partial / complete — that describes
how much of a tool inventory was seen, not how governed a run is.
A partial inventory and an admission-level run are different axes
that happen to correlate in the common case (an incomplete inventory
usually accompanies weak evidence), but conflating them into one scale
would let a caller misread "we saw half the tools" as a governance
level partway between observed and enforced. It is not: partial
coverage caps you at observed at best (see
Partial inventories), it never
produces a distinct level of its own.
What enforced requires¶
enforced is a conjunction, not a single flag. Reading top to bottom
through PersistedCapabilityEvidenceProvider and
CapabilityReporter._validated_level
(src/zeroth/governance/attestations/provider.py,
src/zeroth/governance/langgraph_gateway/capabilities.py:75-140), every one of
these must hold:
- A signed run-start attestation that verifies.
verify_attestation(src/zeroth/governance/attestations/signing.py:68) recomputes the SHA-256 digest from the payload and checks the keyed signature against it. An unsigned, tampered, or unverifiable attestation forcesadmissionimmediately (provider.py:242-243,capabilities.py:60-61). - A registered inventory whose coverage is exactly
complete._coverage_is_completecompares the stored registration's coverage against the literal string"complete"— no case folding, no other token accepted. - A tool-fingerprint match between the registration and the
attestation.
_manifest_completerequiresregistration.inventory_fingerprint == payload.inventory_fingerprint. Acompleteregistration for the wrong inventory proves nothing about this run.
What the registration trust boundary actually is¶
Be precise about which half of that comparison the server owns, because an earlier revision of this page overstated it.
- Server-computed.
inventory_fingerprintandtool_counton a registration are recomputed from the submitted tool identities byrecompute_inventory_fingerprint(src/zeroth/governance/attestations/inventory.py) and cannot be declared.inventory_coverageandtool_countinside a signed attestation are taken from the stored registration, not from the submitted body. - Client-declared, and deliberately so. The attestation's
inventory_fingerprintis submitted by the run. It is a binding, not an authority claim: the whole check is that it equals the digest the server recomputed. Deriving it from the registration too would compare a value with itself and make fingerprint drift undetectable. - Client-declared, and irreducible here.
coverageon a registration is the adapter's claim that it enumerated every governed tool. The server has no independent view of the graph, so it cannot falsify that claim; what it can and does refuse is acompleteclaim whose declared identities do not produce the attested digest. - Matching graph and adapter versions.
classify_version_agreementmust returnVersionAgreement.MATCH— see Mixed versions. - Freshness, checked twice, on two different clocks:
- The provider checks the attestation's own signed
expires_atagainst its clock (provider.py:250) — an expired attestation still has a valid signature, but its consequence (a possibleenforcedclaim) is refused. CapabilityReporter._validated_levelseparately checksobserved_at(the attestation'sissued_at) against the stale threshold — see The stale threshold.
If all five hold, the provider computes governance_level=ENFORCED and
tool_manifest_complete=True on the evidence it returns. Even then,
CapabilityReporter._validated_level (capabilities.py:84-91) makes
its own decision from that evidence: it only returns ENFORCED when
evidence.governance_level is GovernanceLevel.ENFORCED and
evidence.tool_manifest_complete are both true; otherwise it returns
OBSERVED. Nothing here is copied through — RunAttestationPayload
.claimed_level (src/zeroth/governance/attestations/payload.py:66-72)
is the run's own advisory claim, and it can only ever lower the
level the server independently computed, never raise it
(_apply_ceiling, provider.py:103-105). The server recomputes this
from the inventory it stored for the deployment, never from anything
the client asserts about its own coverage.
The stale threshold: 90 seconds¶
CapabilityReporter is constructed with stale_after_seconds, which
defaults to 90.0 seconds
(src/zeroth/governance/langgraph_gateway/capabilities.py:44). It is
configurable through LangGraphGatewaySettings.stale_threshold_seconds,
which also defaults to 90
(src/zeroth/platform/config/settings.py:322).
_validated_level computes age_seconds as the gap between the
reporter's clock and the evidence's observed_at
(capabilities.py:74-82):
- If
age_secondsis negative (clock skew), infinite/NaN, or greater thanstale_after_seconds, the evidence is treated as unusable and the result isadmission— regardless of how strong the evidence would otherwise be. Evidence older than 90 seconds (by default) downgrades the reported level toadmission, full stop — this is the "documented threshold" a stale heartbeat is measured against. - At exactly
stale_after_secondsthe evidence is still fresh (the comparison isage_seconds > stale_after_seconds, not>=), so a reading taken at precisely the 90-second mark still counts. - Otherwise the age check passes and evaluation continues to the level logic described above.
This window is what "stale" means throughout the gateway: evidence
older than the threshold is discarded outright, not downgraded
gracefully to some intermediate state. The same check backs both call
sites — level_for_run and level_for_deployment. A run's own
observed_at is its attestation's issued_at, set once at run start;
by construction this means a long-running run's evidence keeps aging
toward the same 90-second boundary unless a fresh attestation is
issued, in parallel with the attestation's own expires_at TTL
enforced separately in PersistedCapabilityEvidenceProvider._resolve_level
(provider.py:250) — the two are independent gates on different
clocks, and neither is a substitute for the other. (This consequence
for long-running runs follows from reading _validated_level and
evidence_for_run together; it is not something this page saw pinned
by a dedicated test, so treat it as a derived reading of the code
rather than a directly-tested guarantee.)
Heartbeats can report deployment status, never upgrade a run¶
A heartbeat is evidence about a deployment, not about any one run.
zeroth.governance.attestations.heartbeat implements this concretely:
Heartbeat(heartbeat.py:92-111) is one liveness ping — a plain, unsigned record:tenant_id,deployment_ref, an optionalgraph_version/adapter_version, anobserved_attimestamp, and a self-reportedreported_levelstring.HeartbeatRepository(heartbeat.py:137-200) appends these to theenforcement_heartbeatstable and reads back only the newest row per(tenant_id, deployment_ref)— last-known-wins.DeploymentStatusResolver.last_known_evidence()(heartbeat.py:240-276) turns the newest heartbeat into aRunCapabilityEvidenceand hands it toCapabilityReporter.level_for_deployment(capabilities.py:114-121), which applies the same_validated_levelstaleness window described above — the staleness rule is not reimplemented here, it lives in exactly one place.DEFAULT_STALE_AFTER_SECONDS(heartbeat.py:60-68) is read fromLangGraphGatewaySettings.stale_threshold_secondsrather than duplicated as a literal, so the two can never drift apart on what "stale" means.- The evidence this resolver builds always carries
tool_manifest_complete=False(heartbeat.py:243-250) — a heartbeat proves no tool inventory, so no heartbeat, however fresh and however it self-reports, can ever satisfy theENFORCEDpredicate in_validated_level.signature_validis alwaysTrueon this evidence, but that is not a cryptographic claim: a heartbeat's trust boundary is the tenant-scoped read itself, not a signature over its payload, and it istool_manifest_completethat stops an over-claiming heartbeat from reachingENFORCED, not this flag.
level_for_run (capabilities.py:95-112) takes an optional
deployment_evidence parameter, and its first line is del
deployment_evidence (capabilities.py:103). The heartbeat evidence is
accepted for interface symmetry and then discarded by construction — a
run's level is computed solely from evidence_for_run(correlation_id),
its own attestation lookup. A deployment that just heartbeated
enforced cannot lend that status to a run that has no attestation of
its own, or whose attestation fails any of the five conditions above.
Heartbeats answer "is the deployment currently healthy and governed",
not "is this particular run enforced".
Mixed versions and the adapter version¶
ADAPTER_VERSION (src/zeroth/governance/attestations/versions.py:41)
identifies the governed-LangGraph adapter's own attestation/registration
wire contract — a different clock from the repository's release
version in pyproject.toml. It only moves when the shape or meaning
of what the adapter attests or registers changes, not on every release.
classify_version_agreement
(src/zeroth/governance/attestations/versions.py:63-109) compares two
independent pairs — expected vs. actual graph version, and expected vs.
actual adapter version — and returns a VersionAgreement member:
| Member | Meaning |
|---|---|
MATCH |
Both the graph version and the adapter version agree exactly. |
ADAPTER_MISMATCH |
Graph versions agree; adapter versions do not. |
GRAPH_MISMATCH |
Adapter versions agree; graph versions do not. |
BOTH_MISMATCH |
Neither pair agrees. |
UNKNOWN |
Any one of the four inputs was None or "". |
Comparison is exact string equality on both axes — there is no
semver range or compatibility logic. This is on purpose: a
governance claim ("this run is fully enforced") must not depend on
someone's interpretation of what counts as a compatible version range.
If a wire-contract change is genuinely compatible, that is expressed by
not bumping ADAPTER_VERSION — not by teaching the comparison to
reason about ranges.
UNKNOWN is checked before either pair is compared: if the
expected or actual graph version, or the expected or actual adapter
version, is missing or empty, the result is UNKNOWN regardless of
what the other pair would have shown. A missing version is not
evidence of agreement — it is the absence of the evidence agreement
would require, so it fails closed to UNKNOWN rather than falling
through to a mismatch or a match.
permits_full_enforcement (versions.py:112-130) is the single gate a
caller checks: every value other than MATCH — including
UNKNOWN — forbids a full-enforcement claim. It is written as an
explicit equality check against MATCH, not as an allowlist or
denylist of the other members, so a future VersionAgreement member
added without updating this function still forbids enforcement by
default instead of silently permitting it.
Partial inventories cap at observed¶
An inventory with InventoryCoverage.PARTIAL can still identify and
back specific controlled tools — the gateway is not blind to a
partially-inventoried deployment — but it can never, by itself, satisfy
condition 2 in
What enforced requires, which requires
coverage to be exactly complete. A run backed by a partial inventory
therefore reports at most observed, no matter how strong the rest of
its evidence is: a valid signature, a fingerprint match on the partial
set, and a MATCH version agreement all still land on OBSERVED
because _manifest_complete returns False for anything short of
complete coverage (provider.py:77-79, 204-225).
This mirrors the same distinction drawn in
The three levels:
partial is a fact about the inventory, and its consequence is a
governance-level ceiling, not a governance level of its own.
Attesting twice: what a 409 means and what a retry gets¶
A run's evidence is fixed by the first attestation the server accepts:
run_attestations is unique on (tenant_id, correlation_id), so a
second attestation under one correlation cannot replace it. An adapter
therefore has to be able to tell three cases apart.
| What the adapter sent | Answer | authoritative |
|---|---|---|
| The same claims again (a retry after a lost response) | 201 with the original digest and expiry |
true |
| Different claims under the same correlation | 409 with the digest and expiry of the attestation in force |
false |
| A correlation another deployment already attested | 409, fixed message, no digest |
— |
A retry is safe and costs nothing. The server stamps issued_at and
expires_at on arrival, so a retried request is normally not
byte-identical to the one that was stored, and neither its digest nor its
signature can identify it. (The exception is two requests landing in the
same microsecond, which do produce identical bytes; that duplicate is
recognised too, and counted separately.) is_identical_resubmission
(src/zeroth/governance/attestations/signing.py) instead compares every
claim except that issuance window and re-signs the stored payload to
confirm it is intact and still what the current key produces. A matching
retry gets the first response back verbatim and writes no second row; an
adapter can safely POST again after a timeout without deciding whether
the first attempt landed.
Three things break the match, all deliberately. Changed claims —
including inventory_coverage and tool_count, which the server takes
from the deployment's current registration, so a retry crossing a
re-registration that changed either one is a different attestation. A
rotated or retired signing key. And a change in whether the deployment
signs at all. Each answers 409: the attestation in force is not the one
this request would have produced, and saying otherwise would tell the
adapter its claims govern a run they do not.
A re-registration on its own is not enough to break it. Swapping one
tool for another leaves the coverage and the count unchanged, and the
inventory_fingerprint in the comparison is the adapter's own submitted
value — identical across both requests by definition. That retry still
matches, and still gets the original acceptance. The fingerprint is
checked against the server's recomputed digest when the evidence is
read (see What enforced requires), not
when a duplicate attestation is judged.
The third row of the table is narrower on purpose. Correlations are unique per tenant, not per deployment, so a submission can lose to a sibling deployment's run; disclosing that winner's digest would make the 409 body a read of another deployment's evidence, so that case answers a fixed message instead.
On the metrics side the three outcomes are distinct label values on
zeroth_enforcement_attestations_total — recorded, already_recorded,
and earlier_attestation_in_force (plus unavailable). A retry is
deliberately indistinguishable on the wire, so the counter is where an
operator sees retry volume, and recorded stays an honest count of
attestations actually stored.
See also¶
- Govern LangGraph tool calls — the tool-call enforcement surface this evidence model is distinct from; see its "What this does not claim" section for how the two relate.
- Concept: guardrails
- Concept: audit