Agent Trust & Assurance / Evidence ← The Life of a Uderia Agent

Architecture · Evidence · PROP-009

Evidence

Status: building on feature/evidence-per-result (Oct 2026). Proposal and decisions: PROP-009. This document is the live contract; the proposal is the record of why.

1. The question this answers

"For this answer, what produced it, and was each part signed and unchanged at that moment?"

Uderia already signs turns (the Ed25519 provenance chain), artefacts (the Trust Membrane), acts (OBDA-ACT receipts) and audit packs. Evidence is the index over those signatures plus the one record that was missing — which versions of an agent's parts a turn actually ran with. It never copies an artefact: artefacts stay where they live, so an attestation is always compared with the real, live artefact.

2. The records

Record Where Written by Notes
Current attestation trust_attestations trust.store.sign / record_attestation One row per artefact — per owner for agents (§3). The badge reads it.
Attestation history trust_attestation_history every store write Append-only: sign · record · reinstate · revoke · retire · migrated · assigned. A re-sign never erases what was signed before.
Signing keys trust_signing_keys sign (local) · reinstate (carried) · migration active · retired (an earlier local key) · carried (another signer's).
Bill of materials turn['bom'] + a trust.bom chain step the turn (§4) The signed parts the turn ran with.

3. Owner-scoped agent attestations

Bootstrap copies the same profile ids into every account, so the portable agent signature (agent) and the deployment attestation (agent_deployment) are keyed (kind, artifact_id, owner_uuid). Every other kind keeps owner_uuid = '': graphs, collections, flows, dashboards, canvases and packs have platform-unique ids; skills and extensions are platform-wide by design.

4. The bill of materials

4.1 What it records

{
  "version": 1, "profile_id": "profile-…", "profile_tag": "IDGGT", "owner_uuid": "…",
  "resolved": "turn_start", "resolved_at": "2026-10-08T15:40:49Z",
  "badge": "unsigned",
  "agent":      {"kind": "agent", "artifact_id": "profile-…", "label": "IDGGT",
                 "live_hash": "…", "attested_hash": "…", "history_id": 812,
                 "state": "trusted", "badge": "changed"},
  "deployment": {"kind": "agent_deployment", "artifact_id": "profile-…", "label": "deployment",
                 "live_hash": "…", "attested_hash": "…", "history_id": 815, "state": "trusted",
                 "required": true,
                 "summary": {"provider": "Google", "model": "gemini-…", "tools": 31,
                             "prompts": 12, "connectors": 0}},
  "parts": [{"kind": "ontology", "artifact_id": "kg-…", "label": "Fitness",
             "live_hash": "…", "attested_hash": "…", "history_id": 790, "state": "changed"}]
}

4.2 When

Resolved once at turn start, at the seam that already resolved the integrity stamp: executor.run() for the four standard engines and the genie coordinator's own turn assembly (genie is dispatched directly). composition.turn_evidence() is one resolution with two products — the stamp and the BOM — so they cannot disagree; turn_integrity_stamp is its first half. A part edited during the turn is recorded as it was at the start (resolved: turn_start). Cost measured on real profiles: +6 to +11 ms per turn.

4.3 How it is sealed and verified

4.4 The delivered answer verifies on every engine

verify_content checked the turn_complete step against final_summary, which only Optimize writes; the other four engines store final_summary_text. Every non-Optimize answer therefore read as altered (1,310 historical turns on the reference instance). The step now verifies against the engine's own field. A legacy genie turn that kept only final_response, cut to 500 characters, is re-derivable when the answer was short and otherwise reported as not checkable — never as tampering. After the fix, all 2,133 historical turns verify with no mismatch.

5. The evidence record and the usage index (PROP-009 §4.3)

core/trust/evidence_index.py — two tables beside the attestations, written once per turn at session_manager.update_last_turn_data for any turn carrying a BOM (never by scanning session files; a re-save of the same turn replaces its rows; every write fails soft):

Table Holds
evidence_turns One row per (owner, session, turn): profile, chain tip, key fingerprint, the BOM as JSON, answered at. Content-free — hashes, labels and attestation references, never answer text. Outlives the session (§9.3).
evidence_parts One row per signed part the turn used — agent, deployment, each ingredient, each data product it read (from its obda.consumption receipts, identified as <kg_id>::<name> through the agent's ontology), each action it performed (<kg_id>::<action>) — with the content hash it used and its state then.

usage(kind, id, owner, current_hash) answers "which answers used this, and how many used the version it has now" (since_current). A caller's view counts only their own turns; the system view (VIEW_ALL_SESSIONS) counts every account's.

6. The read model (PROP-009 §4.3, §5.1)

core/trust/evidence_view.turn_view(owner, session_id, turn, session_data):

REST: GET /v1/evidence/turn/<session_id>/<turn> (own turns; another user's is 404) and GET /v1/evidence/artifact/<kind>/<id>/usage (?scope=system behind VIEW_ALL_SESSIONS). Blueprint api/evidence_routes.py, registered only beside the Trust Membrane.

The answer view

static/js/handlers/evidenceView.js (window.EvidenceView, loaded only with the suite). The answer capsule (Why trust this answer?) renders an Evidence row only when the script is present and mounts it after load (fresh on re-open, since "now" moves):

7. Artefact usage and the estate (PROP-009 §5.2, §5.3)

8. Portable bundles and the offline verifiers (PROP-009 §4.4)

One export, every caller. POST /v1/evidence/export (core/trust/evidence_bundle.build) returns a self-contained file: for each attestation the record as it was signed (from the history row an answer's bill of materials names, so a later re-sign never changes what an answer's evidence says; or the current record for an artefact asked for directly), its signature, the public key it was signed with and its fingerprint, and — up to EVIDENCE_CONTENT_LIMIT_BYTES (256 KB, env TDA_EVIDENCE_CONTENT_LIMIT_BYTES; content:"all" lifts it, "none" omits it) — the exact bytes whose sha256 is the signed version hash. For each answer: the sealed chain, the bill of materials and (on request) the question, the answer and its trust stamps. Presets: an answer's Export evidence (the former receipt, now one verifiable file), Export shown on the Evidence tab, and Export for audit — every visible signature plus the deployment's signed audit pack, embedded unchanged.

The bytes come from one source. attestation.content_capture records what content_hash hashed while it is open; the bundle runs the badge's own resolver under a capture and keeps the recorded bytes whose digest equals the signed hash. Nothing re-implements a resolver, so what a bundle carries is by construction what the signature binds. When the artefact as it is now no longer produces those bytes (changed since signed, or gone), or they exceed the limit, the entry carries the hash only and names why. The capture never changes what is hashed (gate A1). It only sees bytes hashed through content_hash, so every signable kind must hash there: ontology extensions, action types and data products once used a private _canon_hash, and every export of them read "changed since it was signed" while the answer's own view read them intact. Gate H3 refuses any trust module that hashes canonical JSON outside content_hash.

Two verifiers, one rule set. scripts/verify_evidence.py (needs only cryptography) and static/js/evidenceVerify.js (WebCrypto Ed25519 + SHA-256) check: each signature over <kind>|<version_hash> with the carried key, the key's fingerprint, and sha256 of the carried content; each answer's chain (every step hash, link and signature; root, tip, count) and that the sealed trust.bom step is the digest of the bill of materials; that an exported question and answer hash to the chain's query_intake and turn_complete steps (the chain seals the first 4,096 characters, counted in code points by both verifiers; a longer text says only that prefix is checked; the export carries the answer field the chain sealed, and an answer from an older record that the chain does not cover is marked not checked, never verified); an embedded audit pack's canonical hash and signature. The browser re-encodes the bill of materials exactly as Python's json.dumps(sort_keys=True, separators=(",", ":")) does — ASCII escapes, code-point key order — and takes every number from the bundle text, so 1.0 stays 1.0; the bundle therefore travels as text in the app, and anything the app adds (the answer's evaluation) is appended to the text, never re-serialized. A gate runs both verifiers on the same bundles and requires the same verdict on each, tampered or not.

Who signed it. A bundle carries its keys, so alone it proves the record is internally consistent. The fingerprint, obtained separately from the deployment's operator, proves who signed it; both verifiers ask for it and say "signer not confirmed" without it.

The offline page static/trust/verify-evidence.html (built by scripts/build_evidence_verifier_page.py) inlines the verifier module verbatim — a gate asserts the copy is identical — so it runs from disk with no network.

9. The evidence ledger (PROP-009 §4.5)

core/trust/ledger.py keeps an append-only, hash-chained record (trust_ledger, in the auth db) of every attestation event and every answer's sealed record, so that editing, inserting or removing history after the fact is detectable:

entry_hash = sha256("ledger:v1:<seq>:<entry_type>:<ref>:<digest>:<prev_hash>")

verify walks the chain (gaps, links, recomputed hashes) and cross-checks it against the tables it records: an edited row or record, a removed one (pruning), and one written around the ledger (unledgered). A checkpoint is the tip signed with the deployment key; every export carries one, and POST /v1/evidence/ledger {checkpoints} proves the ledger still contains each earlier tip — so a truncated ledger, or one rewritten and rebuilt consistently (which internal checks alone cannot see), fails against a checkpoint taken before.

Exports carry the checkpoint, each attestation's ledger record, each answer's ledger ref, and the contiguous segment from the earliest included entry to the tip (up to 20,000 entries; beyond that each record's own entry, said so). Both verifiers check the checkpoint signature, the segment's links up to the signed tip, and that every record re-derives the digest it was entered with.

Shown as one chip on the Evidence tab ("ledger intact · 178 entries · 178 entered when it began", or "ledger NOT intact — 1 record edited after entry" in danger), with its limit in the tooltip. Which records moved is listed only with VIEW_ALL_SESSIONS — the ledger is deployment-wide.

Honest limit. The host administrator holds the signing key and could rebuild the whole chain and re-sign a new tip; what they cannot do is make the rebuilt ledger contain a checkpoint someone exported earlier. Key custody and external anchoring (§10) remove the remaining trust in the host.

10. Key custody and external anchoring (PROP-009 §4.6) — opt-in

Both are off by default, and with them off every signature the platform makes is byte-identical to before (Ed25519 is deterministic; a gate compares against the direct key call).

Who signs — one seam. core/signing.py sits behind the only three signing sites: a provenance-chain step (ProvenanceChain._sign), eval.online.sign_payload (attestations, audit packs, ledger checkpoints) and eval.online.sign_attestation. get_key_fingerprint and get_provenance_public_key_pem report the signer's key.

EVIDENCE_SIGNER Where the key is What the host can do
local (default) the tda_keys file on this host sign anything — including re-signing a rewritten history
command outside this host: an HSM, a cloud KMS (gcloud kms asymmetric-sign, EC_SIGN_ED25519), a Vault transit key, pkcs11-tool -m EDDSA request signatures; it cannot read the key

The command signer runs EVIDENCE_SIGNER_COMMAND with the message on stdin and reads a base64 signature from stdout; the public key is the Ed25519 PEM at EVIDENCE_SIGNER_PUBLIC_KEY. Every returned signature is verified against that key before it is used — a signer that answers wrongly, fails, or whose key cannot be read yields no signature (the existing degraded mode), never a bad one. Under it the local key file is never loaded. One process call per signature: for a busy deployment point it at a local signing agent rather than a cold CLI.

Switching custody (a restart with the new configuration) registers the new key and marks the old one retired, and an answer's chain is verified with the key the chain names (from trust_signing_keys), so older answers keep verifying after a rotation or a custody switch.

External anchoring. core/trust/anchoring.py publishes a signed ledger checkpoint every EVIDENCE_ANCHOR_INTERVAL_SECONDS (default 3600; skipped when the tip has not moved) — started at boot only when EVIDENCE_ANCHOR is set and the suite is present — or on demand (POST /v1/evidence/ledger/anchor, admin; 409 anchoring_off when unset). A tip is skipped only when the same sequence number and hash were already published: a ledger truncated and re-extended reuses a sequence number with a different hash, and that rewritten tip is published so it conflicts with what went out before. A checkpoint that cannot be signed is never published; the failure is recorded:

EVIDENCE_ANCHOR EVIDENCE_ANCHOR_TARGET Receipt
file a path on storage this host cannot rewrite (WORM mount, append-only volume) the appended line
webhook an append-only log / transparency service (EVIDENCE_ANCHOR_TOKEN = bearer) the HTTP status
rfc3161 a Time-Stamp Authority URL the authority's signed token (check with openssl ts -verify)

The RFC 3161 request timestamps sha256(the checkpoint payload) and is byte-identical to openssl ts -query -sha256 -cert -no_nonce (gate-asserted). Each anchor is recorded in trust_ledger_anchors, and the ledger reading re-proves that the ledger still contains every anchored tip; a failed publish is recorded and shown ("ledger intact · the last anchor failed: …", as warn). The record of anchors lives on this host too — the external copy is the proof.

Shown: the ledger endpoint reports custody {signer, key_on_host, fingerprint} and anchoring {enabled, sink, interval_seconds, anchors, last_seq, last_at, last_ok, last_error}; the Evidence tab's ledger tooltip states custody and anchoring in words; every bundle carries custody, and its stated limit follows it.

Verified against real services (Oct 2026)

Each opt-in path was run once against a real external service, not a stand-in. These runs used a throwaway database and never touched the deployment key:

Path Service Result
Command signer SoftHSM 2 over PKCS#11 (pkcs11-tool --sign -m EDDSA, an Ed25519 key held in the token) sign_payload, a sealed provenance chain, and an attestation all signed by the token key. verify_chain is valid. An exported bundle passes scripts/verify_evidence.py --fingerprint <token key>. No local key file was loaded.
Webhook anchor A local HTTP receiver, reached through real httpx The signed checkpoint arrived with the bearer token. An unmoved tip is not re-sent.
RFC 3161 anchor freetsa.org Status Granted. openssl ts -verify -data <checkpoint payload> -CAfile cacert.pem -untrusted tsa.crt returns OK on the stored receipt and FAILED (message imprint mismatch) on a payload altered by one byte.

The command signer takes a raw 64-byte signature as well as base64, so an HSM CLI can be used as it is.

11. Gates

Suite Covers
test/test_trust_attestation_history.py migration preserves every reading · attribution rule · owner isolation · history · signing keys · every scoped caller passes an owner
test/test_evidence_bom.py canonical form · turn_evidence · seal and verification · re-sign survival · contextvar isolation · engine wiring · the answer field on every engine
test/test_evidence_view.py the index (per part, re-save, owner isolation, usage) · when-answered vs now · status words · absence (not recorded, session gone) · chain re-verification · wiring · REST scoping
test/test_evidence_estate.py rows, flags and the worst-first summary · usage counts equal the index · visibility fails closed · filters never change the summary · station routes and the suite guard
test/test_evidence_bundle.py content capture never changes a hash · bundle content included / changed / over the limit / not requested · as-signed-when-answered across a re-sign · the Python verifier on clean and five tampers, fingerprints, the audit pack · the browser verifier reaches the same verdict on every case (Node WebCrypto) · the offline page inlines the module · route and UI wiring
test/test_evidence_ledger.py the chain and turn de-duplication · every attestation event entered in its transaction · backfill once, before live entries · detection of edited, removed and written-around records, edited and removed entries, truncation and consistent rewrite against a checkpoint · answer records · the bundle's ledger proof in both verifiers (incl. a cut segment and a broken checkpoint signature) · words and admin-only detail
test/test_evidence_custody.py the default is byte-identical at all three signing sites · the command signer signs, reports its key, never loads the local key, seals a chain that verifies · a wrong, failing or keyless signer yields no signature · anchoring off by default, file/webhook/RFC 3161 sinks, unmoved tip skipped, failures recorded, a rewritten tip at a reused sequence number re-published, an unsigned checkpoint never published, the request equals openssl's, anchored tips re-proved after truncation · an older answer verifies with the key it names after a custody switch · the ledger and bundle wording follow custody · surfaces
test/test_evidence_view_ui.mjs the shipped renderer: state words, the fixed-order pair, absence, the owed act, mount/cache/fresh, the capsule wiring and the suite guard