This directory contains the dual-layer formal verification suite for CAGE's governance architecture, providing exhaustive state-space proofs and TLA+ model checking for critical safety invariants.
CAGE employs a two-layer verification strategy:
- Layer 1: Python BFS State-Space Enumeration (
model.py) — Exhaustive exploration of the governance pipeline state machine using breadth-first search - Layer 2: TLA+ Model Checking (
.tlaspecs +.cfgconfigs) — Temporal logic verification of distributed consensus, CBF barriers, FTRA boundaries, and LangGraph harness integrity
This dual approach provides:
- Completeness: BFS enumerates every reachable state (no under-approximation)
- Expressiveness: TLA+ temporal logic captures liveness and concurrency properties
- Independence: Two distinct proof techniques reduce systematic error risk
The No-Direct-Bind invariant:
NoDirectBind ≜ (phase = "EXECUTED") ⇒ (resolvedAllow = TRUE)
Informal: Every action execution must be preceded by a valid routing seal issued only after all governance tiers pass.
Models the 7-tier deterministic CAGE governance pipeline (TIERS in proof/model.py; ARCH-1 added FTRA, refactor/gateway-surface-cleanup removed the dead fria tier, and refactor/consensus-model-core aligned the universal tuple to the 7 deterministic tiers):
| Tier | Name | Role |
|---|---|---|
| 0.5 | FTRA | Action classification & reachability analysis |
| 1 | STPA | STAMP/STPA unsafe control action check |
| 3b | OPA | OPA Rego policy evaluation |
| 2 | Confidence | Agent confidence threshold |
| 5 | Consensus | Notional threshold escalation / consensus gate |
| 3a | CBF | Control Barrier Function (Redis cash barrier) — phase 2 |
| 4 | Fiscal | Fiscal limit pre-reservation — phase 2 |
The rows are in run_pipeline() execution order: the read-only kernel stages (KERNEL_TIERS), the phase-1 domain tiers, then the phase-2 (mutating) tiers. Tier numbers follow the paper.
Plugin tiers (finance's bounding and optional causal, healthcare's dose_barrier) add no proof states; PLUGIN_TIER_PHASE keeps the POST_HITL predicate from skipping a plugin-named phase-2 tier, and PluginContribution.required_tiers prevents any required tier from being silently disabled. No universal pipeline stage named fria exists (fria is an EU_ECB jurisdiction tier), so the universal model has none.
Note: Tier 3 is split into opa and cbf because each can block the action on its own. The model evaluates tiers in the pipeline's fixed order; it does not enumerate CBF/OPA interleavings (OPA is a phase-1 read-only stage and CBF a phase-2 mutating stage, so they never run concurrently).
| Model Variant | Reachable States | Invariant Holds? |
|---|---|---|
| Gated (correct CAGE) | 34 | ✅ TRUE |
| Ungated (direct-bind) | 17 | ❌ FALSE (counterexample) |
| Skipped tier (cbf) | 31 | ✅ TRUE (structural) |
The former "Concurrent CBF∥OPA: 49" row is gone: no function in proof/model.py enumerates a concurrent state space, and no test pinned that number.
The downscoping plan predicted other numbers. The plan of 2026-10-28, which proposed removing causal from the model, predicted 34 / 19 / 32 reachable states, labelled FULL / POST_HITL / DRY_RUN. The measured counts are gated 34, ungated 17 and skipped-tier (cbf) 31, the variants in the table above, plus 38 under EU_ECB. The plan was wrong about two numbers and all three labels. The code did not drift from the design: these are measured values, and the tests pin them.
The model proves three concrete CAGE gaps:
- Gap 1: Ungated architecture violates NoDirectBind (seal gate is load-bearing)
- Gap 2:
govern()without seal issuance violates NoDirectBind - Gap 3: Skipped required tier preserves structure but removes a mandatory check (prevented at startup by
required_tiersinassert_production_posture())
verdict_of() mirrors ClassificationEngine.classify: HARD → DENY; RELIANCE_INELIGIBLE → DEFER (DENY when DEFER is disabled); OPA MANUAL_REVIEW or HITL → REQUIRE_APPROVAL; every finding NARROWABLE with a narrower proposal → NARROW; DEFERRABLE with confidence below the floor → DEFER; otherwise DENY; no findings → ALLOW. verdict_lattice_holds() checks that only ALLOW and NARROW reach SEAL_ISSUED. I-6 is narrow_valid: phase = NARROW ⇒ seal_present ∧ resolved_allow ∧ clamped_params_valid (it replaces EXECUTED_unmodified, which no model defines). Parity with the real engine is pinned in tests/test_distributed_cbf_proof.py.
- NARROW: Soft threshold exceeded → seal issued on clamped parameters (ALLOW variant,
resolvedAllow=TRUE) - There is no PAUSE state. The verdict lattice is
ALLOW | NARROW | REQUIRE_APPROVAL | DEFER | DENY; a transient infrastructure fault is a DENIED terminal with a refusal receipt.main()asserts every reachable phase is inPHASES.
The gateway publishes one GOVERNANCE_TRACE event per governor decision and one per actuation (governor/trace.py); PipelineResult carries the run's plan and per-stage stage_outcomes. trace_conformance.py projects each decision onto a State and checks it against reachable_over(plan, profile), the gated model instantiated over that run's own tiers. It also joins each EXECUTED event to its seal's issuance (no_direct_bind) and allows one execution per seal (single_use). The projection keeps the first FAIL and treats later tiers as PENDING, matching the model's fail-closed step. Unsealed REQUIRE_APPROVAL, DEFER and NARROW-candidate decisions with findings are outside the model's alphabet; for them the checker only enforces "no seal, phase CHECKING".
uv run python scripts/check_trace_conformance.py events.jsonl # exit 1 on findingstests/test_governance_trace_conformance.py drives the real SymbolicGovernor through govern, validate_action and revalidate_post_hitl over 265 single- and double-fault configurations (795 decisions plus a simulated actuation per seal), checks every trace, and includes a mutation control (a governor that seals over findings is caught) and one negative control per rule.
model.py, trace_conformance.py and stream_invariants.py ship byte-for-byte in packages/cage-conformance/, a standard-library-only CLI that lets a partner or auditor run these checks without a CAGE checkout (specification). The three modules therefore import each other relatively (from .model import ...) and nothing outside the standard library. After editing one, copy it into packages/cage-conformance/src/cage_conformance/: scripts/check_conformance_sync.py fails CI until the copies match.
# Standalone execution (no dependencies)
uv run python proof/model.py
# Regression tests (pins published state counts)
uv run pytest tests/test_no_direct_bind_proof.py -vExpected output:
✅ All assertions passed.
PROVED:
1. The gated CAGE architecture satisfies No-Direct-Bind over the
entire reachable state space (34 states).
2. The ungated (direct-bind) variant provably violates the invariant.
...
| Spec File | Scope | Invariants |
|---|---|---|
DistributedCBF.tla |
Multi-process CBF admission under a lagging Redis replica: read → CAS write, stale failover, process restart, rollback; twin of distributed_cbf_model.py |
SP1_NoDoubleSpend, SP2_NoOvercommit, SP4_FenceEpochMonotonic (action property) |
FtraBoundary.tla |
One request through the network policy, the in-graph ftra_node and the unconditional controller FtraStage: finance registry, fail-closed classification, conditional auto-clear, HITL propagation (POAM-2026-091) |
ControllerBoundaryCoversInGraphBypass, ControllerBoundaryUnconditional, NoUnreviewedIrreversibleExecution, FailClosedOnUnknownAction, RegistryUnavailableFailsClosed, AutoClearOnlyInsideEnvelope, NetworkPolicyEnforced, ConsistentClassification, HITLRequiredPropagates, ParseErrorsPreventClear; liveness EveryRequestTerminates |
LangGraphHarness.tla |
One advisor thread over several turns (graph.py, safety_node.py, governed-trader subgraph) and the DeferQueue tickets it parks (POAM-2026-091, POAM-2026-093) |
NoDirectBind, SealGateIntegrity, EvidenceChainIntegrity, HITLTimeoutSafety, OutputRailCoverage, SingleUseDeferralTicket, BudgetNeverExceededWithoutPause, RefusedTurnNeverTrades, FtraHoldNeverTradesThisTurn |
One agent is one gateway process; Redis is one primary replicating asynchronously to one replica. distributed_cbf_model.py documents the mapping of every action to cbf_engine.py. Bounds: pool 2, one in-flight debit per process, epoch ≤ 4, one stale failover. TLC on each cfg (N = 2, -continue) must report exactly the BFS distinct-state count and SP-1/SP-2 verdicts, and no other violation, SP-4 included (scripts/verify_tla.py).
| cfg | Posture | States (N=1 / 2 / 3) | SP-1 | SP-2 |
|---|---|---|---|---|
DistributedCBF.cfg |
WAIT 1 with strict rollback; reconciled (shipped staging/prod) and self-reported mode alike |
107 / 1811 / 23723 | ✅ | ✅ |
DistributedCBF_nosync.cfg |
No WAIT N (negative control) |
244 / 3972 / 49325 | ❌ | ❌ |
DistributedCBF_unfenced.cfg |
Shipped posture without the fence-epoch CAS | 110 / 2388 / 44261 | ✅ | ✅ |
What the counterexamples show:
WAIT Nis load-bearing. Without it a committed debit is lost at failover and another process — or the same one after a rollback or restart re-seeds_last_seen_epoch— spends the restored balance. The fence epoch and_last_seen_epochdo not prevent this;safety:fence_epoch_hwmregresses with the replica.- One model covers both modes.
LUA_ATOMIC_CBFchecks the barrier against live primary state in reconciled mode (snapshot net of the live total, snapshot generation checked) and in self-reported mode (GETof the state key; Python's earlier read is only a seed for an unset key).LUA_ROLLBACK_CBFrestores only a ledgered amount; a debit with no entry takesROLLED_BACK_UNLEDGEREDand credits nothing. This closed the self-reported fence-epoch ABA and rollback over-credit that the formerDistributedCBF_selfreported.cfgdemonstrated. - SP-1 does not rely on the fence CAS: the script evaluates the barrier on live state.
Each .tla spec has a corresponding .cfg config file that defines:
- Constants: Model parameters (agent sets, thresholds, timeout bounds)
- Invariants: Safety properties to check
- Deadlock checking: disabled — the models are bounded, so terminal states are expected
Example config (DistributedCBF.cfg; tests/test_distributed_cbf_proof.py checks every cfg's constants against the spec and the Python CONFIGS):
SPECIFICATION Spec
CONSTANTS
AgentIDs = {a0, a1}
InitialPool = 2
ReserveAmount = 1
MaxAgentReserve = 1
MaxFenceEpoch = 4
MaxStaleFailovers = 1
SyncReplication = TRUE
Fenced = TRUE
AllowRestart = TRUE
INVARIANTS
TypeOK
SP1_NoDoubleSpend
SP2_NoOvercommit
PROPERTIES
SP4_FenceEpochMonotonic
CHECK_DEADLOCK FALSE
Neither spec has a Python twin, so TLC's distinct-state count (-continue) and the exact set of violated invariants are pinned in tla_pins.py. Each spec has one negative-control cfg that removes the defence it depends on; it lists the demonstrated invariant first because TLC reports only the first violated invariant per state.
| cfg | Posture | Distinct states | Result |
|---|---|---|---|
FtraBoundary.cfg |
Shipped: FtraStage in every governor run, NetworkPolicy applied; FairSpec |
8272 | ✅ all invariants; EveryRequestTerminates holds |
FtraBoundary_nonetpol.cfg |
No NetworkPolicy (e.g. agnostic target) | 8756 | ✅ all invariants |
FtraBoundary_noboundary.cfg |
Governor without FtraStage (negative control) |
13792 | ❌ NoUnreviewedIrreversibleExecution, ControllerBoundaryCoversInGraphBypass, ControllerBoundaryUnconditional |
LangGraphHarness.cfg |
HEAD: status-guarded DeferQueue._resolve; 3 turns |
82652 | ✅ all invariants |
LangGraphHarness_unguarded.cfg |
_resolve without the status guard (pre-POAM-2026-093; negative control); 1 turn |
906 | ❌ SingleUseDeferralTicket |
LangGraphHarness constants: MaxLoopCount = 3 (route_after_evaluator), HITLTimeoutTicks = 1, MaxConsecutiveDenials = 2 (safety_node.MAX_CONSECUTIVE_DENIALS), MaxTurns = 3, ResolveGuarded = TRUE.
What the harness invariants say:
NoDirectBind,SealGateIntegrity,EvidenceChainIntegrity: a trade executes only after a gateway ALLOW (or a consumed approval plus POST_HITL re-validation), with a seal and committed evidence.HITLTimeoutSafety: an expired interrupt never trades.OutputRailCoverage: every response past the input rail passes the output rail.SingleUseDeferralTicket: each DeferQueue ticket is resolved at most once and consumed at most once.BudgetNeverExceededWithoutPause: a refusedsafety_checkreportsHARD_PAUSE_BUDGET_EXCEEDEDexactly when the newconsecutive_denialsis at least 2. The pause ends that turn; it does not lock the thread, and the counter persists in the thread checkpoint.RefusedTurnNeverTrades,FtraHoldNeverTradesThisTurn: a refused or paused turn, and a turn the FTRA node parked, never reach the trader (an FTRA HITL resume goes to the explainer).
TLC needs Java 11+ and tla2tools.jar, neither of which is in the repository or the uv environment. CI uses the runner's Temurin 21 and fetches the jar at TLA_TOOLS_VERSION, checked against TLA_TOOLS_SHA256; both values are in tlc-model-check.yml. To reproduce CI locally:
# The jar CI uses; take TLA_TOOLS_VERSION and TLA_TOOLS_SHA256 from tlc-model-check.yml
curl -fsSL -o tla2tools.jar \
"https://github.com/tlaplus/tlaplus/releases/download/<TLA_TOOLS_VERSION>/tla2tools.jar"
echo "<TLA_TOOLS_SHA256> tla2tools.jar" | sha256sum -c -
# Any Java 11+. Without one, unpack a Temurin 21 JRE (https://adoptium.net) into a user
# directory after checking the archive against the sha256 Adoptium publishes for it.
export JAVA=/path/to/jre/bin/java TLA_TOOLS_JAR="$PWD/tla2tools.jar"Without them, nothing fails. make verify-tla runs the Python BFS only and exits 0. The live TLC tests in tests/test_tla_specs_proof.py and tests/test_distributed_cbf_proof.py skip (TLA_TOOLS_JAR not set), so pytest stays green. CI's pytest-logic job has no TLC and always runs them that way; only the tlc-model-check job runs TLC. A local run counts as TLC evidence only if pytest reports those tests passed rather than skipped (-rs lists the skips), or if scripts/verify_tla.py is run directly, which exits 2 when either tool is missing.
# Python BFS, then TLC on every pinned cfg (DistributedCBF vs the BFS; the others vs tla_pins.py)
TLA_TOOLS_JAR=/path/to/tla2tools.jar make verify-tla
# A single cfg
java -cp tla2tools.jar tlc2.TLC -config proof/DistributedCBF.cfg proof/DistributedCBF.tlaDistributedCBF.cfg reports Model checking completed. No error has been found. with 1811 distinct states. Under -continue TLC prints that line, and exits 0, even after reporting violations (DistributedCBF_nosync.cfg prints it after 1,986 invariant violations), so verify_tla.py reads TLC's Error: lines instead. The negative-control cfgs report Invariant SP1_NoDoubleSpend is violated and a counterexample trace; make verify-tla treats that as the expected result and fails on a count or invariant verdict that differs from the BFS. FtraBoundary*.cfg and LangGraphHarness*.cfg must match tla_pins.py exactly. On every cfg, any other Error: line fails the run, whatever it reports: a violated action property (TLC reports SP4_FenceEpochMonotonic as Action property … is violated, not as an invariant), a temporal property, a deadlock or an exception. So does a run that never reports completion. Every cfg must also declare exactly the invariants and properties DECLARED_CHECKS in tla_pins.py pins for it: dropping an INVARIANTS or PROPERTIES line leaves every state count unchanged, so the counts alone cannot show that one property fewer was checked. Without TLA_TOOLS_JAR, make verify-tla runs the BFS only.
-
Python BFS:
- ✅ Exhaustive (enumerates every reachable state)
- ✅ No dependencies (runs in CI without TLA+ toolchain)
- ✅ Pins published state counts (regression protection)
- ❌ No liveness properties (safety only)
- ❌ No temporal logic (hard to express distributed properties)
-
TLA+ Model Checking:
- ✅ Temporal logic (liveness, fairness, eventually properties)
- ✅ Mature distributed systems verification (Raft, Paxos, etc.)
- ✅ Expressive language for concurrency and non-determinism
- ❌ Needs Java and
tla2tools.jarlocally (CI fetches a sha256-pinned jar in thetlc-model-checkworkflow on every PR) - ❌ Requires TLA+ expertise to interpret traces
Prior to ARCH-1, the Python BFS model excluded FTRA (Tier 0.5) because it then ran at the LangGraph graph level, not inside the governor's per-call checks. This created a proof/implementation divergence:
- Production: FTRA blocked irreversible actions before the governor's per-call checks ran
- Proof: FTRA was not modeled, so the state-space omitted action classification barriers
ARCH-1 resolution: FTRA is now modeled as the first tier in the BFS state machine, and at runtime it is the first read-only stage of run_pipeline() (src/gateway/governance/governor/stages/ftra.py). The proof covers its fail-closed semantics and routing seal enforcement.
From FtraBoundary.tla (TLC-checked, POAM-2026-091):
- ControllerBoundaryCoversInGraphBypass: every request that reaches the governor without the in-graph node gets a controller FTRA verdict, and an uncleared irreversible terminal is not sealed without a human
- ControllerBoundaryUnconditional:
FtraStageruns in every FULL / DRY_RUN governor run - NoUnreviewedIrreversibleExecution: a truly irreversible action executes only with a human approval (in-graph or governor) or a conditional auto-clear inside its envelope
- FailClosedOnUnknownAction / RegistryUnavailableFailsClosed: unregistered actions and unloadable registries classify
IRREVERSIBLE_TERMINALand never auto-clear; an unavailable registry is a HARD deny at the controller - NetworkPolicyEnforced: with the NetworkPolicy applied, only admitted callers reach the gateway (an interim compensating control;
FtraBoundary_nonetpol.cfgshows the controller check alone suffices)
These are not provable in the Python BFS model (which abstracts FTRA as a binary PASS/FAIL tier). The in-graph node has no magnitude extractor, so nothing auto-clears in-graph, and bypassed_ftra_node is a telemetry label: the controller cannot know whether the in-graph node ran.
# .github/workflows/ci.yml
- name: no-direct-bind-proof
run: uv run python proof/model.py
- name: pytest-logic # includes tests/test_distributed_cbf_proof.py
run: uv run pytest tests/test_no_direct_bind_proof.py -vRegression tests pin the governance model counts — gated 34 / ungated 17 / skipped-tier 31 / EU_ECB 38 — and every distributed CBF count and verdict in the table above, plus the cfg ↔ spec ↔ Python constant parity. Any change that alters these numbers fails CI and triggers a review of docs/paper/REVISION_TRACKER.md (published figures must stay consistent with proof).
TLC runs in the tlc-model-check workflow on every pull request and every push to main, and locally via make verify-tla. pytest-logic also catches drift without Java: tests/test_distributed_cbf_proof.py and tests/test_tla_specs_proof.py fail if a cfg names a constant or invariant the spec does not define, declares a different set of invariants or properties than DECLARED_CHECKS, or is not pinned, and if the Step 15 table in FORMAL_VERIFICATION.md stops matching the pins. They also run TLC themselves when TLA_TOOLS_JAR is set. Update both the spec and the Python twin whenever the CBF admission protocol changes (LUA_ATOMIC_CBF, LUA_ROLLBACK, _check_fence_epoch, WAIT handling).
- Python BFS Model:
proof/model.py - TLA+ Specs:
proof/*.tla - TLC Configs:
proof/*.cfg - Regression Tests:
tests/test_no_direct_bind_proof.py,tests/test_distributed_cbf_proof.py,tests/test_tla_specs_proof.py,tests/test_governance_trace_conformance.py,tests/test_stream_invariants_oracle.py - Trace & Stream Checkers:
proof/trace_conformance.py,proof/stream_invariants.py,scripts/check_trace_conformance.py - Standalone Verifier Specification (W4):
docs/architecture/CONFORMANCE_VERIFIER_SPEC.md - TLC runner:
scripts/verify_tla.py(pins:proof/distributed_cbf_model.py,proof/tla_pins.py) - Paper Citation: CAGE_ARXIV.MD §4.4 "Formal Verification", Appendix A
- Revision Tracker:
docs/paper/REVISION_TRACKER.md(published state counts)
All proof artifacts in this directory are released under the Apache 2.0 License.
The Python BFS model (model.py) is adapted from the open-source implementation by LalaSkye (Apache 2.0), available at: https://github.com/LalaSkye/no-direct-bind
Modifications: Extended to CAGE's 7-tier deterministic architecture, added the NARROW state, seal consumption semantics, and FTRA tier.