ProcureGraph — Guardrails Catalog (defense in depth)¶
Every layer assumes the layers above it failed. Sources:
research/04-prompting.md§4/§7,research/01-rain-api.md§5,research/02-monad-x402.md§d,research/03-langgraph.md§d,research/06-architecture-data.md§b. Explicit non-goal: we never claim prompt-level injection resistance — if every prompt defense fails, the deterministic layers still stop money movement.
Layer 0 — Prompt hygiene (reduces, never guarantees)¶
- All security rules live in the system/developer message (instruction hierarchy); provider/tool content only ever appears in the lowest-privilege position.
- Untrusted content wrapped in XML tags; adapter escapes
</>inside untrusted payloads so tags can't be forged; prompts declare tagged content is DATA. - Strict
json_schemaon every call (additionalProperties: false, all fields required); Pydantic re-validation server-side;refusalfield handled explicitly. - Every role has a
flagsfield; nonzero flags on money-adjacent steps bias policy toward ESCALATE and are ledgered. temperature 0, fixedseed, cappedmax_tokens,parallel_tool_calls: false;system_fingerprintlogged per call.
Layer 1 — Deterministic policy engine (the security boundary)¶
Specification objects, AND-combined; every verdict machine-readable, ledgered, and shown in the UI:
Rule result algebra (review B21): each rule returns pass or fail(reason, severity);
severity ∈ {validation, security}. The engine maps results: any security fail →
security BLOCK; only-validation fails → validation reject (no freeze); explicit
escalate triggers (flags present on money-adjacent output, amount within envelope but
above soft threshold, human-approval category) → ESCALATE. Rules do not individually
"escalate" — the engine does.
| Rule | Fails when (deterministic) | Severity |
|---|---|---|
MerchantAllowlisted |
proposal merchant ≠ scorer-selected merchant for this mission's purchase ($97 GiftCardHub) | security |
WithinEnvelope |
amount_minor > remaining pool or > effective spend grant (mission-scoped grant if present, else org-level), ACID-checked at decision time |
security |
PoolHasFunds |
insufficient available balance (locked account rows) | security |
OutsideMandateScope |
provider category ∉ mandate allowed_categories, or purchase not tied to the scorer-selected provider/task (deterministic checks — no semantic claims; review A25) |
security |
NoRecurring |
recurring/subscription terms when mandate forbids them | security |
CardScopeValid |
requested scope wider than policy defaults (limit ≠ amount, expiry > window, missing merchant lock) | security |
AgentAuthorityActive |
proposing agent frozen or effective grant revoked | security |
MandateComplete |
compiled mandate has missing/contradictory required fields | validation |
Security BLOCK revokes the agent's mission-scoped grant only (design/02 §3);
resumption via human escalation. Validation reject never freezes (review A26) — the
mission ends cleanly in reconcile with the reject reasons ledgered. The model's text
has no code path to a payment call — its output is a proposal object, nothing else.
Settled proposals are immutable: any post-settlement spend request becomes a NEW proposal
with a fresh id through the same gate.
The canonical attack cases (both pinned regressions forever):
- $97 over-budget GiftCardHub → fails WithinEnvelope + MerchantAllowlisted +
OutsideMandateScope (proves stacked reasons).
- $5.00 within-budget GiftCardHub → WithinEnvelope PASSES, fails
MerchantAllowlisted + OutsideMandateScope + task linkage → security BLOCK. This is
the demo's headline case: budget controls alone are insufficient; scope rules bind.
- Prompt-path case: the buyer prompt flags the injection and produces NO proposal —
the two defense layers are demonstrated separately (Layer 0 works; then Layer 1 is
shown holding under simulated total model compromise: the labeled demo switch replaces
the buyer's output with a fixed schema-valid malicious output that flows through the
NORMAL propose/authorize use cases — no separate code path).
- Credential layer (order matters — the mock's first-hit decision order returns
card_inactive for retired cards, so merchant enforcement is provable only while the
card is active and unused): scope proof happens at issuance time — out-of-scope
authorization attempt at GiftCardHub while the card is active/unused → declined
merchant_not_allowed; after the valid settlement and retirement, a re-authorization
attempt → declined card_inactive (lifecycle proof). Labeled mock unless the Rain
sandbox can demonstrate declines for real.
Layer 2 — Credential minimization (Rain)¶
Cards issued only from ALLOW verdicts, with: merchant lock, limit = exact amount
(perAuthorization), ~20-min expiry, single-use, no recurring. Card retired
(status=canceled) immediately after settlement or block. PAN/CVC never enter LLM
context, graph state, or logs — adapter-internal only. Even a bad proposal that slipped
through buys one merchant, one amount, one time window.
Layer 3 — Rail-level idempotency & replay safety (both rails — review B11)¶
- x402:
payment-identifierextension in our resource servers — same id + same payload → cached response; same id + different payload → 409; required ids → 400. Never re-sign on retry: reuse the exact signed payload + payment id (EIP-3009 nonce makes double-settle of one signature impossible on-chain). - Rain: idempotency keys mandatory on card issue AND freeze/retire at our port —
but Rain honoring them is UNPROVEN (research/01 open question). Defensive protocol
until the sandbox confirms: persist the local
rail_operationfirst; stamp identifying metadata (displayName carries the operation id) so a created card is findable; after any ambiguous response, queryGET /cards//transactionsbefore acting; NEVER blindly issue another card after a timeout — markissuance_unknown, freeze the workflow, open a reconciliation case. - Webhooks are a transactional inbox (status received→processed applied in the same
transaction as the business transition; payload-hash mismatch on a duplicate id =
security incident;
confirmednever regresses). - Idempotency keys derive from durable business IDs (
audition_id,payment_attempt_id,rail_operation_id), generated by code, persisted before use, injected into prompts as constants — never from node names or graph topology.
Layer 4 — Database constraints (enforce what code promises)¶
Balanced-journal deferred trigger (debits = credits) · account_balances as a view
(balance cannot drift) · one live attempt per proposal (partial unique) · one active card
per proposal (partial unique) · state CHECKs on every enum · append-only trigger +
REVOKE UPDATE, DELETE on events · hash-chained ledger rows · journals.idempotency_key
UNIQUE · spend authority in spend_grants rows, never in prompt text.
Layer 5 — Orchestration safety (LangGraph)¶
durability="sync" on payment runs · payment nodes idempotent (ledger check-then-act)
because nodes replay from the top on resume · interrupt() for escalation, never inside
broad try/except · domain state machine rejects transitions the graph shouldn't request ·
static interrupt_before=["purchase"] available as demo safety net · no secrets in graph
state (checkpoints persist) · checkpoint lib ≥4.1 (CVE-2025-64439).
Layer 6 — Agent-creation gate (ADR-003 rev 2, research 04 §7.3)¶
Master prompt generates ROLE/CONTEXT/INSTRUCTIONS/EXAMPLES (each example re-validated by
code against the role schema); code injects the security block, no-spend clause, and
output contract (boilerplate never passes through the LLM). Evaluation runs as a bounded
background job (≤60 s, ≤25 LLM calls, cached pinned corpus; status evaluating with
progress events). Activation requires ALL at 100%: structural checks, authority-claim
lint, schema validity, injection resistance on the pinned corpus (incl. the $97 payload),
hierarchy + no-math suites, and determinism 5/5 (single unified threshold — reviews
A22/B7). New agents start with zero spend capability; grants are separate human-approved
records. Security-block updates bump boilerplate_hash and force re-gating of every
active agent. Runtime executes the exact pinned prompt version id from our DB, never a
label.
Layer 7 — Demo integrity (honesty guardrails)¶
- If Rain's sandbox can't simulate declines, we do NOT fake a Rain decline — we show the block at credential issuance and explain defense in depth.
- "Difference vs next valid offer", never unsubstantiated "savings".
- Fallback ladder for x402 (env-var flip): official facilitator → self-hosted → mock; recorded backup video against the real facilitator early in the week.
- Wallets funded days early (faucet rate limits); facilitator
/supportedcanary daily. - Narrator role is read-only; quotes injected text as attributed quotation, ≤140 chars; never implies approvals not in the ledger.