Skip to main content

Interlock

A reference monitor for agent side effects. An agent proposes a plan. It holds no credentials and cannot execute. Interlock applies the plan against a real but uncommitted substrate, measures what actually changed, evaluates deterministic predicates against that measurement, and only then commits or rolls back.

uv run python demo.py
  ACT 2  The identical plan, through Interlock.
    MEASURED by the database, not predicted from the plan:
      rows updated    63   inserted    63   deleted   103
      blast radius   229 rows across 4 tenants
      orders.total $21,350.25 -> $425.25

    ADJUDICATED by 7 deterministic checks:
      BLOCK   blast_radius          plan would mutate 229 rows, limit is 8
      BLOCK   tenant_isolation      plan spans 4 tenants, limit is 1
      BLOCK   no_delete             plan deletes 103 row(s) from ungranted table(s): order_audit
      BLOCK   column_value_guard    orders.total falls 98.0% (21,350.25 to 425.25), limit is 30%
      advise  stated_footprint      agent stated 6 row(s); substrate measured 229 (38.2x)

    OUTCOME  ROLLED BACK
      Byte-identical to before the plan ran. Nothing reached disk.

Why authorization does not catch this

A prompt-injected agent is a fully authorized agent. It is doing something it has permission to do, so an authorization check passes. The injected instruction arrived from the customer's own database, inside a tool result, after every provider-side filter had already run, so a provider-side filter does not see it either. The only remaining place to catch it is between the effect being computed and the effect becoming durable.

That position requires write-path credentials to the database the effects land in, which is why this runs inside the deployment rather than at the model provider.

The demo is also a control for model quality: the agent loops zero times, costs $0.0012, and emits valid SQL on the first attempt. The damage is not a model-quality failure.

demo.py's agent is scripted, not live. injected_plan (a function local to demo.py, not part of the package) and the rest of Act 2's input are hardcoded EffectPlans in demo.py itself; no model is called anywhere in that file. That is deliberate -- it isolates one variable (does the escrow stop this plan?) from another (would a real model produce it?) -- but it means demo.py cannot answer the second question, and an adversarial audit of this project found that a real, current Claude model declines the exact injected instruction shown above, across several realistic framings of the same prompt injection. For the first question answered against a real model instead of a scripted stand-in, see scripts/live_gauntlet.py: eight scenarios, a real anthropic.Anthropic client, real governed spend, and verdicts the script itself says are not reproducible, because what the model chooses to do is not.

What it measures

The diff is read from the substrate, never reconstructed from the plan. The two are routinely different:

Agent stated 1 row
SQL rowcount 1 row
Measured 2 rows across 2 tables

A compliance trigger nobody remembered doubled the footprint. No plan inspection, SQL parser or policy engine reading the statement could know that. The database knew, because it had just done it.

Lifecycle

PLANNED -> STAGING -> STAGED -> VERIFIED  -> COMMITTED
                             \-> REJECTED -> ABORTED

These properties hold by construction:

  1. No path from STAGED to COMMITTED skips adjudication.
  2. REJECTED is not overridable in-process. Overriding means submitting a new plan that carries an explicit waiver, which is then itself a recorded artifact.
  3. A committed effect is discoverable from the chain. COMMIT_INTENT is written, and forced to stable storage, before the substrate is told to commit, and COMMITTED after it returns, so a crash in that window leaves an intent with no terminal record after it.
  4. That crash is resolved exactly, not guessed. An intent says only that a commit was attempted, so the substrate also writes a marker row, in _interlock_commits, inside the stage's own transaction: it exists if and only if the effects do. On restart, EscrowEngine.recover() reads the marker for every intent a crashed process left open and appends COMMITTED or ABORTED, noted as recovered. EscrowRuntime runs it at startup whenever it has a chain_path. An intent written before v0.1.2, or by a substrate without markers, is left open and logged for an operator: its marker's absence proves nothing. So is one the server has not decided yet (a client that died mid-COMMIT leaves PostgreSQL still committing); a later recover() resolves it.
  5. A commit whose answer is lost is resolved the same way, while the process lives. If the connection drops with COMMIT in flight, the engine reads the marker at once: the plan is reported committed if it landed, and rolled back only once the server has ended the transaction without it. Until the server decides, execute() raises CommitUnsettledError and writes no terminal record. Do not retry the plan then: the intent stays open, and recover() settles it.

Quickstart

EscrowRuntime owns the substrate, the record chain, the AgentGov anchor and the engine. Five lines to a governed write:

from interlock import EscrowRuntime, TableSpec

runtime = EscrowRuntime(
    "prod.db",
    tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
    scope_id="support-agent",
)
result = runtime.execute_sql(
    "UPDATE orders SET total = :total WHERE id = :id",
    {"total": 480.0, "id": 1},
    tenant_id="acme",
    stated_rows=1,
)
print(result.committed, result.blocked_by)

That stages the statement in a real transaction, measures what the database actually did, adjudicates the measurement, and commits or rolls back.

The runtime takes every option the engine does: substrate= in place of the path (any substrate, PostgreSQL included), windows=, inbox=, sinks=, and anchor= for claim and settle. EscrowRuntime.from_config(config, scope_id=...) builds the one interlock.toml describes.

Placeholders must be named. Effect.parameters is a Mapping, so the driver binds by name: write :id, never ?. A positional placeholder is refused when the plan is built, before any lock is taken.

Building a plan

For anything past one statement, PlanBuilder mints the identifiers and the UTC timestamp and wires the dependency DAG:

from interlock import PlanBuilder

plan = (
    PlanBuilder(scope_id="support-agent")
    .update(
        table="orders",
        statement="UPDATE orders SET total = :total WHERE id = :id",
        parameters={"total": 100.0, "id": 1},
        tenant_id="acme",
        stated_rows=1,
    )
    .build()
)

Effects are sequential by default: each depends on the one added before it. Pass independent=True for no ordering constraint, or after=[effect_id] to fan out. builder.last_effect_id hands back the id to depend on.

Under the runtime

The primitives are unchanged and the runtime hands them back (runtime.engine, runtime.chain, runtime.anchor). Wire them yourself when you need to:

from interlock import (
    BlastRadius,
    EscrowChain,
    EscrowEngine,
    PlanBuilder,
    SqliteSubstrate,
    TableSpec,
    TenantIsolation,
)

substrate = SqliteSubstrate(
    "prod.db",
    tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
)
engine = EscrowEngine(
    substrate,
    checkers=[BlastRadius(50), TenantIsolation(1)],
    chain=EscrowChain("escrow.jsonl"),  # omit this and the chain is in memory only
)

plan = (
    PlanBuilder(scope_id="support-agent", intent="refund order 1")
    .update(
        table="orders",
        statement="UPDATE orders SET total = :total WHERE id = :id",
        parameters={"total": 480.0, "id": 1},
        tenant_id="acme",
        stated_rows=1,
    )
    .build()
)

result = engine.execute(plan)
if not result.committed:
    print(result.blocked_by, result.diff.blast_radius)

execute() takes a per-plan settle_cost= that overrides the engine's default, because what a plan cost to produce is a property of the plan.

The chain file. EscrowChain(path) resumes an existing file: every record is read and verified before the first append, so a restarted process continues the chain rather than starting a second one at sequence 1. One file has one writer. The chain claims <path>.lock for as long as it is open, and a second writer, in this process or another, gets ChainInUseError: two writers would fork the chain, and recovery would resolve one writer's in-flight commits as if it had died. EscrowChain.load(path) reads and verifies a live chain without claiming it, and cannot append. A final line left torn by a crash mid-append is cut off by the next writer, because that append never returned. Intents and outcomes are fsynced before append returns; pass fsync=False to give that up for speed.

Typing

PlanId and EffectId are NewTypes over str. PlanBuilder mints both, so most code never names them. Under mypy --strict, constructing an EffectPlan or Effect by hand means importing them:

from datetime import UTC, datetime

from interlock import Effect, EffectId, EffectKind, EffectPlan, PlanId

plan = EffectPlan(
    plan_id=PlanId("plan-0001"),
    scope_id="support-agent",
    trajectory_id="traj-0001",
    created_at=datetime.now(UTC),
    effects=(
        Effect(
            effect_id=EffectId("eff-0001"),
            kind=EffectKind.UPDATE,
            target="orders",
            statement="UPDATE orders SET total = :total WHERE id = :id",
            parameters={"total": 480.0, "id": 1},
            tenant_id="acme",
            stated_rows=1,
        ),
    ),
)

Invariants

Checkers are pure functions of (plan, diff). No I/O, no clock, no RNG, no model call. A checker that reads the world cannot be replayed, and a verdict that cannot be replayed is not evidence. Where a judgment genuinely needs a model, the model is a sensor whose output is passed in at configuration time, never consulted from inside check. A checker that raises becomes a blocking violation: fail closed.

Checker Bounds
BlastRadius captured mutations in one plan
TenantIsolation distinct tenants one plan may span; with declared=True, the tenants its effects declare
ColumnValueGuard fall in a column total, table-wide
TenantDrawdownGuard fall in a column total, per tenant
NoDelete deletions outside granted tables
NoSchemaChange effects declared as DDL
TruncationGuard a diff that hit the row cap
StatedFootprint the agent's claim against the measurement
CrossEffectAgreement an outbound request against the rows it rides with (outbox)
SinkAllowlist the sinks and operations a plan may call
OutboundCount the requests one plan may enqueue, in all or per sink
PayloadAmountCap a payload amount, per request, per tenant or per plan, per currency
RecipientAllowlist where a request may send: email domains and addresses, https hosts, per tenant
OutboundTenantIsolation the tenants one plan's requests may span
FactAgreement a write against the inbound fact it must follow (inbox)

