@octabits-io/evidence
When an agent prepares and a person decides, someone later asks for the proof: which actions were applied, who decided them, what the reviewer changed, what each value was read from, which model saw what, and whether the record was touched since. Writing that by hand is the work an audit costs. This package generates it from the two records the platform already keeps — the agent ledger and the disclosure log — as one bundle per period or per run:
- every applied action — the ledger entry and its hash, who acted and on
whose behalf,
reviewed/autopilot/manual, what it changed, whether the reviewer edited each value, the passages each value was read from (derivedFrom.citations), and whether it was reverted since; - the model calls behind each action, joined on the workflow id — where each went, the content class, sent / failed / denied, a digest of the payload and never the payload — and the calls no applied action accounts for;
- the edit rate per field over every accepted value an agent proposed, the number that says whether the preparation is worth trusting;
- stated confidence against review — when the producer gave a
derivedFrom.confidence, how often reviewers kept values stated at each level, so a model’s claim to be calibrated is checked on your own data; - disclosure totals per destination, class and outcome, with how many calls a detector raised;
- what the detectors did, where the gate had one: calls raised (by the class they were declared as), clear, skipped, failed, and the finding types summed — never what was found;
- the ledger chain verified — every link and hash recomputed — against a head you anchored outside the database;
- findings, most severe first: a broken chain, an unanchored one, an action applied without review, a run with no model call in the log, values with no citation, a field reviewers keep rewriting, a confidence that runs ahead of what reviewers kept, a call that declared no class, a detection that failed.
It is JSON under a published schema (schema/evidence-bundle.schema.json), the
record you file, and a Markdown cover sheet, its reading for a person and a
document-management system. The root is zod and three sibling roots; no
driver, no model runtime, no node:*.
npm i @octabits-io/evidence @octabits-io/agent-ledger @octabits-io/disclosure @octabits-io/proposal zod
From the stores
import { collectEvidence, formatEvidenceMarkdown } from '@octabits-io/evidence';
const bundle = await collectEvidence({
ledger, // your AgentLedgerStore, bound to the scope
disclosureLog, // your DisclosureLogStore — bound to the scope, or read with scopeKey below
anchor: lastAnchor, // a head you kept outside the database; omit and the bundle says "unanchored"
since: new Date('2026-07-01'), until: new Date('2026-10-01'),
scopeKey: orgId, generatedBy: userId,
});
if (!bundle.ok) throw new Error(bundle.error.message); // evidence_source_failed, with the store named
await files.put(`evidence-${quarter}.json`, JSON.stringify(bundle.value));
await files.put(`evidence-${quarter}.md`, formatEvidenceMarkdown(bundle.value));
workflowId instead of a period covers one run — the evidence for a single
decision. collectEvidence reads the whole ledger chain once (a chain
verifies only from its beginning) and pages the disclosure log for the period.
From what you already read
buildEvidence is the same, pure and synchronous, over data: the chain oldest
first, its verifyLedgerChain verdict, the anchor, and the log’s rows. Use it
when the rows come from somewhere else — an export, a replica, a test.
import { buildEvidence } from '@octabits-io/evidence';
import { verifyLedgerChain } from '@octabits-io/agent-ledger';
const bundle = buildEvidence({ entries, chain: await verifyLedgerChain(entries, { anchor }), anchor, disclosures, since, until });
A model gateway: the disclosure log alone
A gateway in front of a chat — LiteLLM behind the disclosure guardrail, say —
has no agent that applies anything, so it has no ledger. Leave ledger out
(or entries and chain, for buildEvidence): the bundle covers the
disclosure log alone and says ledgerCovered: false, finds nothing about a
chain it never read, and the cover sheet reads as Evidence of model calls —
the calls by destination, class and outcome, what the detectors found, and,
where the log names one, the calls per actor (actors).
const bundle = await collectEvidence({ disclosureLog, since, until, generatedBy: 'monitor' });
The actor is whatever the log was given: a key or a team by default in the guardrail, a person only where the host may record one.
What the host does for it
- Store the resolved operations in the ledger. An apply records
resolveDecision(proposal, decision)as itsoperations: that is where the fields, theeditedflags and the citations come from. Values and labels may be transformed before the ledger hashes them — sealed into references so an erasure can reach them — since the bundle reads neither (a label is used only while it is a string). Operations in another shape are reported asunreadable_operations, and the action appears without them. - Record what was turned down. A decision names what it accepts, so the
ledger alone never sees a rejection. Store
{ ...decision, rejected: rejectedOperations(proposal, decision) }as the entry’sdecision— the rejected operations by shape (field, label, stated confidence), never their values — and the bundle counts each one:rejectedandrejectRateper field, ahigh_rejection_ratefinding aboverejectRateThreshold(1/3) overminProposedForRejectRate(3) values, and a miss in the calibration. A decision that turns everything down is still an entry: no operations, only the rejected list.quality.withoutRejectionscounts the reviewed actions that do not say. - Give every model call its run. The disclosure log’s
correlationIdis the workflow id the ledger entry carries —disclosureLog.sink({ correlationId: String(workflowId) })on the guard. A run with no call in the log is a finding. - Name the agents whose model is not yours. An outside agent — an MCP
client proposing through the app’s tools — runs its model on its own side;
nothing it does passes the gate. Pass
outsideAgent: (principal) => principal.id.startsWith('mcp:')(or however the host names them) and its actions are anoutside_agent_action(info) instead ofno_model_calls_recorded. An agent it does not name is still expected to have its calls in the log. - Cite. A value an agent proposed without
derivedFrom.citationsis counted as uncited. - Anchor. Keep
ledger.head()somewhere the database’s owner cannot rewrite and pass it back. Without it the chain can still be verified, but a rewrite of the whole chain would not be seen, and the bundle says so.
Stated confidence against review
Some models report a probability with each answer, and some vendors sell it
as calibrated. A producer that has one puts it on the operation as
derivedFrom.confidence (0 to 1); bundle.calibration holds it against what
reviewers did:
bundle.calibration
// { scored: 120, unscored: 14, meanConfidence: 0.86, keptRate: 0.71, calibrationError: 0.15,
// bins: [{ from: 0.8, to: 1, values: 96, meanConfidence: 0.91, keptRate: 0.74 }, …] }
Values are grouped into five bins by stated confidence; each bin gives the
mean stated and the share kept unchanged, and calibrationError is the
weighted mean gap. Only reviewed actions count — an autopilot value has no
reviewer to disagree. A value is an update or a create: a delete or a
reorder proposes none, and is counted nowhere. A rejected value counts as not
kept where the entry records it (see above); where it does not, the miss never
reached the ledger, so for those actions keptRate is an upper bound — a gap
found here is real, and a model that looks calibrated may still be worse. A
value that was accepted and reverted later still counts as kept, which is one
more reason the rate is a ceiling. A bin whose stated confidence runs
more than calibrationTolerance (0.15) ahead of its kept rate, over at least
minValuesPerBin (10) values, is an overconfident_values finding. Until that
number is small on your data, do not order, route or skip review on a
confidence.
Decisions feed the same calibration. A host that applies a decision step’s
outcome (octaflow’s defineDecisionStep) writes one update per question
with the decider’s confidence in derivedFrom.confidence and edited: true
where a person chose otherwise. Under a threshold policy the reviewed
decisions are the ones the decider was unsure of, so the low bins fill first;
the questions above the threshold in a decision a person settled still count,
because the person saw them too.
Applied without review
What took effect with no person checking it first — a decision above its
threshold, a grant that allowed it — is counted per agent in
bundle.autopilot, with how much of it a person reverted since:
bundle.autopilot
// { actions: 214, reverted: 9, revertRate: 0.042,
// agents: [{ agent: 'ai:message-triage', actions: 214, reverted: 9, revertRate: 0.042 }] }
A revert is the only sign that an autopilot action was wrong, so the rate is
a lower bound. An agent whose revert rate passes autopilotRevertThreshold
(0.1) over at least minAutopilotActions (5) is an autopilot_reverted
finding: raise its threshold, or send more of its work to review. The field is
optional in the schema, so a bundle written before it existed still reads.
Findings
| Code | Severity | Means |
|---|---|---|
chain_broken |
high | a ledger entry was changed, removed or inserted after it was written, or the chain does not reach the anchor |
chain_unanchored |
medium | verified without an anchor |
unreadable_operations |
medium | an entry’s operations are not proposal operations |
autopilot_action |
medium | applied by an agent without review, under a grant that allowed it |
autopilot_reverted |
medium | more than a tenth of what one agent applied without review was reverted since (at least five actions) — autopilotRevertThreshold, minAutopilotActions |
no_model_calls_recorded |
medium | an agent’s run behind an action left no call in the log for the period — not raised for an agent outsideAgent names |
high_edit_rate |
medium | reviewers rewrote more than a third of a field’s accepted values (at least three) — editRateThreshold, minAcceptedForRate |
overconfident_values |
medium | values stated at one confidence level were kept unchanged markedly less often than stated — calibrationTolerance, minValuesPerBin |
class_not_declared |
medium | a model call declared no content class and was treated as confidential |
detection_failed |
medium | the detector failed on a call, which was treated as confidential: check it is reachable and answers |
outside_agent_action |
info | proposed by an agent whose model runs outside the app (outsideAgent, an MCP client say): its model calls are not in the log, and none is expected |
uncited_agent_values |
info | values an agent proposed without a citation |
disclosure_failed |
info | a call failed after the gate let it through; the payload may have left |
disclosure_denied |
info | calls the gate denied; nothing left |
detection_raised |
info | calls whose content a detector found above the declared class; the gate decided on the higher one |
Exports
| Path | What |
|---|---|
. |
collectEvidence, buildEvidence, formatEvidenceMarkdown, evidenceField, evidenceBundleSchema and the part schemas, EVIDENCE_FORMAT, EVIDENCE_VERSION, EVIDENCE_FINDING_CODES, EVIDENCE_SEVERITIES, and their types |
./schema/evidence-bundle.schema.json |
the bundle as JSON Schema (draft 2020-12) |
What it does not do
- Decide. It reports; it never blocks, reverts or approves anything.
- Prove the disclosure log. The ledger is hash-chained and anchored; the disclosure log is not. The bundle reports what the log holds.
- Map to a regulation. No article numbers, no Annex IV template: the bundle is the facts a DPO, an ISO or an auditor maps onto their own framework.
- Keep payloads. Neither source stores prompts or outputs, so neither does the bundle.
Stability
0.x: minors may break and say so; patches never do. The bundle carries
format and version; a change to its shape bumps version.
From @octabits-io/evidence@0.7.0 in the platform repository.