Skip to main content

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

LevelEntityRequired References
L4 Canoncanon-*origin_l3_id (must point to an L3 entry)
L3 Proposalproposal-*evidence_links (array of L1 entity IDs)
L1 Traceexec-*, decision-*None (terminal nodes)

Reference integrity on decay

When an L3 entry decays to L1:

  1. A new L1 entity is created with ID decayed-{originalId}.
  2. All L3 and L4 entries with evidence_links containing the old ID are updated to reference the new ID.
  3. The origin_l3_id field in any L4 entry pointing to the decayed L3 is updated.
  4. 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:

#InvariantEnforcement
1Every entity on disk has a matching index entry, and vice versa.Index rebuild on corruption, spot-check on load
2No entity exists in two layers simultaneously.vault.update() rejects layer changes; promotion/decay create new entities
3Every evidence_links entry resolves to an existing entity.Decay processor updates references; checkDanglingReferences() audits
4Every L4 entry has a valid origin_l3_id.Schema validation on writeToLayer('canon', ...)
5Every L2 entry has team_id and decay_at.Schema validation on writeToLayer('working', ...)
6L1 and L4 entries never have decay_at.Schema validation rejects the field
7Every 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)

ScenarioPrevention
L1 trace manually deletedcheckDanglingReferences() detects it; deletion is not a normal vault operation
L3 proposal decays while L4 references itDecay processor updates origin_l3_id and evidence_links to new L1 location
Vault index corruptedIndex rebuilt from disk; spot-check validates 10% of entries on load
Concurrent writes corrupt an entityFile locking via O_EXCL with PID-based stale detection
Worker creates entity in wrong layerwriteToLayer() enforces permission matrix; wrong-layer writes throw LayerPermissionError