Evidence Chains
Problem
A policy exists in L4 canon: "Agents must cap retries at 3." A new engineer asks: "Why does this policy exist?" Without an audit trail, the answer is archaeology — searching through commit history, Slack threads, meeting notes. Nobody remembers. The policy becomes unchallengeable not because it is right, but because its origin is lost.
This problem compounds across the system:
- Policy rot. Policies outlive their justification. The original evidence may have been a transient spike, but the policy persists forever.
- Review theater. Governance reviewers approve proposals without understanding the underlying data because the evidence is not surfaced alongside the proposal.
- Broken trust. When agents or teams cannot trace a constraint back to data, they route around it instead of engaging with it.
Solution
SOMA maintains a complete evidence chain from every L4 canon entry back to the raw L1 traces that motivated it. The chain is structural — built into entity references, not reconstructed after the fact.
The traversal path:
L4 entry (ratified_by, ratified_at)
→ origin_l3_id → L3 proposal (confidence_score, evidence_links)
→ [L1 trace, L1 trace, L1 trace, ...]
Every L4 entry carries origin_l3_id pointing to the L3 proposal it was promoted from. Every L3 proposal carries evidence_links — an array of L1 entity IDs that the Synthesizer used as evidence. The Governance API's get_evidence() method traverses this chain and returns the full tree.
When entries decay (L3 → L1), evidence references are updated to point to the new L1 location. The chain never breaks.
Architecture
When an evidence entry decays, the reference is updated, not broken:
Code Examples
Retrieving an evidence chain
import { createGovernanceAPI } from './governance';
const governance = createGovernanceAPI(vault);
// Get full evidence chain for a canon entry
const chain = await governance.get_evidence('canon-retry-limit');
// Returns:
// {
// canon: {
// id: 'canon-retry-limit',
// layer: 'canon',
// status: 'enforcing',
// origin_l3_id: 'proposal-retry-limit',
// ratified_by: 'reviewer-jane',
// ratified_at: '2026-03-21T15:00:00.000Z'
// },
// proposal: {
// id: 'proposal-retry-limit',
// layer: 'emerging',
// status: 'promoted',
// confidence: 0.82,
// evidence_links: ['exec-invoice-agent-001', 'exec-email-router-042', 'decision-retry-invoice-019']
// },
// evidence: [
// {
// id: 'exec-invoice-agent-001',
// type: 'execution',
// layer: 'archive',
// agent_id: 'invoice-agent',
// status: 'completed',
// name: 'Invoice Agent: process batch'
// },
// {
// id: 'exec-email-router-042',
// type: 'execution',
// layer: 'archive',
// agent_id: 'email-router',
// status: 'failed',
// name: 'Email Router: route request (3 retries, timeout)'
// },
// {
// id: 'decision-retry-invoice-019',
// type: 'decision',
// layer: 'archive',
// decision_type: 'retry',
// agent_id: 'invoice-agent',
// choice: 'fetch-data',
// retry_count: 3
// }
// ]
// }
Traversing the chain programmatically
// Start from any L4 entry and walk the full chain
async function traverseEvidenceChain(vault, canonId: string) {
// Step 1: Get the L4 canon entry
const canon = await vault.get(canonId);
if (!canon || canon.layer !== 'canon') {
throw new Error(`${canonId} is not an L4 canon entry`);
}
// Step 2: Follow origin_l3_id to the L3 proposal
const proposal = await vault.get(canon.origin_l3_id);
if (!proposal) {
throw new Error(`Origin proposal ${canon.origin_l3_id} not found`);
}
// Step 3: Resolve all evidence_links to L1 traces
const evidence = await Promise.all(
(proposal.evidence_links || []).map(id => vault.get(id))
);
// Step 4: Filter out any null results (dangling references)
const resolved = evidence.filter(Boolean);
const dangling = (proposal.evidence_links || []).filter(
(id, i) => !evidence[i]
);
return {
canon,
proposal,
evidence: resolved,
dangling_references: dangling,
};
}
Checking for dangling references
import { checkDanglingReferences } from './decay';
// Audit all evidence links across the entire vault
const report = await checkDanglingReferences(vault);
// Returns:
// {
// total_checked: 142,
// dangling: [
// {
// entity_id: 'proposal-old-pattern',
// field: 'evidence_links',
// missing_reference: 'exec-deleted-somehow-001',
// layer: 'emerging'
// }
// ],
// healthy: 141
// }
CLI evidence inspection
# Show a canon entry with its full evidence chain
soma governance show --id canon-retry-limit
# Output:
# Canon Entry: canon-retry-limit
# Ratified by: reviewer-jane
# Ratified at: 2026-03-21T15:00:00.000Z
# Status: enforcing
#
# Origin Proposal: proposal-retry-limit
# Confidence: 0.82
# Status: promoted
#
# Evidence Chain (3 entries):
# [L1] exec-invoice-agent-001 execution invoice-agent completed
# [L1] exec-email-router-042 execution email-router failed
# [L1] decision-retry-invoice decision invoice-agent retry x3
Dashboard evidence drill-down
The governance page in the AgentFlow Dashboard shows evidence chains inline:
GET /api/soma/governance/evidence/canon-retry-limit
Response: {
"canon": { ... },
"proposal": { ... },
"evidence": [ ... ]
}
Reviewers expand a pending proposal to see all linked L1 traces before approving or rejecting. The evidence is not optional — it is structurally required for every L3 proposal.
Rules and Invariants
Chain structure
| Level | Entity | Required References |
|---|---|---|
| L4 Canon | canon-* | origin_l3_id (must point to an L3 entry) |
| L3 Proposal | proposal-* | evidence_links (array of L1 entity IDs) |
| L1 Trace | exec-*, decision-* | None (terminal nodes) |
Reference integrity on decay
When an L3 entry decays to L1:
- A new L1 entity is created with ID
decayed-{originalId}. - All L3 and L4 entries with
evidence_linkscontaining the old ID are updated to reference the new ID. - The
origin_l3_idfield in any L4 entry pointing to the decayed L3 is updated. - The
checkDanglingReferences()function verifies integrity post-decay.
The 7 vault invariants
These invariants are tested with 200-operation random sequences in the property-based test suite:
| # | Invariant | Enforcement |
|---|---|---|
| 1 | Every entity on disk has a matching index entry, and vice versa. | Index rebuild on corruption, spot-check on load |
| 2 | No entity exists in two layers simultaneously. | vault.update() rejects layer changes; promotion/decay create new entities |
| 3 | Every evidence_links entry resolves to an existing entity. | Decay processor updates references; checkDanglingReferences() audits |
| 4 | Every L4 entry has a valid origin_l3_id. | Schema validation on writeToLayer('canon', ...) |
| 5 | Every L2 entry has team_id and decay_at. | Schema validation on writeToLayer('working', ...) |
| 6 | L1 and L4 entries never have decay_at. | Schema validation rejects the field |
| 7 | Every layered entity has a known source_worker. | Schema validation on all writeToLayer() calls |
What checkDanglingReferences does
// Scans all entities in L3 and L4
// For each evidence_links entry:
// - Attempts vault.get(linkedId)
// - If null, reports as dangling
// For each L4 origin_l3_id:
// - Attempts vault.get(origin_l3_id)
// - If null, reports as dangling
// Returns { total_checked, dangling, healthy }
When chains can break (and how SOMA prevents it)
| Scenario | Prevention |
|---|---|
| L1 trace manually deleted | checkDanglingReferences() detects it; deletion is not a normal vault operation |
| L3 proposal decays while L4 references it | Decay processor updates origin_l3_id and evidence_links to new L1 location |
| Vault index corrupted | Index rebuilt from disk; spot-check validates 10% of entries on load |
| Concurrent writes corrupt an entity | File locking via O_EXCL with PID-based stale detection |
| Worker creates entity in wrong layer | writeToLayer() enforces permission matrix; wrong-layer writes throw LayerPermissionError |