ContractRefine Demo Runbook

Self-contained, demo-neutral walkthrough that exercises the full ContractRefine loop introduced in epic #1020:

POST /v1/preflight    → returns a passing PreflightDecision with id
POST /v1/tool         → with X-Autonomy-Preflight-Id: <id>; 200 allow
GET  /v1/audit/{id}   → returns BOTH preflight + decision frames
                        (chronological INV-CR-8 join)

The embedded policy bundle declares requires_preflight: true, so a /v1/tool call WITHOUT the header is denied at the runtime binding layer (contract: preflight_required). The Rego rule additionally requires input.preflight.outcome == "pass", so repair_required and deny verdicts also fail at the policy layer. Both gates fire under mode=enforce.

No Docker, external registry, or control-plane connection is needed.

What the demo proves

Three independent claims, each verified by a separate step:

Step

Claim

How

1/3

The verifier returns a deterministic verdict for a well-formed plan

POST /v1/preflight; outcome must be pass

2/3

The runtime binding gate honors a valid X-Autonomy-Preflight-Id header

POST /v1/tool with the header; decision must be allow

3/3

The audit surface joins the two frames under one audit_id (INV-CR-8)

GET /v1/audit/{audit_id}; frames must contain BOTH autonomy.preflight and autonomy.decision

A pass on all three is the “verify → bind → execute → audit join” loop. A failure on any of them is a runbook-reproducibility regression — see the failure drills below.

Run the demo

autonomy demo contract-refine

Expected output (abbreviated):

──────────────────────────────────────────────────────
  AutonomyOps  /  ContractRefine  /  governance demo
──────────────────────────────────────────────────────
  policy        : contract-refine-demo v1.0.0
  mode          : enforce (requires_preflight=true)
  audit         : tamper-evident WAL (local)
──────────────────────────────────────────────────────

  Step 1/3   POST /v1/preflight
             outcome      : pass
             preflight_id : <uuid>
             audit_id     : <uuid>

  Step 2/3   POST /v1/tool  (with X-Autonomy-Preflight-Id)
             decision     : allow
             output       : contract-refine demo: verify → bind → execute → audit join
             audit_id     : <uuid>

  Step 3/3   GET /v1/audit/{audit_id}
             frames       : 2 (chronological)
                [0] kind=autonomy.preflight timestamp=<HH:MM:SS.mmm>
                [1] kind=autonomy.decision  timestamp=<HH:MM:SS.mmm>

──────────────────────────────────────────────────────
  PASS  1  —  preflight verdict was pass
  ALLOW 1  —  /v1/tool admitted under verified binding
  JOIN  2  —  preflight + decision frames present in audit
  WAL   7 events  —  binding + decision durable
──────────────────────────────────────────────────────

✓ PASS — verify → bind → execute → audit join

The PASS line on the final row is the load-bearing signal — the smoke test in CI (TestDemoContractRefine_HappyPath) pins it.

Footprint control

By default the WAL directory is a temp directory removed on exit.

  • --keep preserves the auto-generated directory.

  • AUTONOMY_DEMO_WAL_DIR=<path> pins the WAL to a known path (created if missing, never auto-removed).

The footprint panel at the end of the run prints the actual paths and byte counts so the operator can inspect them.

Failure drills

Each drill modifies one step’s input and asserts the documented deny path fires. These are the runbook’s reproducibility pins for the four contract:preflight_* reason codes from C08 (#1028).

Drill 1 — missing header ⇒ contract: preflight_required

The bundle’s requires_preflight=true flag is what makes this gate fire. Omit the header on /v1/tool:

curl -s -X POST http://localhost:7777/v1/tool \
  -H 'Content-Type: application/json' \
  -d '{"kind":"tool.echo","params":{"text":"hi"}}' | jq .

Expected:

{
  "decision": "deny",
  "reason": "contract: preflight_required",
  "audit_id": "<uuid>"
}

Drill 2 — kind mismatch ⇒ contract: preflight_binding_mismatch

Preflight a request bound to tool.echo, then call /v1/tool with tool.shell:

# Step 1: get a preflight bound to tool.echo (the demo helper does this).
PFID=$(autonomy preflight verify --json - <<'EOF' | jq -r .preflight_id
{
  "intent":{"kind":"mission.start"},
  "actor":{"node_id":"demo-node"},
  "capability":"tool.echo",
  "context":{"zone":"default"},
  "plan":[{"kind":"tool.echo"}]
}
EOF
)

# Step 2: try to use it for a different kind.
curl -s -X POST http://localhost:7777/v1/tool \
  -H "X-Autonomy-Preflight-Id: $PFID" \
  -H 'Content-Type: application/json' \
  -d '{"kind":"tool.shell","params":{"cmd":"true"}}'

Expected: reason: "contract: preflight_binding_mismatch".

Drill 3 — replay ⇒ contract: preflight_replay

Use the same preflight_id twice. The first call returns allow; the second 403s with preflight_replay — the durable WAL marker (EventKindPreflightConsumed) makes this hold even if the in-memory cache evicts between calls. See runtime/server_preflight_binding.go for the marker mechanism.

PFID=<from Drill 2>
curl -s -X POST http://localhost:7777/v1/tool \
  -H "X-Autonomy-Preflight-Id: $PFID" \
  -H 'Content-Type: application/json' \
  -d '{"kind":"tool.echo","params":{"text":"hi"}}'
# → allow

curl -s -X POST http://localhost:7777/v1/tool \
  -H "X-Autonomy-Preflight-Id: $PFID" \
  -H 'Content-Type: application/json' \
  -d '{"kind":"tool.echo","params":{"text":"hi again"}}'
# → deny, reason "contract: preflight_replay"

Drill 4 — expired ⇒ contract: preflight_expired

The verifier’s default BindingWindow is 5 minutes. Wait past it (or pin a shorter window via DefaultVerifier.BindingWindow in a custom build) and re-use the id:

sleep 301
curl -s -X POST http://localhost:7777/v1/tool \
  -H "X-Autonomy-Preflight-Id: $PFID" ...
# → deny, reason "contract: preflight_expired"

In CI the 5-minute sleep is impractical; the test surface for this deny path lives in runtime/server_preflight_binding_test.go (TestBinding_ExpiresAtInPast_DenyExpired), which stamps a past ExpiresAt directly.

Architecture reference

The contract this demo exercises is documented in docs/architecture/contract-refine.md. The nine INV-CR-* invariants the loop satisfies are listed in docs/architecture/invariants.md.

Cross-epic

This demo is the reference shape Isaac and ROSOrin extend:

  • Isaac: replaces the in-process DefaultVerifier with one backed by the governance-lab mock_policy_service; the rest of the loop (binding consumption, audit join) is unchanged.

  • ROSOrin: the same loop runs against a real DDS bridge; the per-message governed bridge router consults the same /v1/preflight endpoint before forwarding to the real domain.

Neither integration changes the runbook claims above — they reuse the same verify bind execute audit join shape.

Where the smoke test lives

TestDemoContractRefine_HappyPath in cmd/autonomy/commands/demo_contract_refine_test.go pins the runbook’s headline pass signal end-to-end.