TenantIsolation(1) counts tenants: a plan declared for acme that changes one row of globex, and none of its own, spans one tenant and passes. When an agent acts for one tenant and the operator stamps every effect with it (tenant_id=, never the model's choice), pass declared=True: a row of any tenant the plan's effects do not declare is refused, one row as much as many, and the agent is told its own tenants and nothing of the other.

TenantDrawdownGuard exists because ColumnValueGuard sums the column across the whole diff, and a sum is the wrong denominator on a multi-tenant substrate. A plan that zeroes one tenant while inflating another by the same amount moves that sum by 0%, so a table-scoped guard passes it — with a tenant wiped. Grouping by tenant first is the only way to see it. Column totals are Decimal, computed through Decimal(str(value)), so a guard permitting a 30% drawdown does not admit 30.000000000000004%.

Every threshold in default_checkers is an uncalibrated placeholder. Measure your own diffs and set the limits from the measurement.

Errors

Every failure on the write path is an InterlockError. The AgentGov seam is an implementation detail of the anchor, and its exceptions are translated at the boundary, so this is sufficient:

from interlock import EscrowRuntime, InterlockError, TableSpec

runtime = EscrowRuntime(
    "prod.db",
    tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
    scope_id="support-agent",
)

try:
    runtime.execute_sql(
        "UPDATE order_audit SET note = :note WHERE id = :id",
        {"note": "x", "id": 1},
        table="orders",  # the label lies; the statement writes elsewhere
        tenant_id="acme",
        stated_rows=1,
    )
except InterlockError as exc:
    print(type(exc).__name__, exc)

That example is the case worth knowing about: table= is a label the agent writes, and the statement goes somewhere else entirely. The substrate denies the write inside SQLite before it executes, and it surfaces as a ForbiddenStatementError like everything else on this path.

Refusals: two audiences

A refusal has two readers, and they must not get the same record. The operator needs everything: which tenants the plan reached, the exact totals, the checker's own message. The agent must get none of it. A guard's message is data about other tenants, and an injected agent reading refusals is reading a side channel.

So every result carries both. StageResult.refusal.evidence is the operator's record. StageResult.feedback (and exc.feedback on any error execute raises) is what the agent may be told:

from interlock import EscrowRuntime, TableSpec, TenantIsolation

runtime = EscrowRuntime(
    "prod.db",
    tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
    scope_id="support-agent",
    checkers=[TenantIsolation(1)],
)
result = runtime.execute_sql("UPDATE orders SET total = 0", table="orders", tenant_id="acme")

assert not result.committed
print(result.feedback.render())  # for the agent
# The plan was refused and rolled back; nothing changed.
# - tenant_isolation: the plan changed rows of more tenants than one plan may
#   (at most 1); its declared tenants are acme. Confine every statement to its
#   declared tenants.
assert "globex" in result.refusal.evidence.tenants_touched  # for the operator

Feedback is built so that it cannot carry more than the agent already knew:

  • it names only tables the plan's effects name, and tenants they declare. Anything else is "a table the plan does not name", never named or counted;
  • row counts are buckets (0, 1, 2-9, 10-99, 100-999, 1000+);
  • it carries no aggregate at all: no column total, no fraction of one. The only other number is a built-in checker's configured limit, as a whole percentage;
  • its text comes from fixed templates, and its constraints come in a canonical order, never the order the violations came in.

A checker contributes a typed hint per violation, and the hint is sanitized against the plan field by field. A custom checker's hint can name the plan's own tables and tenants and pick a kind of guidance. Its numbers and columns are dropped, because nothing can tell a row count from a total. Errors are mapped by type, never by message, since a database error can quote another tenant's row. Property tests check, over arbitrary multi-tenant diffs and adversarial checkers, that renaming anything the plan did not name, scaling every amount, or changing other tenants' values leaves the feedback byte-identical (see tests/test_feedback.py).

Repair: the part that would pass

A refused plan is often one step away from an acceptable one: nine corrections to one tenant's orders and a tenth that reaches another tenant. Refusing all ten is right; telling the agent which nine would pass turns a halt into finished work. repair() finds the largest part of a plan that would be admitted, by experiment. It stages the plan once and, inside that one stage, tries candidate sub-plans in savepoints: each is run, measured and adjudicated by the same checkers, then rolled back.

from interlock import EscrowRuntime, TableSpec, TenantIsolation

runtime = EscrowRuntime(
    "prod.db",
    tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
    scope_id="support-agent",
    checkers=[TenantIsolation(1)],
)
plan = (
    runtime.plan(intent="apply ticket 9001 corrections")
    .update(
        table="orders",
        statement="UPDATE orders SET total = 450 WHERE id = 1",
        tenant_id="acme",
        effect_id="reprice_1",
        independent=True,
    )
    .update(
        table="orders",
        statement="UPDATE orders SET total = 250 WHERE id = 2",
        tenant_id="acme",
        effect_id="reprice_2",
        independent=True,
    )
    .update(
        table="orders",
        statement="UPDATE orders SET total = 0 WHERE id = 3",  # globex's order
        tenant_id="acme",
        effect_id="reprice_3",
        independent=True,
    )
    .build()
)
assert not runtime.execute(plan).committed

repair = runtime.repair(plan)  # advisory: nothing is committed here
print(repair.feedback.render())  # for the agent
# Resubmit the largest part of the plan that would be admitted: keep reprice_1,
# reprice_2; drop reprice_3.
# - reprice_3: tenant_isolation: the plan changed rows of more tenants than one plan
#   may (at most 1); its declared tenants are acme. Confine every statement to its
#   declared tenants.
assert runtime.execute(repair.proposal).committed
  • Advisory, and re-adjudicated. The repair stage is always rolled back. The proposal is a new plan whose repair_of names the refused one, and it commits only through execute(), staged and adjudicated again from scratch, because the database may have moved in between.
  • Exactly as proposed, and once. A plan naming itself a repair is admitted only if this engine's chain holds a REPAIR_PROPOSED record with its exact content hash, and only until it has committed. A widened or forged "repair" is refused.
  • No monotonicity assumed. A debit alone fails a drawdown guard that the debit and its balancing credit pass together. A search that dropped whatever fails on its own would keep a lone credit; this one keeps both and drops only the step that is wrong.
  • Runnable candidates, largest first. A step is kept only with everything it depends on. The whole plan is tried, then every such sub-plan one step smaller, and so on, later steps dropped first within a size; the first admitted one is a largest, and repair.exhaustive says so. max_trials (default 32) bounds the search; when it runs out, a greedy pass keeps each of the plan's independent parts (the connected components of its dependency graph), then each step, whose addition is admitted, which is sound but may not be largest. Parts first, because steps a rule admits only together (a row and the request it rides with) are each refused alone. The stage's time bound ends the search too.
  • Explained without leaking. Each dropped step says why, from its own trial, adding it back with the dropped steps that depend on it: the constraints that refused it, sanitized like every other feedback, a dropped dependency, or a statement the substrate refuses outright.

Both substrates have savepoints. A repair's trials all run inside one stage, so the stage's locks are held for the whole search.

Rate windows

A checker sees one plan. An agent that keeps every plan under every per-plan limit, a refund of 50 a hundred times over, passes every one. A rate window measures a plan with what came before it (docs/EPIC4_DESIGN.md §3):

from datetime import timedelta

from interlock import (
    BlastRadius,
    EscrowEngine,
    OperationSpec,
    RateWindow,
    Requests,
    RequestSum,
    SinkRegistry,
    SinkSpec,
    SqliteSubstrate,
    TableSpec,
)

engine = EscrowEngine(
    SqliteSubstrate(
        "prod.db",
        tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
    ),
    checkers=[BlastRadius(100)],
    sinks=SinkRegistry(
        [
            SinkSpec("mail", (OperationSpec("send"),)),
            SinkSpec("payments", (OperationSpec("refunds.create"),)),
        ]
    ),
    windows=[
        RateWindow("mail_per_tenant_hour", timedelta(hours=1), 10, Requests("mail"), per="tenant"),
        RateWindow("mail_per_agent_hour", timedelta(hours=1), 50, Requests("mail")),
        RateWindow(
            "refunds_per_agent_day",
            timedelta(days=1),
            "10000",
            RequestSum("payments", "refunds.create", "amount"),
        ),
    ],
)

A window counts requests (Requests), sums a payload amount (RequestSum) or a column over the plan's rows (RowSum, inserted or net), or counts plans (Plans), per scope, per tenant or globally, and slides: what a plan added stops counting a span after it committed. Pair a per-tenant window with a per-scope one: a request's tenant is the plan's to declare.

Each plan is measured with the history of every window key it adds to, read with the key locked: on PostgreSQL a transaction-scoped advisory lock held to COMMIT, the history read on a second, READ COMMITTED connection; on SQLite the stage's own write lock. Plans racing into one window commit exactly up to its limit. The history and what the plan adds are part of the diff, so the verdict replays from it; what the plan adds is written beside its commit marker, or not at all. Statements of a plan can neither read nor write window history. The agent is told which window refused it, never what the window holds:

- rate_window: the plan would take rate window mail_per_agent_hour past its limit. The
  window slides: what earlier plans added stops counting as it ages.

[[windows]] in interlock.toml configures them, checked against [[sinks]] and [[tables]], for EscrowEngine(windows=config.windows).

AgentGov integration

Interlock imports agentgov as a read-only audit dependency. The dependency runs one way (interlock -> agentgov) and requires no change to AgentGov.

Every adjudication is written to a hash-linked chain whose records carry the AgentGov head hash observed at the time. That proves a lower bound on a record's time. It does not prove an upper bound on its own, because a record could name a stale head, so verify_anchors() additionally requires the observed AgentGov sequence to be non-decreasing across an append-only chain.

The breaker is read as it is now, immediately before commit. Attached read-only (audit_path=, the separate-process deployment), Interlock refreshes its view of the governor before every record and before the pre-commit breaker check, verifying everything the governor wrote since, so a halt written after Interlock attached stops the commit. A view that fails verification refuses to operate. With a governed manager in the same process, the governor's own lock is held from that check through the substrate's commit, so a trip lands strictly before it (and the commit is refused) or strictly after it. Across processes no lock spans both databases: a trip the governor commits between the check and the commit cannot be excluded, and when one happens the COMMITTED record says so.

Where Interlock is co-resident with a write-capable governor it also writes its own chain head into AgentGov's chain: into a zero-value ANCHOR entry, or, when the plan carries a cost, into the memo of the spend that settles it. memo is inside AgentGov's hash payload, so the reverse anchor cannot be altered without breaking AgentGov's own verification. The two chains then interlock in both directions.

Reverse anchoring is free. settle_cost defaults to "0", which writes a zero-value ANCHOR entry and charges nothing. Pass the plan's real cost and the anchor rides on the spend that settles it instead:

from agentgov import BudgetManager, money

from interlock import EscrowRuntime, TableSpec, TenantIsolation

governor = BudgetManager()
governor.open_root("support-agent", money("8.00"))

runtime = EscrowRuntime(
    "prod.db",
    tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
    scope_id="support-agent",
    checkers=[TenantIsolation(1)],
    governed=governor,  # enables reverse anchoring
    settle_cost="0.0002",  # what this plan cost to produce
)

result = runtime.execute_sql(
    "UPDATE orders SET total = :total WHERE id = :id",
    {"total": 480.0, "id": 1},
    tenant_id="acme",
    stated_rows=1,
)
print(result.committed, bool(result.anchored_to))

scope_id must name a scope AgentGov knows about. That is checked at admission, not at commit, because the reverse anchor is written after the substrate commits and a failure there would arrive on top of durable effects.

Every failure on this seam is an InterlockError. agentgov exceptions are translated at the anchor boundary, so a caller never has to import agentgov.exceptions to catch a write-path error.

AgentGov's latching breaker is re-read immediately before commit, not at admission. The staging window is where a trip has to be observed, because a halt that does not stop in-flight side effects does not stop the effects.

Settling with the commit: claim and settle (PostgreSQL)

Across processes, the governed mode above leaves two gaps: a trip another process commits between the breaker check and the commit, and a crash between the commit and the reverse anchor, which leaves effects with no settlement. Both close when the ledger lives in the same PostgreSQL database as the observed tables (BudgetManager.open_postgres, agentgov v0.3):

from agentgov import BudgetManager

from interlock import EscrowEngine, LedgerAnchor, PostgresSubstrate, TableSpec, default_checkers

governor = BudgetManager.open_postgres("postgresql://owner@db/app")  # the tables' owner
governor.store.grant_join("interlock_agent")  # once: the stage role may claim, and no more
engine = EscrowEngine(
    PostgresSubstrate(
        "postgresql://interlock_agent@db/app",
        tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
    ),
    checkers=default_checkers(row_limit=8),
    anchor=LedgerAnchor(governed=governor, same_transaction=True),
    settle_cost="0.25",
)

Each plan is settled in three steps:

  1. Reserve. Before the stage opens, the governor places a hold for the plan's cost, in its own transaction. A scope that is halted or cannot pay refuses the plan here, before anything is staged, and an overdraft trips its breaker.
  2. Claim. At commit the stage takes the ledger's writer lock inside its own transaction, the governor catches up with every other governor and checks the breaker, and a settlement claim keyed to the hold is written into the stage. The claim commits in the stage's COMMIT, with the effects and the commit marker, or not at all, and no trip in any process can land between the check and the commit.
  3. Settle. After the commit the claim is redeemed into AgentGov's chain: the hold's release and the spend, which names the plan's COMMIT_INTENT record.

The claim reads nothing of the chain, so the stage's REPEATABLE READ snapshot, however many ledger commits old, conflicts with nothing: a busy ledger never stops a plan from committing. Measured with four engines racing for one account while a governor wrote the ledger back to back, every plan committed, and every retry was the engines' own race for the account's row, as many as with no ledger traffic at all.

No crash can leave effects without their claim, or a claim without its effects. A process that dies after its commit leaves the claim pending, and recover() books it (any governor may book any pending claim: one exists only for a stage that committed). A plan that did not commit leaves at most its hold, which recover() releases once the chain says the plan can no longer commit. tests/test_crash_consistency.py runs every kill point against this mode, including after the hold, mid-claim, and after the commit but before the claim is booked.

  • The stage's role cannot write the ledger or claim anything. It gets no privilege on the ledger's tables, only EXECUTE on one SECURITY DEFINER function that records a claim, and only with a token derived from a secret the role cannot read and from the transaction's own id. A statement in an agent's plan runs as that role, and can neither write the ledger, forge a claim, nor reuse a governor's token.
  • Booked, a settlement stands whatever happened since. A claim whose hold an operator voided is booked as a spend on its own; one that overdraws the scope is booked and trips the breaker. Its effects already happened.
  • A refused plan is charged against its hold, after its stage, as before, naming its terminal record. A plan whose stage fails releases its hold.
  • A stage stuck in COMMIT holds the ledger until the server decides: no governor can write meanwhile, bounded by the stage's lock_timeout.

Receipts

With a ReceiptIssuer, every plan the engine adjudicates, committed or refused, gets a signed ARC1 receipt in an agentgov ReceiptLog (the format is agentgov's docs/RECEIPTS.md). Anyone holding the issuer's key can then check offline what the agent was allowed to do, what it said it would do, what it measurably did, what was decided and what it cost:

from agentgov.receipts import HmacKey, ReceiptLog, verify_bundle

from interlock import EscrowRuntime, ReceiptIssuer, TableSpec, TenantIsolation

key = HmacKey.generate()
log = ReceiptLog("support-receipts", key, path="receipts.jsonl")
runtime = EscrowRuntime(
    "prod.db",
    tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
    scope_id="support-agent",
    checkers=[TenantIsolation(1)],
    receipts=ReceiptIssuer(log),
)
result = runtime.execute_sql(
    "UPDATE orders SET total = 480 WHERE id = 1", table="orders", tenant_id="acme"
)
receipt = result.receipt
print(receipt.outcome.status.value, receipt.effect.row_count)  # committed 1

bundle = log.bundle(log.index_of(receipt.receipt_id), log.checkpoint())
assert verify_bundle(bundle, issuer_key=key).passed

What goes into one:

  • Authority: the AgentGov scope path when an anchor is attached, the trajectory, and a capability naming the observed tables and the tightest BlastRadius limit.
  • Intent and effect: the plan's content hash and stated footprint; the diff hash the chain's DIFF_COMPUTED record carries; a salted commitment over every measured row change, from which one row can be disclosed later without the rest; and a hash of the observed schema, foreign keys included.
  • Coverage: the observed tables, and every gap in words: an acknowledged cascade, a substrate that did not read the foreign keys or does not refuse unobserved tables.
  • Decision: the verdict hash, each checker with a digest of its configuration, the issuer's policy epoch, and, for a repair, the refused plan's receipt as repair_of.
  • Cost: the AgentGov transaction that settled the plan. The receipt is issued last, after the reverse anchor, so agentgov's verifier can check the cost against the ledger.

The receipt and the escrow chain name each other: the stage's terminal record carries the receipt id, and the receipt's anchors.escrow names that record by sequence and hash. A receipt that cannot be issued after a commit is logged, not raised; the effects stand, and the terminal record names the receipt that should exist. An HMAC receipt verifies only for a holder of the key: for third parties, sign with agentgov's Ed25519Signer (agentgov[sign]) and publish the public key.

Recovery

A halt is correct, and it used to be the end of the task. RecoveryRuntime gives a halted task a bounded way to finish instead: a few more model calls, each under tighter constraints than the one before, paid from a reserve set aside for it, and each recorded and signed before it is made.

from decimal import Decimal

from agentgov import BudgetManager
from agentgov.cognitive import CognitiveBreaker, CognitivePolicy
from agentgov.exceptions import AgentThrashingError
from agentgov.receipts import HmacKey

from interlock import (
    Directive,
    RecordLog,
    RecoveryPolicy,
    RecoveryRuntime,
    ToolRevokedError,
    Trip,
    TripKind,
)

governor = BudgetManager()
governor.open_root("org", "10.00")
governor.delegate("org", "support-agent", "5.00")
policy = RecoveryPolicy(
    reserve=Decimal("0.50"),  # carved out of support-agent's envelope
    step_estimate=Decimal("0.05"),  # held for each recovery call
    directives=(
        Directive(
            "no-repeat",
            "Do not repeat a tool call that has already returned; use the results you have.",
            frozenset({TripKind.THRASHING}),
        ),
    ),
)
records = RecordLog(HmacKey.generate(), log_id="support")
runtime = RecoveryRuntime(
    governor, "support-agent", policy, records, tools=["lookup", "apply_plan"]
)

transcript = [
    {"role": "user", "content": "What is the total of order 1?"},
    {
        "role": "assistant",
        "content": [{"type": "tool_use", "id": "toolu_02", "name": "lookup", "input": {"id": 1}}],
    },
]
breaker = CognitiveBreaker(
    policy=CognitivePolicy(max_identical_repeats=2), observer=None, manager=governor
)
try:
    for _ in range(2):  # the same lookup, twice: halted before the second runs
        breaker.observe_call("support-agent", "lookup", kwargs={"id": 1})
except AgentThrashingError as exc:
    trip = Trip.of(exc, tool="lookup")

step = runtime.recover(trip, transcript, max_tokens=4096)
print(step.rung, step.granted_tools, step.revoked_tool)  # revoke_tool ('apply_plan',) lookup
# Send step.messages (through the beta endpoint with step.betas when non-empty,
# the plain one otherwise) and step.max_tokens, the system prompt and the tools
# as before; settle what the call cost; refuse a revoked tool where you run it:
runtime.settle(step, "0.03")
try:
    runtime.check_tool("lookup")  # the tool the halt named, and this step revoked
except ToolRevokedError:
    pass  # refuse the call here instead of running it
runtime.close()
records.verify()
print(governor.is_halted("support-agent"), len(records))  # True 4

The ladder is fixed and deterministic, one rung per step: revoke the tool the halt names, then an operator directive from the policy's allowlist, then a lower token ceiling, then guidance in fixed words. Constraints come before conversation. Each step takes the first rung that can still do something for this kind of halt, and tightening only accumulates: a revoked tool stays revoked and the ceiling stays down. When no rung is left, or max_steps is reached, recover() raises RecoveryExhaustedError and the halt stands. The same halts always take the same steps. A refused plan is left to guidance by default, so the agent can correct it; its guidance is the refusal's sanitized feedback, or a repair's (see Repair).

History is never edited. A step returns the transcript it was given, as the same objects, with messages appended: error results for tool calls the halt stopped, then the rung's notice. The system prompt and the tool definitions are never touched. On Claude Opus 5, Opus 5.5, Opus 4.8, Fable and Mythos, a directive goes in an appended {"role": "system"} message and a revocation in a tool_removal block (beta inline-tools-2026-09-15, in step.betas), the operator channel a user turn cannot forge; Channel.USER uses user-turn notices for every other model. Either way, check_tool() refuses a revoked tool where the harness runs it. Each step checks that the transcript it is handed extends the one the last step returned, so an edit in between is refused, and its record pins the transcript before and after by a running hash (transcript_head).

A non-empty step.betas has to go through the beta endpoint, not the plain one: with the anthropic Python SDK that is client.beta.messages.create(..., betas=step.betas), never client.messages.create(betas=...) (which raises a plain TypeError — that constructor does not take a betas keyword at all). step.betas is empty under Channel.USER, so a harness that only ever runs on a Channel.USER model never needs the beta endpoint. TOOL_CHANGES_BETA (interlock.recovery) names the one exact string this release sends; treat it as a snapshot of Anthropic's current API surface, not a permanent constant — an audit against live traffic already caught it drifting once (see the docstring), and nothing here detects the next rename automatically.

The reserve sits beside the scope, not under it. A trip halts the tripped scope's whole subtree, and recovery exists for a tripped scope, so {scope}/recovery is carved out of the scope's own envelope (released to its parent and delegated from there). The halted scope stays halted; every call made during recovery is billed to the reserve, a step's through recover() and settle(), the task's own through hold() and capture(). A root scope's reserve is a new root funded from the treasury, or RecoveryPolicy(funding=...) names a scope to delegate it from. close() returns what is left to the parent.

Every act is signed first. Opening, each step, each settlement and the close are RecordLog entries: ILOK1, agentgov's canonical JSON under an ILOK1/record/v1 signing prefix, hash-linked, and anchored into the AgentGov ledger as ANCHOR entries, so check_anchors() catches a log truncated or rewritten after the fact. A step's record names the halt (and a refused plan's ARC1 receipt), the rung and exactly what it did, the hold that pays for it, the policy's digest, and the transcript before and after, and is written before the step is returned. Persist the log (RecordLog(..., path=...)): a runtime restarted over it adopts the recovery scope and replays what was revoked and lowered, so a restart never loosens a constraint.

Extension quotes

AgentGov's backstop is blunt on purpose: an authorization that would overdraw a scope trips its breaker, and so does a capture that spends it to zero. For a runaway loop that is the answer. For a task that is only more expensive than its envelope, it throws away the work done. BudgetGuard.authorize() asks first: when a call will not fit, it returns a signed ExtensionRequest instead of placing a hold that would trip the breaker.

from agentgov import BudgetManager
from agentgov.receipts import HmacKey

from interlock import BudgetGuard, ExtensionRequest, Milestones, RecordLog

governor = BudgetManager()
governor.open_root("org", "10.00")
governor.delegate("org", "support-agent", "5.00")
guard = BudgetGuard(governor, RecordLog(HmacKey.generate(), log_id="support"))

held = guard.authorize("support-agent", "4.60")  # fits: an ordinary hold
governor.capture(held, "4.60")

done = Milestones(done=3, total=5, unit="tickets")
quote = guard.authorize("support-agent", "0.50", milestones=done)
assert isinstance(quote, ExtensionRequest)  # did not fit: quoted, and nothing tripped
print(quote.requested, governor.is_halted("support-agent"))  # 3.44 False
print(quote.render())
# Extension requested for support-agent: 3.44000000.
# Spent 4.60000000 (support-agent 4.60000000); held 0.00000000; 0.40000000 left; ...
# Proof of work: 0 plan(s) committed (0 rows); declared 3 of 5 tickets done.
# Estimate: 1.53333333 per unit x 2 remaining = 3.06666667, plus 25% margin = 3.84000000 ...

grant = guard.grant(quote, approved_by="ops@example.com")
print(grant.scope_id, governor.available(grant.scope_id))  # support-agent/ext-1 3.84000000

A quote has three parts, and is a signed extension.quoted record anchored in the ledger:

  • Spend to date, from the ledger: what the task's scopes (the scope, its recovery scope, its extensions) settled net of refunds, what they hold, and what is left.
  • Proof of work: with BudgetGuard(receipts=...), the ARC1 receipts of the plans the task committed and the rows they measurably changed, with a checkpoint the receipt log signed for the quote, so each receipt is provable by inclusion (log.bundle(log.index_of(id), checkpoint)); the recovery steps it took; and any milestones the harness declares, which are carried as declared, not proven.
  • An estimated completion cost, by a named method: cost per declared milestone times those remaining, or with none declared, the call that did not fit; plus a margin, rounded up to the cent. requested is that, less what the scope has left.

A quote is answered once, before it expires, by the guard that issued it: grant() or decline(), each a signed record naming the quote. A root scope is topped up in place, and if the money running out is what tripped it, its breaker is reset. A delegated scope cannot be topped up, so the grant delegates {scope}/ext-N beside it, carries along what the old scope had left, and the task bills there from then on. A grant the parent cannot make changes nothing. A scope halted for any other reason is not quoted: authorize() raises AgentGov's CircuitOpenError, because more money is not the answer to a safety halt.

A RecoveryRuntime given the guard quotes for its own reserve the same way: when a step will not fit, RecoveryExhaustedError.quote carries the request, and extend() takes up the grant. Quotes are operator evidence; nothing in them is sent to the agent. Granting is the operator's act: the guard records approved_by but cannot authenticate it, so put grant() behind the approval you already trust, and never within the agent's reach as a tool.

PostgreSQL

PostgresSubstrate stages each plan in a REPEATABLE READ transaction and measures it with row triggers installed once, so no stage ever alters a busy table. Install as the tables' owner, then stage as a separate role granted DML on the observed tables and nothing else:

uv pip install 'interlock[postgres]'
interlock install --config interlock.toml --database postgresql://owner@db/app
interlock check   --config interlock.toml --database postgresql://interlock_agent@db/app
substrate = "postgres"
schema = "public"
stage_roles = ["interlock_agent"]   # granted the interlock schema's stage functions
acknowledge_cascades = []

[[tables]]
name = "orders"
columns = ["id", "tenant", "total"]
tenant_column = "tenant"
from interlock import EscrowEngine, PlanBuilder, PostgresSubstrate, TableSpec, default_checkers

substrate = PostgresSubstrate(
    "postgresql://interlock_agent@db/app",
    tables=[TableSpec("orders", columns=["id", "tenant", "total"], tenant_column="tenant")],
)
engine = EscrowEngine(substrate, checkers=default_checkers(row_limit=8))
plan = (
    PlanBuilder("support-agent")
    .update(
        table="orders",
        statement="UPDATE orders SET total = %(total)s WHERE id = %(id)s",
        parameters={"total": 100, "id": 1},
    )
    .build()
)
result = engine.execute(plan)

What interlock install puts in the database: an interlock schema holding the stage table and four functions, and on each observed table one AFTER row trigger plus one TRUNCATE trigger, both ENABLE ALWAYS so session_replication_role does not switch them off. For a transaction that opened no stage, the trigger logs the write to interlock.unmediated (see Unrecorded writes). For one that did, it writes before and after images into a temporary table that exists only in that session for that transaction. Rows are keyed to the stage by pg_current_xact_id(), read from a row only the stage-opening function can write, not from the interlock.stage_id setting, which any statement could change; the setting is still set, and the trigger refuses to go on when the two disagree.

Every stage checks, before its first effect and with the observed tables locked ROW EXCLUSIVE so none of it can change underneath:

  • each observed table carries Interlock's triggers, enabled always, capturing exactly its TableSpec's columns, and has no inheritance children or partitions (a statement on the parent writes their rows too, through no trigger, and PostgreSQL checks privileges on the parent alone);
  • the stage's role is not a superuser, owns no observed table (an owner can disable a trigger), and can write no other table, directly, through a column grant, or through any role it belongs to. PostgreSQL has no statement authorizer, so the grant is the table boundary; the substrate checks it rather than trusting it. enforce_table_access=False trusts it;
  • the cascade check, read from pg_constraint.

What differs from SQLite:

  • Placeholders are psycopg's: %(name)s, and %% for a literal percent sign.
  • Only row statements stage: SELECT, INSERT, UPDATE, DELETE, MERGE, WITH, VALUES, TABLE. Each is sent by the extended query protocol, as one unnamed statement, which the server will not split, so UPDATE ...; COMMIT fails instead of committing before adjudication. Nothing is prepared on the server.
  • A cascade gate fires per row. A delete of a gated row, or an update that changes a gated key, is refused inside the statement and PostgreSQL rolls the statement back, cascade included. SQLite refuses when it prepares the statement.
  • NUMERIC is read as Decimal, exactly.
  • Bounds are the server's: statement_timeout, lock_timeout and idle_in_transaction_session_timeout are set for the stage and re-set before every effect, so a statement cannot lift them, and a stage whose client died cannot hold locks past max_stage_seconds.
  • Crash recovery reads the transaction. The stage's row is its commit marker, and pg_current_xact_id() is written into the commit intent, so recovery can tell a transaction the server still has open (a crashed client's, until its session times out, or one still committing when its client died) from one that rolled back, and leaves the first open. A connection lost with COMMIT in flight is answered from the same marker. PostgreSQL stops statement_timeout before running a commit's deferred triggers, so work there is bounded by the stage's lock_timeout and not by its statement bound.

Behind a connection pooler. A stage holds its connection for its whole life, and reads the rate windows on a second, in READ COMMITTED: its own snapshot cannot see what other plans committed after it began. With PgBouncer in transaction mode, give the stage role's pool at least one connection more than [engine] workers: a windows' read then waits only for other windows' reads, never for a commit. A pool no larger is slow, never stuck. The second connection must be had within [engine] pool_timeout_seconds (the lock timeout by default); when it cannot be, the stage is aborted with PoolExhaustedError, a StageConflictError, its locks and its connection released, and the daemon's engine pool stages the plan again. The read is one transaction that sets nothing beyond it, and nothing is prepared, so PgBouncer needs no max_prepared_statements and no client inherits another's session.

What grants cannot see, so the substrate cannot either: a SECURITY DEFINER function the role may call that writes elsewhere, an extension such as dblink that opens another connection, and large objects. Do not grant them to the stage role. Requires PostgreSQL 14 or later; CI runs 16.

Outbound requests: the transactional outbox

An email, a refund through a payment API, a webhook: a plan often has to call something besides its database, and none of those calls can be staged, measured or rolled back. An agent that makes the call itself has made it before anything could check it, which is how an instruction injected through a tool result gets out.

So Interlock does not let the plan make the call. It uses the transactional outbox pattern: the plan enqueues a request for the call, beside its SQL effects, and the request is handled like a row, on PostgreSQL and on SQLite alike (the design, and the proofs, are in docs/OUTBOX_DESIGN.md and docs/EPIC3_DESIGN.md).

  1. Enqueue. PlanBuilder.enqueue() adds the request to the plan. Admission holds it to the operator's SinkRegistry: a registered sink and operation, a payload that matches the operation's JSON Schema and fits the sink's size bound, no field named like a credential, and the compensation the operation registers, if any.
  2. Stage. The stage writes the request to interlock.outbox in its own transaction, through a function gated by a token only the substrate holds. The checkers read it back from there, beside the measured rows. It commits with the rows and the stage marker, or not at all: a refused or failed plan leaves no request behind.
  3. Pay. The plan pays at commit, in the settlement that commits with it: its settle cost and each request's cost_per_call. The hold placed before staging covers both; a refused plan pays only its settle cost, since its requests never commit.
  4. Deliver. interlock relay, a separate process under its own database role and the only holder of the sinks' credentials, reads committed requests and makes the calls, at least once.

Configuring a sink

Sinks are declared in interlock.toml beside the tables. interlock install mirrors them into the database, which then refuses a request for any sink or operation it does not hold. A sink has no URL and no credential here: those belong to the relay.

substrate = "postgres"
stage_roles = ["interlock_agent"]
relay_roles = ["interlock_relay"]     # granted read access and the relay functions, nothing else

[[sinks]]
name = "payments"
cost_per_call = "0.0005"              # a decimal string, charged at commit
idempotency = "header"                # the sink deduplicates on Idempotency-Key; "none" if not
unknown_outcome = "redeliver"         # or "dead-letter": a call that may have acted is not repeated
not_after_seconds = 900               # delivered within this, or not at all
max_attempts = 10
backoff_base_seconds = 1              # doubling, with jitter, up to the cap
backoff_cap_seconds = 600

[[sinks.operations]]
name = "refund"
schema = "schemas/payments-refund.json"   # a JSON Schema subset, relative to this file
compensation = "none-possible"            # or the operation that undoes this one
interlock install --config interlock.toml --database postgresql://owner@db/app

Enqueueing a request, and checking it against its rows

from interlock import (
    BlastRadius,
    CrossEffectAgreement,
    EscrowEngine,
    PlanBuilder,
    PostgresSubstrate,
)
from interlock.config import load_config

config = load_config("interlock.toml")
engine = EscrowEngine(
    PostgresSubstrate("postgresql://interlock_agent@db/app", tables=config.tables),
    checkers=[
        BlastRadius(10),
        # The refund the payment API is asked for is the refund the plan recorded.
        CrossEffectAgreement(
            "payments",
            "refund",
            field="amount",
            table="refunds",
            column="amount",
            key=("order_item", "order_item_id"),
        ),
    ],
    sinks=config.sink_registry(),
)
plan = (
    PlanBuilder("support-agent")
    .insert(
        table="refunds",
        statement=(
            "INSERT INTO refunds (id, order_item_id, amount) VALUES (%(id)s, %(item)s, %(a)s)"
        ),
        parameters={"id": 9100, "item": 5000, "a": "50.00"},
    )
    .enqueue(sink="payments", operation="refund", payload={"order_item": 5000, "amount": "50.00"})
    .build()
)
result = engine.execute(plan)

A row diff alone has a blind spot: every row can be valid and every request well-formed, and the two can still contradict each other. CrossEffectAgreement closes it. If a tool result talks the agent into asking the payment API for 5000.00 while the refund row it inserts says 50.00, the plan is refused before it commits: no row, no request, nothing for the relay to send. Both halves are measured, the request read back from the outbox and the rows from the stage's capture, so the check judges exactly what would be sent and what would be committed. measure="net" holds a request to the change in a balance it moves, and measure="value" to one exact value such as a currency. The rule is strict both ways: a refund call with no refund row, or a refund row with no call, is refused too. The agent is told which rule it broke, never the amounts.

Running the relay

[relay]
key = "relay.key"                                  # this relay's Ed25519 key; or INTERLOCK_RELAY_KEY
database = "postgresql://interlock_relay@db/app"   # a relay role; or INTERLOCK_RELAY_DATABASE
ledger = "postgresql://interlock_relay@db/app"     # AgentGov, read-only, for the breaker
lease_seconds = 60                                 # at least twice timeout_seconds
timeout_seconds = 10
workers = 4

[[relay.endpoints]]
sink = "payments"
url = "https://api.payments.example"
routes = { refund = "POST /v1/refunds" }                    # every operation the sink registers
header_env = { Authorization = "PAYMENTS_AUTHORIZATION" }   # the value is read from the environment
export PAYMENTS_AUTHORIZATION="Bearer ..."     # in the relay's environment, nowhere else
interlock relay --config interlock.toml        # runs until SIGTERM; --once drains and exits

# As the installer: inspect, verify, and act on what the relay could not deliver.
interlock outbox status  --config interlock.toml --database postgresql://owner@db/app
interlock outbox list    --state dead --config interlock.toml --database postgresql://owner@db/app
interlock outbox verify  --config interlock.toml --database postgresql://owner@db/app
interlock outbox release --scope support-agent --key ~/.interlock/alice.key --config interlock.toml ...

Run as many relays as throughput needs, on as many hosts: they share the work through FOR UPDATE SKIP LOCKED and never wait on each other. On SIGTERM a relay finishes the call in hand, records it, and exits. interlock outbox show <message> prints a message's delivery log; requeue sends a dead message back with a fresh budget of attempts, and cancel drops one.

A relay reads AgentGov's ledger and never writes it: over a ledger shared through PostgreSQL, grant its role USAGE on the agentgov schema and SELECT on its tables, nothing more. Over a SQLite ledger, name the file; it is opened read-only. Without AgentGov at all, set breaker = "none", explicitly: a relay never runs without a breaker by default.

What the relay guarantees, each with a test that fails without it:

  • Nothing is sent before its plan commits, and nothing but what was adjudicated. The relay reads committed rows only, and re-hashes the stored payload before every call; a payload that no longer matches is refused, not sent.
  • Every call is recorded before it is made, and its outcome after, in the message's delivery log, interlock.outbox_attempts. The database links each row to the one before it by hash; interlock outbox verify recomputes every link and checks each message's state against its log.
  • A tripped breaker holds. AgentGov's breaker for the message's scope is read immediately before every call; tripped, the message is held, not sent, until an operator releases it. Each call records the ledger position its breaker check read.
  • Relays never share a message. Leases are claimed with FOR UPDATE SKIP LOCKED, and a fence on each lease stops a relay whose lease ran out from acting on the message again.
  • Delivery is at least once, and a duplicate is never silent. Every call carries the same idempotency key. A relay that dies after its call reached the sink and before it recorded the outcome leaves the call to be recorded as lost and made again: a sink that honours the key acts once, one that does not acts twice, and the log shows the lost call the second effect came from. unknown_outcome = "dead-letter" makes such a sink at most once instead. tests/test_relay_crash.py kills relays at every point of this path.
  • Failure is bounded. Retries back off exponentially with deterministic jitter, honour Retry-After, and stop at max_attempts or the request's deadline. A dead request takes the requests waiting for it with it; interlock outbox requeue brings them back.

Stripe and SendGrid

Two sinks are typed: they bring their operations, strict payload schemas of their own, and an adapter that speaks their vendor's API. Configuration names which operations to allow; it does not write their schemas.

[[sinks]]
name = "payments"
type = "stripe"                       # payment_intents.create, charges.create, refunds.create
cost_per_call = "0.30"

[[sinks.operations]]
name = "payment_intents.create"       # takes money, so carries its refund...

[[sinks.operations]]
name = "refunds.create"               # ...which is registered too

[[sinks]]
name = "email"
type = "sendgrid"                     # mail.send; at most once unless unknown_outcome = "redeliver"
cost_per_call = "0.001"

[[sinks.operations]]
name = "mail.send"

[[relay.endpoints]]
sink = "payments"
secret_env = "STRIPE_SECRET_KEY"      # the adapter knows the routes and the headers

[[relay.endpoints]]
sink = "email"
secret_env = "SENDGRID_API_KEY"
  • A Stripe charge carries the refund that undoes exactly it. The refund names the charge by the placeholder {"$bind": "delivered.id"}, never a literal id, so a plan cannot point its undo at another customer's payment; it refunds no more than was charged, and names no currency. The rule is checked at admission and again by the database when the request is written, so an engine configured with a laxer registry cannot stage the charge either.
  • The adapter sends Stripe's form encoding with the request's idempotency key and a pinned Stripe-Version, and records the id of the payment intent it created in the delivery log. A redelivered call is answered by Stripe's idempotent replay: one charge, whatever the relay went through.
  • SendGrid has no idempotency keys, so its sinks are at most once by default: a call whose outcome is unknown is not made again, and the message is dead for an operator to resolve. Every email carries its message id and key in custom_args.
  • tests/test_relay_crash.py drives both through the relay's kill matrix, on both stores: a charge is made once and an email is never sent twice.

On SQLite

The outbox, the relay and every operator command work on a SQLite file as they do on PostgreSQL. interlock install switches the file to WAL and installs the outbox; the relay is interlock relay with the same configuration. SQLite admits one writer at a time, and that is the mechanism: every write is a short BEGIN IMMEDIATE transaction, so claims queue where PostgreSQL's skip, and a relay never holds the lock while it calls a sink. WAL means every process that opens the file runs on one host. SQLite has no roles: the file's permissions are the boundary, and verification is how an edit made around Interlock is found.

Operators: every action signed

Releasing a held request, cancelling one, requeueing a dead one, and compensating a delivered one are a person's decisions about money and messages that leave the system. Each is signed with the operator's own Ed25519 key, in two phases: a signed intent names the targets and the delivery-log head of each, before the database is touched; the database acts under the intent's hash, only at those heads, and refuses an operator's row that carries no such authority; a signed outcome names the rows it wrote. A command killed between the phases is resolved by the next one.

interlock operator keygen --out ~/.interlock/alice.key --name alice   # prints the line below
[operators]
log = "operators.ilok1"               # the signed operator log, beside this file
ledger = "governor.db"                # optional: every record anchored into AgentGov

[operators.keys]                      # public halves only
alice = "ed25519:5f0c..."
export INTERLOCK_OPERATOR_KEY=~/.interlock/alice.key
interlock outbox release   <message> --reason "the breaker tripped on a false alarm" --config ...
interlock outbox compensate <message> --config ...       # the undo the plan carried, bound
interlock outbox compensate --plan <plan> --late --config ...   # a whole plan, in reverse order
interlock outbox verify    --config ...

interlock outbox verify holds every delivery log to the operator log, from public keys alone, and names each edit made around it: an operator row with no authority, an authority no signed intent holds or one replayed on another message, a row written at a head other than the one signed for, an operator log edited or cut short (its signatures, its AgentGov anchors), a sink re-enabled or widened since the last signed interlock install. A database's owner can still edit their own database; they cannot do it unnoticed.

Relays sign every outcome

What a sink answered is recorded only as the relay that heard it signed it: an ARC1 1.1 attestation, with the relay's own Ed25519 key, over the request as the outbox committed it and the outcome as the row records it. The database refuses an outcome without one, and a relay starts only with a key registered for verification:

interlock keygen --role relay --out ~/.interlock/east-1.key --name east-1   # prints the line below
[relays.keys]
east-1 = "ed25519:9a1b..."

interlock outbox verify rebuilds each outcome's attestation from the outbox row and the log row, and names every outcome no registered relay signed: a delivery written around Interlock, linked and hashed perfectly, is named all the same, and so is a genuine row rewritten after it was signed. Outcomes recorded before schema version 4 carry no attestation. They are the legacy set the install that brought version 4 recorded, once, and the signed interlock install vouches for it: a row forged into an old log later is not in it, however early it is dated.

Keys held by a signing service

A relay, the inbox, the vacuum and the receipt log can sign with a key a KMS, an HSM or Vault's transit engine holds: the process keeps the key's name, the version it pinned, the public half and a credential, and never the private key (docs/EPIC8_DESIGN.md §1).

[signers.relay]
type = "http"                       # interlock.signers.HttpRemoteSigner's protocol
url = "https://kms.internal:8200"
key = "interlock-relay"             # the key's name at the service
token_env = "INTERLOCK_KMS_TOKEN"   # its bearer token, read from the environment
timeout_seconds = 5
# version = 3                       # pinned; the service's latest when left out

[relay]
signer = "relay"                    # in place of key = "relay.key"

Every signature the service hands back is verified under the pinned key before it is used: a service that signs with another key or version, or answers with anything but a signature, fails the call, and nothing is attested. sign_async signs off the event loop. Operator commands take --signer NAME in place of --key PATH. An adapter for another service implements RemoteSigner._remote_key and _remote_sign.

Registering, revoking and rotating keys

The operator log is the key registry and its revocation list (docs/EPIC8_DESIGN.md §2). An operator registers a key beside the configuration's; it is trusted from the next record on. Revoking one is an operator action in two phases, like every other. For a relay's or an inbox's key, the database records the revocation, in the transaction of the signed intent, and seals every row the key attested: the reference and hash of each. The applied record signs the seal's count and digest, anchored in AgentGov, and from then on the database refuses every row the key would attest. What the key attested before verifies as it always did, exactly as sealed: a row written around the refusal, or one of its rows rewritten, is named by interlock outbox verify.

interlock keys list --config interlock.toml
interlock keys register --config interlock.toml --key ops.key --role relay --name east-2 \
    --public-of relay                 # the version [signers.relay]'s service serves now
interlock keys revoke --config interlock.toml --key ops.key --role relay 03dd37a92a71a9ba \
    --reason "rotated"

A planned rotation: a new version at the key service; register it; reload the daemon (kill -HUP, or await supervisor.reload()), each part finishing its step and opening again with the key it is given now; then revoke the old one. Nothing is refused on the way. After a compromise, revoke first: a relay finds its key revoked before its next claim, the inbox at its next matching pass, and each opens again with the key its service serves then. An operator's key is revoked by another operator, never its own, and never the last.

Settlement: delivery receipts, and credits for compensations

The process that holds the receipt log and the ledger (the engine's) settles each delivered request once (docs/EPIC4_DESIGN.md §4):

import psycopg

from interlock import Settler

settler = Settler(
    psycopg.connect(SETTLER_DSN, autocommit=True),  # a role in settler_roles
    receipts=issuer,  # the engine's ReceiptIssuer
    chain=engine.chain,
    relays=config.relay_keyring(),
    ledger=governor,  # the engine's AgentGov ledger
    operator_log="operators.ilok1",
    operators=config.operators.keyring(),
    sinks=registry,  # the engine's SinkRegistry, which priced every request
)
report = settler.settle()  # as often as you like
  • A delivery receipt for each delivered request: an ARC1 DeliveryReceipt signed by the receipt log, carrying the relay's attestation, bound to the action receipt of the plan that committed the request by that receipt's leaf hash: the plan its attested idempotency key was derived from, not merely the one its row names. agentgov's verifier checks it: agentgov verify-receipt delivery.json --relay-key ... --action action.json.
  • A credit for a delivered compensation: AgentGov refund() of the cost_per_call the engine's sink registry prices the request at, which is what its plan was charged for it, to the scope the ledger charged; never the money the request moved. Only when a registered relay attested the compensation's delivery; the original's compensated row carries the authority of a signed operator intent that names exactly this compensation, recorded applied; and the outbox row agrees with the registry's price and the compensation's signed key. No column the database's owner can rewrite sets the amount or the scope: the plan's charge caps the credits, and nothing is credited when the row and the registry disagree.
  • Exactly once. Each step finds what a crashed run left of it. Killed after the receipt, the credit, or the settlement row, the next run settles every request once: no receipt twice, no orphaned receipt, no second credit.

Inbound facts: the zero-trust inbox

Vendors answer later. Stripe sends charge.refund.updated days after the refund; SendGrid reports a bounce an hour after the send. An agent has to react, and a webhook is exactly where a prompt injection would ride in: a forged request, or a genuine one whose description field says "refund everything". The inbox turns a webhook into a fact the agent may act on, and nothing else (docs/EPIC5_DESIGN.md §2):

inbox_roles = ["interlock_inbox"]     # PostgreSQL: the inbox's own role

[inbox]
key = "inbox.key"                     # interlock keygen --role inbox
database = "postgresql://interlock_inbox@db/app"
listen = "127.0.0.1:8787"             # plain HTTP: put TLS in front of it

[[inbox.sources]]                     # POST /inbox/stripe
name = "stripe"
kind = "stripe"                       # or "sendgrid", or "http" (Standard Webhooks)
secret_env = "STRIPE_WEBHOOK_SECRET"  # the secret lives in the inbox's environment only

[inbox.keys]                          # what engines verify facts under
main = "ed25519:7d2e..."
interlock keygen --role inbox --out ~/.interlock/inbox.key --name main   # prints [inbox.keys]
STRIPE_WEBHOOK_SECRET=whsec_... interlock inbox serve --config interlock.toml
interlock inbox verify --config interlock.toml
  • Verified, or not recorded. The vendor's signature (Stripe's HMAC, Standard Webhooks' HMAC, SendGrid's ECDSA) is checked over the raw bytes, within a timestamp tolerance, before the body is parsed. A forged, replayed or stale webhook is answered 401 and writes nothing; a retry of a genuine one records nothing twice.
  • Bound to what Interlock sent. An event names what it is about (a refund's id). It is bound to the delivered request whose relay-attested remote_ref is that id: never to a delivery no registered relay attested, never to two messages' deliveries at once. The binding gives the fact its scope and plan.
  • Attested, and chained. Each source's events form a hash-linked log, and the inbox signs every event and every fact with its own Ed25519 key. A fact written, copied or moved around the inbox does not verify, and interlock inbox verify names it.
  • Typed. A fact carries a projection: ids, codes, integers, decimals, currencies, booleans, instants. No type admits free text, so a vendor's description, metadata or error message never reaches the agent. A value that does not fit is withheld, by name.

An engine consumes facts, never webhooks:

from interlock import EscrowEngine, FactAgreement, PlanBuilder

engine = EscrowEngine(
    substrate,
    checkers=[
        # A refund is marked failed only on the vendor's word that it failed.
        FactAgreement(
            "charge.refund.updated",
            field="status",
            table="refunds",
            column="status",
            exempt=["pending"],
        ),
    ],
    inbox=config.inbox_keyring(),
)
(fact,) = engine.facts("support-agent")  # pending, attested, this scope's
plan = (
    PlanBuilder("support-agent")
    .consume(fact)
    .update(
        table="refunds",
        statement="UPDATE refunds SET status = %(status)s WHERE id = 9000",
        parameters={"status": fact.fields["status"]},
    )
    .build()
)
engine.execute(plan)

A fact is consumed in the plan's own stage, exactly when it commits, once: a second plan naming it is refused, and a refused plan consumes nothing. The stage reads the fact back for the diff, where it is verified again, and the receipt commits to it. No statement of a plan can read the inbox. FactAgreement makes the reaction answer to the fact: a plan that writes a status no consumed fact says is refused, and the agent is told the rule, never the values.

Compaction: the vacuum

The outbox, window history and the inbox only grow. interlock vacuum prunes what no check will read again, and keeps it provable (docs/EPIC5_DESIGN.md §1):

[vacuum]
retain_days = 30                      # a delivered request can be compensated this long
margin_seconds = 3600                 # window history stays this long past the longest span
archive = "archive"                   # optional: the pruned rows, written first, provable
interlock vacuum --config interlock.toml --key ~/.interlock/alice.key --dry-run
interlock vacuum --config interlock.toml --key ~/.interlock/alice.key --reason monthly
interlock vacuum --config interlock.toml --verify-archive archive/checkpoint-1.jsonl

It verifies everything first: delivery logs, relay attestations, operator records, earlier checkpoints, and the inbox under [inbox.keys]. What does not verify is kept and named. What may go: stages whose every request is delivered and settled, or cancelled, past the retention; window history past the longest span; each inbound source's prefix of events past the retention whose facts were all consumed. A checkpoint commits to all of it (each pruned message's log head and settlement, folds over everything pruned, where each inbound log was cut) and is signed in an operator intent and anchored into AgentGov before a row is deleted. One database transaction then records it, recomputes every fold from what the database holds, and deletes; the database refuses any other delete. Afterwards every verifier passes, accounting for pruned history by its tombstones and cuts, and a window that would reach back past pruned history fails closed.

The daemon: every part in one process

interlock daemon runs the engines that execute your agents' plans beside the relays, the inbox, the settler and the vacuum: one process, one asyncio event loop, every database call on a thread of its own part's (docs/EPIC6_DESIGN.md). Each part connects as its own role, from the sections interlock.toml already has, and these:

[engine]                              # the engines executing agents' plans
workers = 4                           # plans staged at once (SQLite: one)
database = "postgresql://interlock_agent@db/app"   # the stage role
chain = "escrow.chain"                # worker n writes escrow-<n>.chain
settle_cost = "0.01"
ledger = "postgresql://owner@db/app"  # AgentGov in the same database...
same_transaction = true               # ...settles each plan with its commit
conflict_retries = 16                 # a plan that lost a race is staged again

[receipts]                            # one receipt log, the engines' and the settler's
log = "receipts.jsonl"
key = "receipts.key"

[settler]
database = "postgresql://interlock_settle@db/app"
every_seconds = 5

[vacuum]                              # the daemon's own vacuums
every_seconds = 300
key = "vacuum.key"                    # an operator's key, in [operators.keys]
database = "postgresql://owner@db/app"

[daemon]
drain_timeout_seconds = 30            # each shutdown step's bound

[metrics]                             # Prometheus, on a listener of its own
listen = "127.0.0.1:9464"             # GET /metrics and /healthz: a private address
every_seconds = 15                    # how often the database is sampled
database = "postgresql://interlock_audit@db/app"   # an audit_roles role

Your agents and their policy come in as an application: a function that takes the configuration and returns an Application of checkers and agents. An agent is a coroutine taking an AgentContext, or a plain function, run on a thread of its own:

from interlock import AgentContext, Application, BlastRadius, TenantIsolation


async def support(ctx: AgentContext) -> None:
    while not ctx.stopping:
        for fact in await ctx.facts("support-agent"):
            plan = ctx.plan("support-agent", intent=f"react to {fact.kind}")
            ...  # .consume(fact), then the writes it justifies
            await ctx.execute(plan.build())
        await ctx.sleep(1.0)


def build(config: object) -> Application:
    return Application(checkers=[BlastRadius(50), TenantIsolation()], agents=[support])
interlock daemon --config interlock.toml --app myapp.agents:build

A plan that loses a race for a row or a window key is staged again, with jittered backoff. A part that fails is reopened after a backoff that doubles. SIGHUP reloads it: the relays and the inbox finish their step and open again, signing with the key their configuration points at then, the inbox behind a listener that never closes; the vacuum opens its key at each run anyway; engines and agents run on. GET /healthz on the inbox's port answers 200 while every part runs, 503 otherwise, with each part's counters. SIGTERM stops it in order, every step bounded: the agents; the engines (queued plans run until the deadline, and a running stage always finishes); the inbox, then one last match; the relays; the settler, one last pass; a vacuum in progress. A second signal skips the drains and gives the parts a second to close, and the next start recovers as after a crash. Without --app, the daemon runs the relays, the inbox and the vacuum.

In a process of your own, build_supervisor(config, application) returns the same InterlockSupervisor, to run on your event loop.

Several daemons: a cluster

Run three to five daemons over one database and one AgentGov ledger, each in a pod of its own, and any of them can die at any moment (docs/EPIC9_DESIGN.md). Each is a node:

[cluster]                             # PostgreSQL only
node = "interlock-0"                  # or INTERLOCK_NODE, or interlock daemon --node
database = "postgresql://interlock_cluster@db/app"   # direct, or a session-mode pool
heartbeat_seconds = 1
session_timeout_seconds = 10          # a node silent this long holds nothing more

A node's name is 40 characters at most (letters, digits, ., _, -), and database is the database Interlock is installed in: a node's lock is that database's own, where the relays look for it, and a node refuses to join one without Interlock's storage. Its role needs no privilege for the locks; to fence (below) it needs pg_signal_backend, and the parts' roles must not be superusers, whose sessions only a superuser can end:

CREATE ROLE interlock_cluster LOGIN PASSWORD '...';
GRANT pg_signal_backend TO interlock_cluster;

A node holds its node's lock in PostgreSQL, a session-level advisory lock, for as long as its process lives, so the database alone decides when a node is gone: when it ends the node's session, at once for a process killed, after session_timeout_seconds for one frozen or lost to the network. Every node runs the engines, the relays, the inbox and the metrics endpoint. The vacuum, and the inbox's matching of webhooks that came before their delivery was recorded, run on one node at a time: the one leading their role, an advisory lock on the same session, which another node takes at its next ask once the leader is gone. Each node settles the deliveries of the plans its own engines committed, into its own receipt log, since a delivery receipt goes into the log that holds its plan's action receipt: no two nodes ever settle one message.

A relay's lease names its node (storage version 8: interlock install upgrades version 7 in place, and a relay of version 7 left running keeps claiming). When a node is gone, the next claim of any relay takes its leases over, its calls in flight made again under the same idempotency key, without waiting for the leases to run out. In a cluster every session that holds what others wait for, the relays', the inbox's and every AgentGov governor's, is bounded by session_timeout_seconds, and every connection a node opens says whose it is in pg_stat_activity: interlock-<part>@<node>.

A node gone is fenced: at each heartbeat a node ends every session of a node whose lock no session holds, found by its connections' names. A frozen node, or one lost to the network, holds nothing past its session timeout and a heartbeat: not its transactions, and not a lock it was waiting for, which it would otherwise take and sit on for a bound of its own, one wait after another. A node that may not end them says so once in its log, and they end at their own bounds; interlock_cluster_fenced_total counts the sessions a node ended.

In Kubernetes: a StatefulSet, with INTERLOCK_NODE from the pod's name; a volume per pod for its escrow chains and its receipt log ([receipts] log_id unique per pod); the operator log on a ReadWriteMany volume every pod mounts, whose file locks work; /healthz for readiness and liveness, a node on standby for a role being healthy; and the node's session reaching PostgreSQL directly or through a pool in session mode, never in transaction mode. interlock daemon exits 3 when its node runs already, or when another process took its name while it ran.

Metrics

With [metrics] listen set, or interlock daemon --metrics HOST:PORT, the daemon serves Prometheus's text format on a port of its own, never the inbox's, which faces vendors: GET /metrics, and GET /healthz. Each part measures what it does: the engines, every plan by outcome and time, the races lost (to a row's lock or to the pool), the waits for window keys' locks and for the windows' connection, and the longest of each in the last minute; the relays, every call by sink and outcome; the settler, each delivery receipt's lag behind its delivery; the inbox, every webhook by answer and by the trace context it carried; the vacuum, its runs and what they pruned; every part, its state, steps and failures. What only the database knows is sampled every every_seconds, in one read-only snapshot, as the database role: the outbox's depth by state and its oldest message due, settlement's backlog, the facts no plan has consumed, and each rate window's fullest key against its limit (interlock_window_saturation). A scrape reads memory and never the database. No label is a tenant, a scope, a plan or a key, so the number of series is set by the configuration, never by the traffic. interlock.telemetry.CATALOG lists every metric; docs/EPIC7_DESIGN.md §2 says what each measures. The registry costs a plan under two microseconds (scripts/metrics_overhead.py).

The endpoint is not authenticated: bind it to an address only your scraper reaches. It shows the system's shape, never its data.

Trace context

A plan may carry a W3C traceparent, and Interlock carries it wherever the plan's effects go: beside each request in the outbox; out as the traceparent header of every call the relay makes (HttpAdapter, Stripe's and SendGrid's adapters), retries included; onto the refund an operator issues to compensate a charge. A webhook whose signature verifies has the context it carried kept beside its event, and the fact the inbox binds to a delivery continues the delivery's trace: under the webhook's context when the vendor continued ours, and the delivery's own when it sent a trace of its own, a malformed one, or none. A plan that consumes the fact continues it, and agent, outbox, relay, vendor, webhook and agent again are one trace:

from interlock import PlanBuilder, child_traceparent, new_traceparent, parse_traceparent

checkout = new_traceparent()  # or your tracing library's current context
plan = (
    PlanBuilder("support-agent", intent="charge order 7", traceparent=checkout)
    .update(
        table="orders",
        statement="UPDATE orders SET status = :status WHERE id = :id",
        parameters={"status": "charging", "id": 7},
    )
    .build()
)
assert plan.traceparent == checkout

# What arrives over HTTP is read as W3C asks a receiver to: malformed is None.
assert parse_traceparent("not a traceparent") is None

# A plan consuming a fact bound to the charge: a span of its own, in the same trace.
follow_up = child_traceparent(checkout)
assert follow_up.split("-")[1] == checkout.split("-")[1] and follow_up != checkout

In the daemon, ctx.plan(scope, traceparent=...) does the same, and each fact from ctx.facts(scope) has its traceparent. Trace context is outside every hash: a plan, a request, a delivery log, a relay's attestation, a receipt, an event and a fact hash the same with it or without it, and nothing Interlock decides reads it. It is kept in tables of its own, pruned by the vacuum with the rows it describes. Interlock propagates context and records it; it exports no spans of its own.

The soak

scripts/live_stress_test.py runs a cluster of daemons for minutes against a live PostgreSQL, in a container it starts or on a server you name:

uv run python scripts/live_stress_test.py --docker --minutes 5
uv run python scripts/live_stress_test.py --dsn postgresql://admin:pw@localhost:5432/postgres

It runs --nodes daemons, three by default, each interlock daemon --node in a process of its own, over one database and one ledger. On every node, agents keep plans in flight: checkouts that insert an order and charge for it, carrying the refund that undoes it; reconciliations of what the payment API's webhooks say; notes on rows every node contends for; and, now and then, a plan that must be refused. A fake payment API answers in Stripe's shape, honours idempotency keys, and fails on purpose: 500s after acting, 429s, replies slower than the relay waits. Its webhooks come back signed, duplicated, early, about objects nobody created, and forged. An operator refunds paid orders, every action signed. Webhooks reach the nodes through a balancer that takes a node out when it stops answering. At 45% of the load the node leading the vacuum is killed with SIGKILL while its relays have a call in flight; at 70% the node leading it then is frozen with SIGSTOP with a call in flight and a stage open, its connections left open as a host lost to the network leaves them, and killed once the server and the other nodes have ended everything it held. Each comes back, as a pod is rescheduled, and recovers. Then the soak proves, from the database, the ledger, the logs and each node's journal of what its agents did: no deadlock; lock waits within their bounds and every lost race retried to an outcome; every rate window within its limit at every instant of its history; the ledger balanced to the cent, each plan charged once and each refund credited once; one charge per order and one receipt per delivery, but for a plan whose node died before issuing its action receipt; no forgery accepted; the vacuum keeping the outbox bounded as it runs; every verifier passing after; a graceful stop, a second daemon recovering nothing; every checkout's trace context carried to the payment API, onto its refund, back on the facts its webhooks became and on to the plans that consumed them, whatever the vendor sent; the metrics, scraped every second, telling what the run counted itself; keys that rotate under load; every node sharing the work; one node leading each role at a time; the killed node's lock gone with it, its leases taken over within a second and its call in flight made again and acted on once; the frozen node's leases taken only once its node was gone, every session it had fenced a heartbeat after, and no survivor waiting on one of them longer. The relays, the inbox and the vacuum sign through a key service in the soak's process, and a quarter of the way through, the operator rotates the relays' key: a new version, registered; every node reloaded with SIGHUP; the old key revoked while a stale relay still holding it has a call in flight. The database refuses that relay's outcome, another relay delivers its message once under the new key, and everything the old key attested before verifies under its seal. tests/test_soak.py runs it for a minute and a half in the test suite.

The security audit

scripts/security_audit.py asks whether any sensitive value reaches a hash Interlock commits to (docs/EPIC8_DESIGN.md §4). On each store it runs one scenario through the daemon with canaries planted in a plan's trace context, the relay's API key, a webhook's body and headers, and its signing secret, and records the input of every SHA-256 and every signature Interlock and AgentGov compute. None may hold a sensitive value or its digest, but for the body's SHA-256, which the inbound event commits to; and only the columns made for one may hold one. It prints, column by column, what holds a sensitive value and which hash reads it:

uv run python scripts/security_audit.py --docker

The customer acceptance matrix

demo/customer_acceptance_suite.py puts a live Claude model in front of four unrelated business domains on SQLite: accounts-payable disbursements on a double-entry ledger, cluster operations, a multi-tenant SaaS admin console, and fulfilment in dependent stages for a third-party warehouse. Each has its own schema, triggers, constraints and simulated outside APIs, and each policy is built by one function from the same checkers; only table and column names and limits differ. The model is given each domain's everyday request, requests that conflict with its rules (another tenant's rows, a cap, a table outside its remit, an append-only log), and conflicting figures, and everything it proposes goes through PlanBuilder and EscrowEngine.execute. Oracles that do not ask Interlock check every outcome: other tenants' rows byte for byte, each committed request against its rows, the domain's own invariants, delivery order and count, and that nothing shown to the model names another tenant's data. Reference proposals, one per policy dimension in every domain, run the same tool with no model, in CI (tests/test_acceptance_suite.py). The last live run is recorded in docs/CUSTOMER_ACCEPTANCE_MATRIX.md, with every tool call the first model named made, exactly as it made it.

uv run python demo/customer_acceptance_suite.py --offline    # the references; no model
BENCHMARK_API_KEY=sk-ant-... uv run python demo/customer_acceptance_suite.py \
    --matrix docs/CUSTOMER_ACCEPTANCE_MATRIX.md

Unrecorded writes

The monitor only sees what goes through it. A cron job, a migration, a DBA at a prompt, or the agent's own credentials used directly all write observed tables and leave nothing in the escrow chain. interlock reconcile-effects is the check that closes the loop: nothing changes an observed table without a record.

interlock install           --config interlock.toml   # once; SQLite needs it for this too
interlock reconcile-effects --config interlock.toml --chain escrow.jsonl [--after N]

It fails (exit 1) on:

  • an unmediated write: a row change no stage made. On PostgreSQL the installed trigger logs each one to interlock.unmediated inside the writer's own transaction, with the session user, application_name and transaction id; a TRUNCATE is logged too. On SQLite, interlock install adds permanent journal triggers that record every row change in _interlock_journal, and each committed stage records the range of journal rows it produced, which is exact because BEGIN IMMEDIATE admits one writer at a time. A cascade from a table nobody observes is found row by row;
  • an unrecorded stage: one the database committed that no chain records, such as one opened by hand with the stage role's credentials, or by an engine on the default in-memory chain;
  • a contradicted stage: recorded as aborted, or under another plan;
  • an unresolved stage: committed with only its commit intent in the chain, after a crash in the commit window. Recovery resolves it; EscrowRuntime runs recovery at startup;
  • on SQLite, an observed table whose journal trigger is missing.

Each run prints last entry: N; pass it back as --after N to check only what is new. Exit code 5 means a chain failed verification and proves nothing. On PostgreSQL, run it as a role named in audit_roles at install, which may read Interlock's logs and write nothing.

What it cannot see: anyone who can disable the triggers or edit the logs. On PostgreSQL that is the tables' owner or a superuser, which logical decoding would close; on SQLite it is anyone who can write the file.

Scope

Two substrates: SQLite and PostgreSQL. One connector at the row-level-diff standard is worth more than several reporting only row counts, because a row count is the summary this design exists to replace.

What this is not yet a boundary against

Read this before pointing it at a production database. Each item below was reproduced against the shipped code, not inferred.

  • Writes outside TableSpec are denied, by SQLite, at prepare time. Effect.target is a label the agent writes and is never compared against the statement, so it cannot be the boundary. SqliteSubstrate installs a sqlite3.Connection.set_authorizer callback that denies INSERT/UPDATE/DELETE on any table not in TableSpec, plus ATTACH/DETACH. A statement whose label claims an observed table while writing elsewhere never executes; it raises ForbiddenStatementError naming the table it reached for. Pass enforce_table_access=False to lift the unobserved-table rule for a migration; the rules below stay on.

    The same callback refuses a statement that writes the capture table (which would erase the measurement), the commit-marker table, or transaction control: a COMMIT inside a stage used to make every effect before it durable before any checker ran. Grant the connection exactly the observed tables anyway; a database-enforced grant is a boundary, and this is a second one in front of it.

    DDL and transaction control are refused twice over: SqliteSubstrate.reject_reason() reads the statement's leading verb at admission, so ALTER/DROP/CREATE/TRUNCATE /VACUUM/ATTACH/PRAGMA/COMMIT/SAVEPOINT and friends are rejected whatever Effect.kind claims.

  • A foreign-key cascade into an unobserved table is refused unless you acknowledge it. A DELETE on orders that cascades into an unobserved order_notes used to destroy those notes unmeasured. Every stage now reads the foreign-key graph under its write lock and refuses, before a row changes, a delete or a referenced-key update whose CASCADE, SET NULL or SET DEFAULT actions reach an unobserved table, naming the path. The analysis follows chains of actions to any depth and is column-precise: updating orders.status is never refused because of a key on orders.id. EscrowRuntime runs the same check at startup and logs every gated operation. To let a cascade run unmeasured, name the table in acknowledge_cascades=[...]; every stage that runs with that gap says so in its STAGE_OPENED record. The refusal is enforced three ways, so no single layer is load-bearing: the authorizer refuses the parent operation, the authorizer refuses any foreign-key action SQLite compiles into the table (which is what catches INSERT OR REPLACE), and temporary triggers on the table abort any row change there while the stage is open.

  • One engine stages one substrate. A plan whose effects name another substrate is refused at admission; nothing coordinates two substrates, so a plan spanning SQL and a non-transactional sink needs the outbox pattern below.

  • blast_radius counts captured mutations, not distinct rows. A row mutated twice in one plan contributes 2, so the limit is conservative and is not a row tally.

  • ColumnValueGuard and TenantDrawdownGuard are one-directional. Both fire on a fall in the column total. An inflation passes, and so does any diff whose pre-image total is zero, which includes every insert-only plan. TenantDrawdownGuard re-denominates per tenant; it does not make the guard symmetric.

  • StatedFootprint is a cooperative diagnostic. It reads Effect.stated_rows. An agent that omits the field on any effect turns the checker into a no-op.

  • Chain durability is opt-in. With no chain_path the chain lives in memory and dies with the process. With one, the intent is write-ahead and a crashed commit is resolved exactly on restart; see Lifecycle. The _interlock_commits table gains one row per committed stage and is not pruned, not even by the vacuum: recovery and reconcile-effects read it.

  • The chain is keyless. Anyone who can write the chain file can recompute a SHA-256 chain from start to finish and it will verify. A reverse anchor in a governed AgentGov is one copy of the head outside the file. With receipts on, each adjudicated plan also gets a signed receipt naming its terminal record, under a key the chain file does not hold; the chain itself stays unsigned.

  • Lock footprint. A stage holds write locks for its whole life, a repair search's included. max_stage_seconds bounds it. Human review must not happen inside an open stage; abort, present the recorded diff, and re-stage on approval, because the substrate may have moved.

  • Recovery trusts the harness to route its calls. The runtime holds and records its own steps, refuses a revoked tool when asked, and catches an edited transcript at the next step. It cannot see a harness that bills a recovery call to another scope, runs a tool without calling check_tool(), or sends the model something other than step.messages.

  • An extension estimate is arithmetic on what the harness declares. Cost per declared milestone times those remaining assumes the rest of the work costs what the done work did; with none declared it prices only the call that did not fit. The receipts in a quote's proof of work are verifiable; its milestones are the harness's claim.

  • Latency. Staging roughly doubles write-path round trips. Plans below a blast-radius threshold should bypass escrow entirely.

  • External effects get a weaker guarantee than rows, by design. The guarantee for external effects is deliberately weaker, and stated as such: the request is measured, adjudicated and made durable exactly when the rest of the plan is; its delivery is at-least-once, recorded, and bounded; what the external system then does is not measured at all. What it reports back arrives as an inbound fact: the vendor's signed word, bound to the request, never a measurement. See Outbound requests. On SQLite the outbox is installed by interlock install, as on PostgreSQL.

  • Every threshold in default_checkers is a placeholder. They are uncalibrated. Measure your own diffs and set them from the measurement.

  • A node's deliveries wait for it. In a cluster each node settles its own plans' deliveries, from its own chains and receipt log: a node that never comes back leaves them unsettled until its volume is given to another process as that node. Its payments are made, its webhooks recorded and its facts consumed by the rest of the cluster meanwhile.

docs/ESCROW_SPEC.md is the interface contract, the architecture built on it (§5: components, data flows, roles, evidence, concurrency), and a conformance section listing implemented, partial and unimplemented requirements.

Install

agentgov comes from PyPI (interlock-agentgov>=0.4.0, ARC1 1.1), so a clone needs nothing beside it:

git clone https://github.com/crimsondevil0929/interlock
cd interlock && uv sync

Or install the package directly:

uv pip install git+https://github.com/crimsondevil0929/interlock

Signing, a relay's attestations and an operator's records, needs the sign extra (cryptography); verifying never does.

Developing on both repos at once. uv sync resolves agentgov from PyPI, and a sibling ../agentgov checkout is not consulted on its own. To point uv at one, add to interlock's pyproject.toml:

[tool.uv.sources]
interlock-agentgov = { path = "../agentgov", editable = true }

then uv lock && uv sync. It is workspace configuration, not written into the built wheel. Drop it, and rerun uv lock, before merging: CI resolves a clean clone from the lockfile, with no sibling checkout, and fails on a path source.

Development

uv sync
uv run pytest -q
uv run ruff check . && uv run ruff format --check .
uv run mypy src/ && uv run mypy --strict tests/ demo.py

The PostgreSQL tests run against a live server: set INTERLOCK_TEST_POSTGRES_DSN to a role that may create databases and roles, and INTERLOCK_REQUIRE_POSTGRES=1 to fail rather than skip without one. Two suites are release gates. tests/test_crash_consistency.py kills a real process with SIGKILL at every step of a PostgreSQL commit, and at random instants, then checks exactly what recovery makes of it. tests/test_tamper_evidence.py alters every part of the audit trail and checks that verification fails exactly there. INTERLOCK_CRASH_SEED and INTERLOCK_CRASH_ROUNDS replay or lengthen the random-instant run.

Apache-2.0.

Metadata

Release files for interlock-escrow 1.0.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for interlock-escrow 1.0.1
File Size Uploaded
interlock_escrow-1.0.1.tar.gz 1.4 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for interlock-escrow 1.0.1
File Interpreter ABI Platform
interlock_escrow-1.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 1.9 MB

Release files / interlock_escrow-1.0.1.tar.gz

Download URL interlock_escrow-1.0.1.tar.gz
Size 1.4 MB
Tags Source
SHA-256 checksum
How to use checksums
d4e33d5464a452046fe5c3f3466f66f28c182ffd27a189d0d201610630c083c8
BLAKE2b-256 checksum
How to use checksums
2e435a535a5204782a0d9192e2f90ec24e655b37a2e05d6a3f561a4e82edc32d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / interlock_escrow-1.0.1-py3-none-any.whl

Download URL interlock_escrow-1.0.1-py3-none-any.whl
Size 477.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6960567a208b02da094452ba83a4713c942987c7f5514ecdaa5bec98a76ecf4f
BLAKE2b-256 checksum
How to use checksums
81e0ed4c65407c898adebff515001867fb53f3e77efc636ba5748b62f0e64211
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

1.0.1 This release

2 release files

1.0.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page