Arming Context Integrity
Each Context Integrity check only runs once the application declares its precondition. This is the smallest declaration that arms each one — and what an unarmed check used to hide.
Context Integrity ships eight checks. None of them runs for free. invariant-violation at the compose seam needs a mounted map, dangling-reference and unsupported-argument both need a tool that says where its arguments come from (one declaration, two seams), unsupported-claim needs a claim contract, empty-lookup needs that same argumentsFrom field AND an operator's dial — and until 9.62.0, a check with no precondition declared filed no row at all. The one always-on row (wire) sat there green next to nothing, and a run that checked three things looked identical to a run that checked one.
That is not hypothetical. This library's own reference agent shipped for weeks with every optional check unarmed, and a second, independent integration reported reaching the exact same state without knowing about the first. Nobody had declared the preconditions, nothing said so, and the report stayed green throughout. A consumer running this in production put it plainly: a page listing the cheapest way to arm each check would move more people to actually arm them than a fourth check ever would. This is that page.
9.62.0 made an unarmed check visible — it now files an explicit not-applicable row instead of nothing. But visibility only helps once you know what to declare. Below is that declaration, one check at a time, at the minimum size it comes in.
The cheapest declaration, at a glance
Check (kind) | Seam | Cheapest declaration | What has to exist already |
|---|---|---|---|
invariant-violation | wire | none — armed by the provider, not by you | a provider that states LLMResponse.wireManifest (mock, anthropic, browser-anthropic all do) |
invariant-violation | compose | .maps() | a mounted skill map (.skillGraph(map)) |
dangling-reference | compose | argumentsFrom: ['groundingTool'] on the dependent tool | nothing else |
unsupported-argument | choice | the SAME argumentsFrom field — nothing more to declare | nothing else |
unsupported-claim | claim | .claims({ field: { entity, field } }) | .outputSchema() and a tool returning semantic({ facts: [...] }) |
empty-lookup | write | noticeEmptyLookups: true plus the same argumentsFrom field | nothing else — see Empty lookups |
prior-turn-evidence | claim | noticePriorTurnEvidence: true plus .namesAndNumbersFromEvidence() | nothing else — see Prior-turn evidence |
Three of these are one field, and one of those three fields buys THREE checks at three different seams — dangling-reference, unsupported-argument and (once you also set the dial) empty-lookup all read the same argumentsFrom declaration. The remaining one (invariant-violation at compose) is one call — .maps() with no arguments — but only once a skill map already exists; if nothing is mounted yet, .maps() refuses at build() rather than mounting a kernel over an empty set, because there would be nothing for it to park.
invariant-violation — two channels disagreeing about the same fact
The failure. A skill map gets parked — the mount kernel decided its prompt fragment and tools should stop riding the call, because nothing has corroborated it in a while. The park hold-out filters the map's tools out of the registry and the skill-injection list. But a .toolProvider() supplying a same-named tool merges its schema unfiltered — so the model is told, in the same request, that a subsystem is inactive and handed a tool that subsystem owns. That is not a hypothetical either: it is the exact defect the 9.62.0 integration test pins, caught on day one of writing the check.
The cheapest declaration. If you already mount a skill map, it is one call with no options:
import { Agent } from 'agentfootprint';
import { skillGraph, defineSkill } from 'agentfootprint/context';
const audit = defineSkill({ id: 'zone-audit', description: 'Zone redundancy audit.', body: '…' });
const map = skillGraph().entry(audit).build();
const agent = Agent.create({ provider, model })
.skillGraph(map)
.maps() // ← the one call. No options required.
.build();What you'll see. When a parked map's tools reappear through another source, one agentfootprint.integrity.context_error fires with kind: 'invariant-violation', seam: 'compose', and witnesses naming both channels — the park and the still-served tool — never picking a winner. The disposition row invariant-violation @ compose moves off not-applicable; on a pass where nothing parked it stays not-applicable (there was nothing to compare, which is health, not silence).
The wire half of this same check needs no declaration from you at all — it is armed by the provider stating a wireManifest (what the adapter reads back from the serialized request body, after every transform). mock, anthropic, and browser-anthropic all state one. A provider that states none leaves that seam unreachable — a different, honest fact from clean, and the one seam here you cannot arm from your own code.
dangling-reference — offered without its evidence
The failure. A tool result carrying valid ids gets evicted by a window/compaction strategy while the tool that fires at those ids stays on the wire. In a recorded run, the model assembled a plausible-looking id from a name it remembered, called the tool with it, and was refused — spending real actions on a call that had no chance of working, with nothing in the run saying the evidence behind it was gone.
The cheapest declaration. One field, on the tool that depends on another tool's results:
import { Agent, defineTool } from 'agentfootprint';
const whatsHere = defineTool({
name: 'whats_here',
description: 'List the ids visible on the current screen.',
inputSchema: { type: 'object', properties: {} },
execute: async () => ['screen2', 'screen7'],
});
const screenFire = defineTool({
name: 'screen_fire',
description: 'Fire the named screen id.',
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
argumentsFrom: ['whats_here'], // ← the one field. Names the tool it depends on.
execute: async ({ id }: { id: string }) => `fired ${id}`,
});
const agent = Agent.create({ provider, model }).tool(whatsHere).tool(screenFire).build();Declared, never inferred — the library has no way to know that screen_fire's arguments come from whats_here's results except that you say so. defineTool refuses the field if it is empty or names the tool itself, for the same reason a blank identity edge is worse than none: a check would join on it and compare the wrong subjects.
What you'll see. Nothing fires while whats_here's result is still in the window, and nothing fires if it gets re-fetched — those are the check's two fences, and both are silence by design (not-yet-grounded is legitimate sequencing; a re-fetch re-establishes the ground however many times it was dropped before). The finding only files in the third state: grounded, evicted, never re-established, still offered. Then one agentfootprint.integrity.context_error fires with kind: 'dangling-reference', seam: 'compose', and a message telling the model the honest fix — call whats_here again. The disposition row dangling-reference @ compose shows checked-fail for that pass, and checked-pass/not-applicable on every other pass depending on whether anything was actually served-and-grounded to compare.
unsupported-argument — acted on a value nothing served
The failure. The same declaration, one seam later, and the failure it catches is the one that survives every other rail. On turn two of a triage conversation, the window dropped the user message carrying the true machine id and kept the assistant's own rendered answer. Asked for "the status for that machine", the model resolved the reference out of its OWN prior prose — taking the fragment 4417-ganymede from the job name bkp-4417-ganymede-tier2 it had rendered a minute earlier — called the lookup tool with it, got an honest "nothing found", and reported that a protected machine had no backup record. The coverage envelope was complete, the absence envelope truthful, and the evidence gate passed the answer, because every value in it really was grounded. The defect was the referent, bound wrong at the argument.
The cheapest declaration. None beyond the one above. argumentsFrom arms this check too — the compose seam asks whether the ground is still in reach while the tool is offered, and the choice seam asks whether the value the model chose came from that ground when the tool was called. Declaring the field on screen_fire in the previous section already armed both.
What you'll see. After each response, every identifier-like string argument of an armed call must appear (case-insensitive substring) in the system prompt, a user message, or a tool result of the exact request that call was assembled from. Assistant messages are deliberately not ground. Silence for: non-string arguments, values under four characters, anything served anywhere in the frame, and anything the tool's own inputSchema declares in an enum. A finding files in exactly two states, with different messages because they need different fixes — the value's only ground is the model's own earlier answer (re-fetch the real ground; the message names the argumentsFrom tools), or the value appears nowhere in the frame at all. The finding carries predicate = the argument's dot-path, so two bad arguments of one call are two defects. Nothing is blocked; the call goes out exactly as the model made it.
Two limits, stated: a value the model composed from several grounds ("host1,host2") matches no single served string and will file — a tool whose arguments are legitimately model-composed should not declare argumentsFrom. And a value inside ANY tool result passes, even an unrelated one: this catches fabrication and self-reference, not misattribution.
Skill-scoped tools arm too (9.72.0). The declaration is harvested from the full declared catalog — .tool() registrations and every skill-carried tool (defineSkill({ tools }), autoActivate or not) — because a skill tool's declaration is known at chart build even though the tool reaches the model only after its skill activates. Before 9.72.0 the harvest read only the static registry, so an agent whose argumentsFrom tools all rode skills never armed either check and its disposition rows read checked: 0, notApplicable: 1 forever. One hole remains, stated rather than papered over: tools delivered by a ToolProvider are invisible to the harvest — list(ctx) is opaque and per-iteration, so their declarations cannot be known at build. (MCP tools are not in that hole: mcpClient(...).tools() registers them statically.)
externalGrounds — ground the app verified, not the run
Some ground the run never serves. The driving case: a person clicked a row in the app's data panel, the app verified the clicked cells against the artifact the panel renders, and the model was told to act on that selection. The identifier it passes to an armed tool came from a human's verified selection — not fabrication — but nothing in the window served it, so the check would file. externalGrounds on the agent options is how the app says so:
const agent = Agent.create({
provider, model,
externalGrounds: () => viewerSelection.cells.map((cell) => ({
value: cell.text, // verified by the app against the artifact
source: 'viewer-selection', // the audit label that travels
})),
})The option takes an ExternalGroundsProvider — a synchronous function yielding ExternalGround entries ({ value, source }), consulted once per LLM response that contains an armed call, so entries follow the person's selection between turns. A value found only in those entries files no finding; instead the excusal is put on the record as agentfootprint.integrity.external_ground_used (payload: the ExternalGrounding shape — toolName, toolCallId, the argument's dot-path, the value, and the source label of the entry that excused it). An excusal on the record always means the app's assertion was the only thing standing between that value and a finding — values the run itself served are never attributed to the door.
Three laws, because this is an assertion door. Declared, never ambient: this option is the only way in; there is no global registry. Absent or empty is byte-identical: no provider, or a provider yielding [], runs the exact check 9.71.0 ran. And the honesty note: the library records what the app asserts — verifying the assertion is the app's duty, done before the entry is yielded, and the source label travels precisely so a reader can audit that chain instead of trusting it. A provider that throws or returns garbage contributes nothing and never aborts a run.
unsupported-claim — the answer says more than the run settled
The failure. The evidence gate (.namesAndNumbersFromEvidence()) grounds every name and number in the final answer against a tool result — but it cannot catch a false claim assembled from real values. "fc1/3 is healthy" when the data says the port is down uses entirely grounded tokens: fc1/3 is in the evidence, and "healthy" is just a word. Token grounding passes it without a murmur. So does an answer reporting budget_start: 2 when the run's own ledger recorded 30.
The cheapest declaration. A tool that settles a typed fact, and one line naming which answer field claims which fact:
import { z } from 'zod';
import { Agent, defineTool, semantic } from 'agentfootprint';
const screenTool = defineTool({
name: 'whats_here',
description: 'Read the nav count on a screen.',
inputSchema: { type: 'object', properties: {} },
execute: async () => semantic({ facts: [{ entity: 'screen2', nav: 2 }] }),
});
const Answer = z.object({ nav_count: z.number() });
const agent = Agent.create({ provider, model })
.tool(screenTool)
.outputSchema(Answer)
.claims({ nav_count: { entity: 'screen2', field: 'nav' } }) // ← the one line.
.build();.claims() refuses at build() without .outputSchema(), and the refusal message is the constraint, stated plainly:
Agent.claims(): a claim contract needs a validated answer to read — add .outputSchema(schema). The claim seam compares TYPED fields; prose is never checked, so without a schema this contract would check nothing.
There is no typed stratum to compare a claim against until the answer is a parsed object, so a contract with no schema would silently check nothing — and a check that silently checks nothing is exactly the decay this whole family exists to prevent. That is also why .claims() needs a tool returning a semantic() envelope: the envelope is what settles the fact in the first place (ledger.ts flattens every recognized one into (entity, field, value) rows the checker can join against).
What you'll see. Nothing is blocked — the answer returns exactly as the model wrote it. If the claimed value disagrees with the settled fact, one agentfootprint.integrity.context_error fires with kind: 'unsupported-claim', seam: 'claim', naming both the claim and the fact it contradicts. The disposition row unsupported-claim @ claim shows checked-fail; agreement shows checked-pass; a field the answer omitted (schema allowed the absence) shows not-applicable; a fact the run never collected shows unreachable — the check refuses to accuse what it cannot compare.
Reading the disposition report
A findings stream alone cannot tell "no defects" apart from "the checker was unhooked." That is what the disposition ledger is for: one agentfootprint.integrity.disposition event per run, one row per registered (check, seam), filed whether the run passed, failed, or paused.
agent.on('agentfootprint.integrity.disposition', (e) => {
for (const row of e.payload.rows) console.log(row);
});Here is a real-shaped report — an agent that declared argumentsFrom on one tool and nothing else (no .maps(), no .claims()), after a short run where one call actually hit a dangling reference. Note that the one declaration produced two armed rows, at two seams:
{
"posture": "observe",
"workExisted": true,
"rows": [
{ "check": "invariant-violation", "seam": "wire", "checked": 3, "findings": 0, "notApplicable": 0, "unreachable": 0, "synthetic": 0 },
{ "check": "invariant-violation", "seam": "compose", "checked": 0, "findings": 0, "notApplicable": 1, "unreachable": 0, "synthetic": 0 },
{ "check": "dangling-reference", "seam": "compose", "checked": 1, "findings": 1, "notApplicable": 2, "unreachable": 0, "synthetic": 0, "lastFiredAt": 1755738000000 },
{ "check": "unsupported-argument", "seam": "choice", "checked": 2, "findings": 0, "notApplicable": 1, "unreachable": 0, "synthetic": 0 },
{ "check": "unsupported-claim", "seam": "claim", "checked": 0, "findings": 0, "notApplicable": 1, "unreachable": 0, "synthetic": 0 }
]
}Read the rows, not just the totals:
invariant-violation @ composeandunsupported-claim @ claimboth readchecked: 0, findings: 0— and on their own those two numbers would be indistinguishable from a check that ran three times and found nothing.notApplicable: 1is the fact that tells you the real story: neither.maps()nor.claims()was ever declared, so the check registered and immediately said "no subject this run" exactly once. This is the whole reason the field exists — before 9.62.0 these two rows did not appear at all, and the report above would have shown two checks instead of four with nothing marking the difference.dangling-reference @ composeis the armed, active case:checked: 1, findings: 1is the pass that actually caught something (lastFiredAtstamps when), andnotApplicable: 2covers the passes before anything had been dropped from the window — legitimate sequencing, not silence.unsupported-argument @ choiceis armed by the same one field, and readschecked: 2, findings: 0: two responses called the armed tool and every argument was served. ItsnotApplicable: 1is the response that called no armed tool at all — a turn this check had nothing to be about, stated rather than left blank.invariant-violation @ wireis always-on and ran cleanly every pass —checked: 3, findings: 0with nothing to explain, becausemockstates awireManifeston every call.
The one row shape to watch for and that this report does not contain: checked: 0, findings: 0, notApplicable: 0, unreachable: 0 all at once, on a run where workExisted: true. That is a registered check that filed nothing — not pass, not fail, not not-applicable, not unreachable — while the run did real work. It reads exactly like the healthy rows above at a glance (all zeros where you'd expect a small number), and it is the one shape that means the checker itself is unwired. find_context_errors's own report labels a row in that state ⚠ REGISTERED BUT NEVER RAN rather than folding it into a green summary.
integrityPosture: 'dev' — proving the checks are alive, not decorating
A row of zeros can mean three different things: never armed (notApplicable), armed and quiet (checked-pass), or armed and dead (the all-zero shape above). not-applicable and a clean checked-pass are both easy to read from the numbers. Telling "armed and quiet" apart from "armed and rotted" is harder — a checker that runs, is fed real encounters, and simply never fires files exactly the same rows as one that's healthy. Nothing in the counts alone proves the pure function underneath still catches a real defect.
That is what integrityPosture: 'dev' buys you. At the start of every run it mints one deliberately contradictory synthetic fixture per registered check — not just the armed ones, on purpose, because a check that has quietly rotted while nobody armed it must not get to inherit a clean bill of health the moment someone finally does arm it — and runs it straight through the check's real pure function. A check that cannot catch its own canary is dead. On the success path, before the run's result is handed back, assertAlive() checks two things and throws CheckerDeadError naming the first offender:
- a registered check that saw real work (
workExisted: true) but filed nothing at all — the wiring-rot shape above; - a registered check whose canary was minted but never caught.
The one-line pitch, taken straight from the error this throws: a green report from a check that never ran is decoration, not health — and two shipped checks in this codebase decayed exactly that way before anyone measured whether they ran.
const agent = Agent.create({ provider, model, integrityPosture: 'dev' })
.tool(myTool)
.build();Default posture is 'observe': rows only, no canary, no throw — the right choice for production traffic, where you want the accounting but not a synthetic call added to every run. Reach for 'dev' in CI, in a smoke-test run, or anywhere you want the library itself to fail loudly the moment a check stops being fed, rather than finding out from a production incident with a green report sitting right next to it.
What this doesn't cover — and says so
The finding type names nine defect classes: invariant-violation, unsupported-argument, dangling-reference, duplicate-execution, unsupported-claim, empty-lookup, column-type-mismatch, missing-column, prior-turn-evidence. Eight ship as checks. duplicate-execution (did settled work again) has no implementation in this build — no code anywhere is looking for it.
That absence is stated, not hidden. find_context_errors — the tool a debugging agent calls to read findings back from a recording — never offers that kind in its schema, and if something calls it with it anyway, it answers with an explicit refusal rather than a clean-sounding negative:
find_context_errors: ⚠ UNANSWERABLE — 'duplicate-execution' is a defect class the finding type names, and no check in this build can file it. Nothing ever looked for one, so silence about it is not evidence of its absence. Classes this build can file: invariant-violation, unsupported-argument, dangling-reference, unsupported-claim, empty-lookup — call again with one of those, or with no 'kind' at all.
The alternative — silently returning "no duplicate-execution findings" — would be a false negative wearing the same green as a check that actually looked and found nothing. A doc page (or a tool response) that implies coverage that doesn't exist is exactly the disease this whole family exists to cure, so it refuses instead. If your agent's real risk is settled work being repeated, arming the five shipped checks buys you nothing there yet — that gap is open, named, and unclaimed by any declaration in this doc.
See also
- Context Integrity — the mechanism: seams, the finding event shape, the full disposition vocabulary.
- Mounted maps — what
.maps()and engagement parking actually do. - Names and numbers from evidence — the token-grounding check
unsupported-claimexists to complete. - Empty lookups — the write-seam advisory in full, with the field failure it came from.
- Self-explain — wiring
find_context_errorsinto a debugging session over a recorded run.
