Skip to content

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)

  1. All security rules live in the system/developer message (instruction hierarchy); provider/tool content only ever appears in the lowest-privilege position.
  2. Untrusted content wrapped in XML tags; adapter escapes </> inside untrusted payloads so tags can't be forged; prompts declare tagged content is DATA.
  3. Strict json_schema on every call (additionalProperties: false, all fields required); Pydantic re-validation server-side; refusal field handled explicitly.
  4. Every role has a flags field; nonzero flags on money-adjacent steps bias policy toward ESCALATE and are ledgered.
  5. temperature 0, fixed seed, capped max_tokens, parallel_tool_calls: false; system_fingerprint logged 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 GiftCardHubWithinEnvelope 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-identifier extension 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_operation first; stamp identifying metadata (displayName carries the operation id) so a created card is findable; after any ambiguous response, query GET /cards//transactions before acting; NEVER blindly issue another card after a timeout — mark issuance_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; confirmed never 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 /supported canary daily.
  • Narrator role is read-only; quotes injected text as attributed quotation, ≤140 chars; never implies approvals not in the ledger.