Skip to content
Browse the documentation
Reference

The sandbox and the failure matrix

How CommitLayer is tested without customers: a small enterprise of fake systems, a scripted or model-driven agent, injected failures, and an oracle that scores every verdict.

Last changed Markdown versionllms.txt

sandbox/ is how CommitLayer is tested without customers. It builds a small enterprise, lets an agent act in it under injected failures, and scores CommitLayer's verdict against an oracle that reads the systems' databases directly. CommitLayer is what is being measured; the agent is a scripted stand-in until a model key is configured.

.venv/bin/python -m sandbox                      # full matrix → sandbox/results/latest.{json,md}
.venv/bin/python -m sandbox --only refund/       # one workflow
.venv/bin/python -m pytest tests/test_sandbox.py # the regression subset (14 scenarios)

What is in it

Piece File Notes
Seven systems of record sandbox/systems.py payments, orders, crm, tickets, ledger, platform, broker. Each has its own SQLite file, an HTTP API the agent's tools call, and an append-only event log a read-only connector would pull (GET /{system}/events). A virtual clock places every scenario 20 minutes in the past so the 10-minute settlement window is closed at verification time. Writes honour Idempotency-Key.
Failure injection sandbox/chaos.py An httpx transport between the agent (and the extractor) and the systems. Modes: lost_response (commit, then time out), phantom_success (200, no write), error (403 / 429 / 500), unreachable, stale_read (connector sees a snapshot), before (a concurrent actor changes state first), rewrite (tool output altered).
Scripted agent sandbox/agent.py Ordinary tools (refund, update_order_status, add_note, close_ticket, send_payout, …) and behaviour profiles: retry without idempotency key, retry with one, stop on error, claim success on error, follow instructions found in ticket text.
Extractor sandbox/extract.py Pulls each system's events over HTTP through the same chaos layer and turns them into CommitLayer facts. Reports systems it could not reach.
Eight action contracts sandbox/contracts/*.yaml refund customer, cancel subscription, change shipping address, approve discount, close support case, send payout, deploy service, place a trade. The payout, deploy and trade contracts use the checks that look beyond one action: a per-agent total over a window (aggregate_limits), a value compared with the request (effect_attr_differs, payout and trade), and a later record that undoes the action (reversed_by: a returned payout, a rollback, a busted fill).
Scenarios and oracle sandbox/scenarios.py 112 scenarios: 11 injected failures × 8 workflows plus 24 workflow-specific cases (over limit, ineligible, wrong customer, concurrent human refund, prompt injection, forbidden side effect, frozen account, daily total exceeded, amount mismatch, payout returned after settlement, degraded service, wrong environment, too many deploys, unhealthy after deploy, deploy rolled back after settlement, halted product, wrong product, order not filled, fill busted after settlement). A scenario may run earlier actions by the same agent first (prior_runs) and inject something after the verdict settled (after_settlement). The oracle reads the databases and never looks at CommitLayer.
Harness sandbox/harness.py Fresh enterprise and fresh tenant per scenario; record → extract → verify → compare; writes results/latest.md.

Results (run of 2026-09-28)

Scenarios: 62 · policy-lens agreement with the oracle: 62/62 · outcome-lens agreement: 62/62

Metric Value
True failures detected 35/35
False negatives (verifier PASS, truth FAIL) 0
False positives (verifier FAIL, truth PASS) 0 of 22 true passes
UNKNOWN reported when the truth is unknowable (required system down) 5/5
Agent claimed success falsely — caught 35/35
Duplicate effects detected (retry without idempotency, human refunded first, retry twice) 9/9
Provisional verdicts corrected by the settlement-window re-check (stale connector reads) 5/5
Effects on a subject with no recorded action (wrong customer, prompt injection) 2/2 flagged, 0 false alarms
Verify latency, mean / max 16 ms / 68 ms

The per-scenario table is in sandbox/results/latest.md.

What the matrix changed in the engine

The first run scored 50/62. Both gaps were real and are fixed:

  1. "No structured signal" must not apply to action contracts. Twelve scenarios where the agent claimed success and nothing happened (403 on the write, no tool calls at all, wrong customer, injected instruction) came back UNKNOWN. The outcome-billing engine treats "none of the relevant record types were observed" as unverifiable, which is right when a vendor's claim may simply be invisible to the connected sources. For an action the agent asserted, with the sources connected and the subject known from its pre-action state, no effect at all is a FAIL that names every missing effect. Action contracts now skip that fallback (compiler.py).
  2. Orphan effects. When the agent acts on the wrong record, the reported action correctly FAILs (its effects are missing) but the damage happened somewhere else. GET /v1/actions/orphans?contract_id= lists effects of the kinds the contract expects or forbids on subjects that have no recorded action in the window. Both wrong-subject scenarios are flagged; the other 60 produce no false alarm.

A third finding was about the sandbox itself: three systems did not honour idempotency keys, so an idempotent retry still duplicated. CommitLayer reported the duplicate correctly; the systems were fixed to behave like real ones.

Limits of this evidence

  • The agent is scripted. Its failure behaviours are the ones models are known to have, but a model may produce others. Swap in a real agent by giving Agent an LLM-driven tool loop; the harness does not change.
  • Coverage is by "source ever reported", so in a workspace that has run the sandbox before, a system that is down at verification time would otherwise count as connected and its missing effects read FAIL. The harness therefore passes the systems the extractor could not reach to the verifier, which treats them as not connected for that verification (as a connector in error is), and the verdict is UNKNOWN; the re-check at the end of the window reads them again. The endpoint test runs a baseline and then the unreachable scenario in one workspace.
  • Stale connector reads are only corrected because the settlement window re-check exists. A verdict read before the window closes is provisional by design.
  • No comparison against trace-based evaluators has been run. The scenario set is built so the same agent can be run through them: phantom_success, permission_denied and agent_claims_success_no_calls are the cases where the agent's trace looks correct and only the systems of record disagree.

Running the payments system against Stripe test mode

By default the payments system is an in-memory fake that does exactly what the sandbox tells it. The refund scenarios can instead run against Stripe test mode, so the verdicts are scored on a real payment API's objects rather than on the sandbox's imitation of one (sandbox/stripe_payments.py).

export STRIPE_TEST_SECRET_KEY=sk_test_...        # a Stripe *test* secret key; never put it in a file
.venv/bin/python -m sandbox --payments stripe    # refund scenarios only → sandbox/results/latest_stripe.{json,md}
.venv/bin/python -m pytest tests/test_sandbox_stripe.py   # the same code against an in-process fake Stripe; no key

What is real when --payments stripe is set:

  • The objects. Seeding creates one confirmed PaymentIntent per paid or shipped order (pm_card_visa, amount in cents, metadata[order_id]); the agent's POST /payments/refunds creates a Stripe refund against it. The refund_created facts carry Stripe's refund id (re_…), amount (converted back to major units), currency and status.
  • Idempotency. The agent's Idempotency-Key is forwarded to Stripe, which decides what a replay is; the sandbox records the key locally so the existing replay behaviour is unchanged. A retry without a key after a lost response asks Stripe for a second refund: for a partial refund Stripe makes a duplicate, for a full one it refuses because the charge is spent, so that scenario ends in a tool error rather than a duplicate. Both are real outcomes, and the oracle and the verifier agree on them either way.
  • Reconciliation. Before answering, GET /payments/events lists Stripe's refunds for every seeded PaymentIntent and appends a refund_created fact for any it has not seen, keyed by refund id. A refund made in the Stripe dashboard shows up on the next pull as a system-of-record fact, exactly as a connector would report it. A refund is appended once and keeps its sequence number, so the stale_read chaos mode still works.

What is not real:

  • Timestamps. Every fact is placed on the sandbox's virtual clock (one second per write), not on Stripe's created, so a scenario still sits 20 minutes in the past and the settlement window closes deterministically.
  • Payouts and billing stops. /payments/payouts and /payments/billing/stop stay on the local fake behaviour; nothing is sent to Stripe for them. That is why the matrix runs only the refund workflow in this mode and says so up front rather than failing midway.
  • The concurrent human refund. refund/concurrent_update plants its first refund on the sandbox side (the scenario is shared with the fake); the agent's duplicate is the Stripe one.

The key is read from STRIPE_TEST_SECRET_KEY only, is never written anywhere, and must start with sk_test_: anything else is refused before a single call is made. The results carry payments_backend so a JSON or markdown file says which backend produced its numbers.

First run (2026-10-13). 16 refund scenarios against Stripe test mode, scripted agent, verifier 0.2.4: 16/16 agreement with the oracle, 11/11 real failures detected, 0 false positives, the stale read corrected at re-check, the wrong-customer refund flagged as an orphan. Stripe's refusal of a second full refund turns the three duplicate scenarios into missing-downstream-effect failures, and its refusal of a refund above the charge stops the injected 999 refund; both are documented in docs/benchmark.md. Results in sandbox/results/stripe_scripted.{json,md}.

Real-agent benchmark (Claude, OpenAI, Gemini)

sandbox/llm/ swaps the scripted agent for a model driving the same tools through a provider adapter (anthropic_agent.py, openai_agent.py, google_agent.py; fake_agent.py for CI). The model sees the task and the tool results — including timeouts, 403s and injected ticket text — and decides for itself whether to retry, whether to use an idempotency key, and what to report. 46 of the 62 scenarios apply (the four that script a specific agent behaviour are skipped). Two baselines run alongside CommitLayer:

  • Trace-based judge (llm/judge.py): a fixed Claude model that sees only the trajectory (calls, arguments, responses, the agent's own report) — what a trace/observability evaluator sees — and never the systems of record.
  • Braintrust / LangSmith sinks (llm/tracing.py): every trajectory is logged with the oracle truth as the expected value and CommitLayer's verdict as a score, so the same runs can be inspected and re-scored in those platforms.

Run locally (keys in the environment):

.venv/bin/python -m sandbox --agent llm --provider anthropic --judge --sinks
.venv/bin/python -m sandbox --agent llm --provider openai --model gpt-5-mini --judge
.venv/bin/python -m sandbox --agent llm --provider google --model gemini-2.5-flash --judge

Run in production without handling keys (founder only; the keys stay in the Railway environment):

POST /v1/admin/benchmark   {"provider": "anthropic", "judge": true, "sinks": true, "only": ["refund/"]}
GET  /v1/admin/benchmark/{id}      # status, summary, per-scenario table, markdown, log tail
GET  /v1/admin/benchmark           # all runs
POST /v1/admin/benchmark   {"replay_of": "<run id>"}   # re-verify a recorded run on the current engine; no model calls

Replay. A recorded run keeps every tool call the model made (agent_calls). --replay <llm_<provider>.json> (or replay_of on the admin endpoint) re-executes those calls through the same chaos rules against a fresh enterprise, extracts, and verifies again, so a verifier change can be re-scored against real agent behaviour without spending model quota. The trace-only judge's verdict is carried over unchanged (the trace is the same). A replayed row notes how many recorded calls responded differently from the recording; zero means the replay is faithful. The replay test in tests/test_sandbox.py checks that replaying a run reproduces its verdicts.

Each run is an isolated subprocess with its own SQLite database and object store under /data/benchmarks/<id>; it never touches the production database. Results are llm_<provider>.{json,md} in that directory.

Real-agent results (2026-09-29)

Full 46-scenario runs for Claude, GPT and Gemini, with the trace-only judge alongside, are in benchmark.md. The runs found one verifier defect: a real agent notified the customer on the support ticket rather than on the order, and both the oracle and the verifier counted that as a missing notification and as an orphan effect. Tickets are linked to their orders, so effects on linked subjects now count for the action's subject and are never orphans (links on POST /v1/facts; the sandbox extractor derives them from the ticket events). Every benchmark row was then re-verified by replaying the recorded runs on the fixed verifier (see Replay above); no replayed call responded differently from its recording.