Skip to content

LangGraph mission topology — as built

This document is derived from backend/src/procuregraph/composition/graph.py and backend/src/procuregraph/infrastructure/graph/, with the executable topology in build.py and route conditions in routers.py treated as authoritative. The expanded view contains 22 executable leaf nodes in live-LLM mode: 19 outer-graph leaf nodes and three nodes in the compiled purchase_saga. Fixture mode omits the live-only parse_provider_response node and therefore contains 21 leaf nodes. The source contains 23 add_node(...) call sites across the outer graph and saga; at runtime the live graph registers 20 outer nodes including the composite purchase_saga, while fixture mode registers 19.

Full topology

The diagram expands the compiled purchase_saga wrapper so every executable node is visible. Conditional edge labels are the predicates implemented by the router functions; ordinary arrows are unconditional edges.

flowchart TD
    START((START))
    END_NODE((END))

    subgraph MANDATE["Mandate compilation"]
        CM["compile_mandate<br/>LLM agent: mandate_compiler"]
    end

    subgraph EVIDENCE["Evidence and audition fan-out"]
        DP["discover_providers<br/>LLM agent: provider_scout"]
        PLAN["plan_evidence"]
        AUD["run_audition<br/>parallel Send target"]
        QUORUM["assess_evidence_quorum"]
    end

    subgraph SELECTION["Selection"]
        SELECT["select_provider"]
    end

    subgraph PAYMENT["Payment authorization and saga — no LLM"]
        PROP["propose_payment<br/>deterministic proposal"]
        AUTH["authorize_and_reserve<br/>deterministic PolicyEngine"]
        ESC["escalate<br/>interrupt()"]
        PB["policy_block"]

        subgraph SAGA["Compiled purchase_saga subgraph — no LLM"]
            PREP["prepare_rail"]
            EXEC["execute_payment<br/>deterministic rail I/O;<br/>captures checkout response"]
            CONFIRM["confirm_settlement"]
        end

        RAILREC["reconcile_rail_status"]
    end

    subgraph VERIFYREC["Verification and reconciliation"]
        RETIRE["retire_credential"]
        VERIFY["verify_outcome<br/>deterministic grading + live-only<br/>LLM agent: outcome_verifier"]
        PARSE["parse_provider_response<br/>live-only LLM agent: buyer<br/>flags only"]
        FLAG["record_injection_flag"]
        REMEDIATE["remediate_outcome"]
        RECONCILE["reconcile<br/>deterministic close-out"]
    end

    subgraph PRESENTATION["Presentation read model — outside LangGraph and ledger"]
        NARRATE_READ["terminal-event narration projector<br/>live-only LLM agent: narrator"]
    end

    subgraph ATTACK["Attack lane"]
        GATE["demo_attack_gate"]
        ATTACKPROP["propose_payment_attack<br/>fixed deterministic draft"]
    end

    START --> CM
    CM -->|"mandate_compiled = true"| DP
    CM -->|"mandate_compiled is false or absent"| RECONCILE
    DP --> PLAN
    PLAN -->|"decision = run_audition and packets exist: Send per packet"| AUD
    PLAN -->|"decision = reuse"| SELECT
    PLAN -->|"other decision, or run_audition with no packets"| RECONCILE
    AUD -->|"all Send branches join"| QUORUM
    QUORUM -->|"quorum_proceed = true"| SELECT
    QUORUM -->|"quorum_proceed is false or absent"| RECONCILE
    SELECT -->|"selected_offer_id is present"| PROP
    SELECT -->|"selected_offer_id is absent"| RECONCILE
    PROP --> AUTH
    AUTH -->|"authorize_decision = allow"| PREP
    AUTH -->|"authorize_decision = escalate: interrupt"| ESC
    AUTH -->|"all other decisions"| PB
    ESC -->|"resume sets escalation_decision = approved"| AUTH
    ESC -->|"all other resume decisions"| PB
    PREP --> EXEC
    EXEC -->|"saga_outcome = ready_to_confirm"| CONFIRM
    EXEC -->|"otherwise: subgraph ends; outer outcome != settled"| RAILREC
    CONFIRM -->|"saga_outcome = settled"| RETIRE
    CONFIRM -->|"saga_outcome != settled"| RAILREC
    RAILREC -->|"rail_reconciliation = confirmed"| RETIRE
    RAILREC -->|"all other reconciliation branches"| RECONCILE
    RETIRE -->|"live LLM: compiled target"| PARSE
    RETIRE -->|"fixture mode: compiled target"| FLAG
    PARSE -->|"buyer flags only; no proposal or verdict"| FLAG
    FLAG --> GATE
    GATE -->|"attack_simulation is true and attack_executed is false"| ATTACKPROP
    GATE -->|"otherwise"| VERIFY
    ATTACKPROP --> AUTH
    REMEDIATE --> RECONCILE
    PB -->|"attack_executed = true"| VERIFY
    PB -->|"otherwise"| RECONCILE
    VERIFY -->|"verification_passed = true"| RECONCILE
    VERIFY -->|"verification_passed is false or absent"| REMEDIATE
    RECONCILE --> END_NODE
    RECONCILE -.->|"committed terminal event; best-effort projection"| NARRATE_READ

    classDef llm fill:#ede9fe,stroke:#6d28d9,stroke-width:2px,color:#2e1065;
    classDef interrupt fill:#fff3cd,stroke:#b7791f,stroke-width:2px;
    class CM,DP,VERIFY,PARSE,NARRATE_READ llm;
    class ESC interrupt;

Purple nodes call a ProcureGraph LLM role. compile_mandate and discover_providers use StructuredLLM in both live and fixture-backed execution; verify_outcome, parse_provider_response, and the out-of-graph terminal-event narration projector call their displayed roles only when settings.llm_is_live. Every verdict, score, policy decision, and amount still comes from deterministic code. The buyer can return flags only, the outcome verifier must copy the already-graded facts exactly, and the narrator runs only after the terminal fact has committed; its prose is stored in mission_narrations, never appended to the hash-chained events ledger.

execute_payment routes to confirm_settlement only when it returns saga_outcome="ready_to_confirm". Any other outcome ends the inner subgraph; the outer route_after_saga then sends every non-settled outcome to reconcile_rail_status. The direct-looking expanded edges above represent those two compiled routing steps.

Agents

All five pinned application-role prompts now have real callers. Solid arrows below are actual StructuredLLM.call(...) paths. The three orange callers are composed only in live mode; dashed gray arrows show the immutable fact path that remains authoritative before or after those calls.

flowchart LR
    subgraph GRAPH_CALLERS["Mission graph and presentation callers"]
        CM_AGENT["compile_mandate"]
        DP_AGENT["discover_providers"]
        EXEC_AGENT["execute_payment<br/>persisted checkout-response artifact ID"]
        BUYER_AGENT["parse_provider_response<br/>live only; flags only"]
        VO_AGENT["verify_outcome<br/>deterministic grade first;<br/>live explanation second"]
        NARRATE["terminal-event read-model projector<br/>outside graph and ledger;<br/>live only"]
        DETERMINISTIC["select_provider + PolicyEngine + payment saga<br/>all verdicts, scoring, and money are deterministic"]
    end

    subgraph ROLES["Five shipped and eval-covered role prompts"]
        MC_ROLE["mandate_compiler"]
        PS_ROLE["provider_scout"]
        BUYER_ROLE["buyer"]
        OV_ROLE["outcome_verifier"]
        NARRATOR_ROLE["narrator"]
    end

    CM_AGENT -->|"LlmMandateCompiler → StructuredLLM.call"| MC_ROLE
    DP_AGENT -->|"LlmProviderDocNormalizer → StructuredLLM.call"| PS_ROLE
    EXEC_AGENT -.->|"untrusted response only; never spend input"| BUYER_AGENT
    BUYER_AGENT -->|"LlmBuyerResponseParser → StructuredLLM.call"| BUYER_ROLE
    VO_AGENT -->|"LlmOutcomeVerifier → StructuredLLM.call"| OV_ROLE
    NARRATE -->|"LlmNarrator → StructuredLLM.call"| NARRATOR_ROLE
    DETERMINISTIC -.->|"graded facts"| VO_AGENT
    DETERMINISTIC -.->|"committed ledger facts"| NARRATE

    classDef live fill:#ede9fe,stroke:#6d28d9,stroke-width:2px,color:#2e1065;
    classDef liveOnly fill:#fff7ed,stroke:#c2410c,stroke-width:2px,color:#431407;
    classDef deterministic fill:#f3f4f6,stroke:#4b5563,color:#111827;
    class CM_AGENT,DP_AGENT,MC_ROLE,PS_ROLE live;
    class BUYER_AGENT,VO_AGENT,NARRATE,BUYER_ROLE,OV_ROLE,NARRATOR_ROLE liveOnly;
    class EXEC_AGENT,DETERMINISTIC deterministic;
Role prompt Mission-graph mapping as built Code trace
mandate_compiler Invoked by compile_mandate. The graph node calls CompileMandate.execute (node); composition injects LlmMandateCompiler (graph.py); the binding calls StructuredLLM.call("mandate_compiler", ...) (port_binding.py).
provider_scout Invoked by discover_providers. The graph node calls DiscoverProviders.execute (node); composition injects LlmProviderDocNormalizer (graph.py); the binding calls StructuredLLM.call("provider_scout", ...) (port_binding.py).
buyer Invoked by live-only parse_provider_response immediately after credential retirement and before record_injection_flag and the attack gate; fixture mode compiles the direct retirement-to-flag edge and retains its constant fixture flags. The role can return flags only. Rain checkout persists an optional untrusted provider-response artifact whose ID execute_payment carries in graph state (execute node). The live-only node calls ParseProviderResponse.execute, which reloads the persisted mandate, proposal, offer, and response artifact as separately trusted/untrusted context (node, use case); composition injects LlmBuyerResponseParser, whose binding rejects any non-null proposal and calls StructuredLLM.call("buyer", ...) (graph.py, port_binding.py). Fixture flags originate at run admission, not from the model (api_adapters.py).
outcome_verifier Invoked inside verify_outcome in live mode, after deterministic holdout grading. Its explanation is optional evidence and never a router input. VerifyOutcome invokes the provider holdout, calls deterministic grade_labels, computes pass/fail against the mandate, and only then passes GradedVerificationFacts to the optional explainer (use case). LlmOutcomeVerifier calls StructuredLLM.call("outcome_verifier", ...) and rejects output unless verdict, computed quality, target, and elapsed time exactly copy those facts (port_binding.py); the event stores it under provenance-labeled llm_explanation, while the graph node routes only on outcome.passed (verify node).
narrator Invoked by the live-only terminal-event read-model projector after reconcile has committed the deterministic terminal fact. It stores optional prose in mission_narrations; it never appends model text to events, and fixture mode has no enricher. Reconcile only commits and publishes mission.completed or mission.failed (use case). MissionNarrationProjector reads committed ledger facts, calls the narrator, and insert-only caches the result outside the ledger (narration.py); LlmNarrator rejects a changed count, order, UUID, or timestamp (port_binding.py). EventReader enriches terminal rows before the schema-whitelisted SSE projection, and the UI consumes the optional nested field (reader.py, projections.py, TimelinePanel.tsx).

All five calls use the same strict-schema machinery: the roles are enumerated by the credentialed eval CLI, their per-role token ceilings are registered in structured.py, and each role has a pinned prompt and strict schema under backend/prompts/roles/ and backend/prompts/boilerplate/. Live-only interpretation is fail-open with respect to the already-committed deterministic fact: an explanation or narration refusal/validation error is logged and omitted, never used to change routing, remediation, policy, scoring, or money.

Prompt provenance and activation

There are two related provenance lanes in the code. The shipped role prompts are repository artifacts loaded by exact file pins at mission runtime; user-created agent prompts use the prompt_versions database lifecycle. The dashed connector below is intentional: there is no automatic importer that copies the five repository role files into prompt_versions.

flowchart TD
    subgraph SHIPPED["Shipped role-prompt artifact lane"]
        MASTER["backend/prompts/master_prompt.md"]
        ROLE_SPEC["role-spec JSON<br/>generation input"]
        SCHEMA["backend/prompts/boilerplate/&lt;role&gt;.schema.json"]
        SECURITY["backend/prompts/boilerplate/security_block.md"]
        GENERATE["scripts/generate_prompt.py<br/>credentialed offline generation"]
        ROLE_FILE["backend/prompts/roles/&lt;role&gt;.md"]
        PINS["prompt_pins.json<br/>version_id + content_hash"]
        BP_FILE_HASH["boilerplate_hash.txt<br/>SHA-256 of security_block.md"]
        FILE_RESOLVER["FilePromptStore + PinnedPromptResolver<br/>hash-verified exact version"]
        RUNTIME_LLM["StructuredLLM runtime<br/>all five application roles are wired;<br/>three are live-only"]

        MASTER --> GENERATE
        ROLE_SPEC --> GENERATE
        SCHEMA --> GENERATE
        SECURITY --> GENERATE
        GENERATE --> ROLE_FILE
        ROLE_FILE --> PINS
        SECURITY --> BP_FILE_HASH
        PINS --> FILE_RESOLVER
        ROLE_FILE --> FILE_RESOLVER
        FILE_RESOLVER --> RUNTIME_LLM
    end

    subgraph DATABASE["User-created agent prompt-version lane"]
        AGENT_SPEC["agent name + description + rules"]
        MASTER_ROLE["LlmPromptGenerator<br/>StructuredLLM role: master_prompt"]
        CODE_BP["GeneratePromptVersion.BOILERPLATE<br/>code-owned security + authority + output contract"]
        GENERATE_VERSION["GeneratePromptVersion"]
        PROMPT_VERSION["DB prompt_versions<br/>content + content_hash + boilerplate_hash<br/>eval_report + gated_at"]
        TIER2_JOB["EvaluateCandidate + Tier2CandidateEvaluator<br/>candidate report persistence"]
        ACTIVATE["ActivatePromptVersion<br/>all hard metrics = 1.0; determinism = 5/5"]
        STAMP["gated_at: NULL → timestamp<br/>write-once DB trigger"]
        ACTIVE["agent_definitions.active_prompt_version_id<br/>same-agent FK; status active"]

        AGENT_SPEC --> MASTER_ROLE
        MASTER_ROLE --> GENERATE_VERSION
        CODE_BP --> GENERATE_VERSION
        GENERATE_VERSION --> PROMPT_VERSION
        PROMPT_VERSION --> TIER2_JOB
        TIER2_JOB --> ACTIVATE
        ACTIVATE --> STAMP
        STAMP --> ACTIVE
    end

    ROLE_FILE -.->|"explicit promotion would be required; no built importer"| PROMPT_VERSION

    subgraph EVALS["Two evaluation tiers"]
        CI["Tier 1: CI pytest fixtures<br/>no live OpenAI; validates assets, adapters, scorers, failure cases"]
        LIVE["Tier 2: make eval<br/>credentialed actual pinned prompt; report + verdict"]
    end

    CI -.->|"proves machinery; fixture replay cannot activate"| ACTIVATE
    LIVE -.->|"report must be persisted before activation; CLI itself does not stamp DB"| TIER2_JOB

    classDef artifact fill:#eff6ff,stroke:#2563eb,color:#172554;
    classDef database fill:#ecfdf5,stroke:#059669,color:#022c22;
    classDef gate fill:#fef3c7,stroke:#b45309,stroke-width:2px,color:#451a03;
    class MASTER,ROLE_SPEC,SCHEMA,SECURITY,GENERATE,ROLE_FILE,PINS,BP_FILE_HASH,FILE_RESOLVER,RUNTIME_LLM artifact;
    class AGENT_SPEC,MASTER_ROLE,CODE_BP,GENERATE_VERSION,PROMPT_VERSION,TIER2_JOB,STAMP,ACTIVE database;
    class CI,LIVE,ACTIVATE gate;
Provenance stage What the code establishes Evidence
Canonical generator input The offline generator reads master_prompt.md, wraps an untrusted role spec, calls a fixed model/seed at temperature 0, validates generated sections, then code-injects the security block and strict output contract. master_prompt.md, generate_prompt.py
Five generated role artifacts The repository carries mandate_compiler, provider_scout, buyer, outcome_verifier, and narrator prompts with canonical sections, the shared security block, examples, and schema-valid example outputs. roles/, test_prompt_assets.py
Strict wire contracts Each role has a strict JSON Schema used both as the model response format and for output re-validation. boilerplate/, port_binding.py, structured.py
Repository prompt pins prompt_pins.json binds every shipped role to a version_id and SHA-256 content_hash; startup loads exactly those files and refuses a hash mismatch rather than selecting “latest.” prompt_pins.json, prompt_store.py, composition/llm.py
Boilerplate provenance boilerplate_hash.txt pins security_block.md for the shipped artifacts. The database lifecycle uses a separate boilerplate_hash() over the in-code BOILERPLATE constant when creating a PromptVersion; these are distinct hashes and code paths. boilerplate_hash.txt, security_block.md, generate_prompt_version.py
Immutable database version GeneratePromptVersion inserts content, content_hash, and boilerplate_hash into prompt_versions. Domain and PostgreSQL rules keep content/hashes immutable; eval_report and gated_at may each move from NULL only once. generate_prompt_version.py, PromptVersion domain, 0001_initial_schema.py
Tier 1 — merge CI GitHub CI runs the full pytest suite without live OpenAI. Fixture-backed tests validate all prompt assets/pins/schemas, StructuredLLM, eval planning/scoring, weakened-prompt failures, determinism drift, budgets, and CLI behavior. ci.yml, test_prompt_assets.py, test_evalrunner.py, test_run_evals.py
Tier 2 — credentialed candidate make eval runs one requested role or all five exact pinned prompts through live gpt-4o-mini with independent limits of at most 25 calls and 60 seconds. The CLI verifies the prompt pin and emits the report/verdict; it does not itself update gated_at. Makefile, run_evals.py, evalrunner.py
Activation gates The hard metrics are schema_validity, injection_resistance, hierarchy, and no_math where applicable; each applicable metric must equal 1.0, and the candidate must be byte-identical for 5/5 determinism runs. Extraction accuracy is reported but not gated. evalrunner.py, evaluator.py, activate_prompt_version.py
Active prompt pointer A passing activation stamps gated_at, moves the agent to active, and sets agent_definitions.active_prompt_version_id; the database composite foreign key requires the active prompt to belong to that same agent. activate_prompt_version.py, AgentDefinition domain, 0001_initial_schema.py

Three wiring limits matter when reading that lifecycle as an as-built diagram. First, runtime mission calls currently resolve the repository pins through FilePromptStore, not from prompt_versions (composition/llm.py). Second, EvaluateCandidate and Tier2CandidateEvaluator exist, but the current application container exposes generation and direct activation without composing the evaluation job (assembly.py, container.py, agents router). Third, the assembled LlmPromptGenerator requests the master_prompt role, while prompt_pins.json and backend/prompts/roles/ contain only the five application roles; the current PinnedPromptResolver therefore cannot resolve that generator call. The offline scripts/generate_prompt.py path does read master_prompt.md directly (port_binding.py, prompt_store.py, prompt_pins.json).

Node inventory

LLM_RETRY means at most three attempts, starting at 0.1 seconds with a 2.0 backoff, no jitter, and retries only for NodeTimeoutError and ConnectionError. NO_RETRY means exactly one attempt with an empty retry_on tuple. Payment, credential, policy, interrupt, attack, and audition nodes are explicitly NO_RETRY; there is no graph-wide payment retry fallback.

“Failure routing” below means an outcome that the node deliberately represents in graph state. Unless a row says that the node catches an exception, an exception propagates after that node's retry policy is exhausted rather than being converted into a graph edge.

Node Node implementation Application use case called Retry policy Explicit failure or alternate routing
compile_mandate backend/src/procuregraph/infrastructure/graph/nodes/compile_mandate.py CompileMandate.execute LLM_RETRY A non-compiled result sets validation_reject and routes to reconcile; success routes to discover_providers.
discover_providers backend/src/procuregraph/infrastructure/graph/nodes/discover_providers.py DiscoverProviders.execute LLM_RETRY No conditional failure edge; a successful call always continues to plan_evidence.
plan_evidence backend/src/procuregraph/infrastructure/graph/nodes/plan_evidence.py PlanEvidence.execute LLM_RETRY The typed purchase_fresh mode is the deployed default; explicit reuse_prior can produce reuse and route directly to select_provider. run_audition emits sorted Send packets when any are plannable; every other result, including no packets, routes to reconcile.
run_audition backend/src/procuregraph/infrastructure/graph/nodes/run_audition.py RunAudition.execute NO_RETRY Expected provider failure adds its provider ID to failed_audition_provider_ids; every branch still joins assess_evidence_quorum.
assess_evidence_quorum backend/src/procuregraph/infrastructure/graph/nodes/assess_quorum.py AssessQuorum.execute LLM_RETRY Insufficient or zero evidence records insufficient_evidence or all_auditions_failed and routes to reconcile; sufficient evidence routes to selection.
select_provider backend/src/procuregraph/infrastructure/graph/nodes/select_provider.py SelectProvider.execute LLM_RETRY No winner records no_feasible_provider and routes to reconcile; a winner routes to propose_payment.
propose_payment backend/src/procuregraph/infrastructure/graph/nodes/propose_payment.py ProposePayment.execute NO_RETRY No conditional failure edge; success always continues to authorize_and_reserve. Missing required state or another exception propagates.
authorize_and_reserve backend/src/procuregraph/infrastructure/graph/nodes/authorize_and_reserve.py AuthorizeAndReserve.execute NO_RETRY allow enters the saga, escalate enters interrupt(), and both validation/security blocks go to policy_block.
escalate backend/src/procuregraph/infrastructure/graph/nodes/escalate.py None; calls LangGraph interrupt() NO_RETRY Execution pauses durably. An approved typed resume payload re-enters authorize_and_reserve; every other decision routes to policy_block.
policy_block backend/src/procuregraph/infrastructure/graph/nodes/policy_block.py FreezeAgent.execute for a security block; none for validation/rejection NO_RETRY Records security_block, validation_reject, or escalation_rejected; a block on the already-executed simulated attack rejoins verify_outcome, while every pre-purchase block routes to reconcile.
prepare_rail backend/src/procuregraph/infrastructure/graph/nodes/prepare_rail.py PrepareRail.execute NO_RETRY No explicit failure edge or exception translation; success continues to execute_payment.
execute_payment backend/src/procuregraph/infrastructure/graph/nodes/execute_payment.py ExecutePayment.execute NO_RETRY ready_to_confirm continues to confirm_settlement; submit_failed, an ambiguous/non-settled submission, TimeoutError, or NodeTimeoutError ends the saga with a non-settled outcome and routes to reconcile_rail_status. A successful rail result may also persist an untrusted checkout response and expose only its artifact ID in graph state; it is not consulted by authorization, scoring, or settlement.
confirm_settlement backend/src/procuregraph/infrastructure/graph/nodes/confirm_settlement.py ConfirmSettlement.execute NO_RETRY Success marks settled and routes to retire_credential; TimeoutError or NodeTimeoutError marks unknown and routes to reconcile_rail_status.
reconcile_rail_status backend/src/procuregraph/infrastructure/graph/nodes/reconcile_rail_status.py ReconcileRailStatus.execute; replays ExecutePayment.execute only to recover a missing durable attempt ID NO_RETRY confirmed routes to retire_credential; compensated, held_unknown, and security_incident set typed terminal reasons and route to reconcile. Confirmation/compensation/hold/security work occurs inside the application use case.
retire_credential backend/src/procuregraph/infrastructure/graph/nodes/retire_credential.py None; topology marker NO_RETRY No failure branch; records credential_retired and continues to live parse_provider_response when composed, otherwise directly to record_injection_flag. Actual credential retirement already occurs inside ConfirmSettlement.
verify_outcome backend/src/procuregraph/infrastructure/graph/nodes/verify_outcome.py VerifyOutcome.execute; live mode also calls the optional VerificationExplainerPort after grade_labels LLM_RETRY The deterministic outcome.passed is the only router input: failure routes to remediate_outcome; pass routes directly to reconcile. LLM explanation failures are caught and cannot change or suppress the verdict event.
parse_provider_response (live only) backend/src/procuregraph/infrastructure/graph/nodes/parse_provider_response.py ParseProviderResponse.executeLlmBuyerResponseParser LLM_RETRY If no persisted response-artifact ID exists, returns an empty update; otherwise the buyer returns flags only, which replace state.injection_flags, then the node always continues to record_injection_flag. It runs before the attack gate and verification, and cannot create a proposal or affect the completed payment. Fixture mode does not register this node.
record_injection_flag backend/src/procuregraph/infrastructure/graph/nodes/record_injection_flag.py RecordInjectionFlags.execute NO_RETRY No conditional failure edge; its committed ledger write precedes demo_attack_gate. A deterministic per-mission causation identity plus the database unique index makes a crash replay return the existing row instead of duplicating it.
demo_attack_gate backend/src/procuregraph/infrastructure/graph/nodes/demo_attack_gate.py None; the node is a no-op and route_after_gate reads state NO_RETRY Routes to propose_payment_attack only when attack_simulation is true and attack_executed is false; otherwise routes to verify_outcome.
propose_payment_attack backend/src/procuregraph/infrastructure/graph/nodes/propose_payment.py ProposePayment.execute with a deterministic fresh attack proposal and malicious draft NO_RETRY No conditional failure edge; success loops through the normal authorize_and_reserve policy gate.
remediate_outcome backend/src/procuregraph/infrastructure/graph/nodes/remediate_outcome.py RemediateOutcome.execute LLM_RETRY Records remediated_<decision> and routes only to reconcile; it cannot return to purchase.
reconcile backend/src/procuregraph/infrastructure/graph/nodes/reconcile.py Reconcile.execute, except for an uncompiled mandate or a manual-review terminal reason LLM_RETRY Preserves typed terminal reasons; settlement_unknown and settlement_mismatch deliberately skip close-out. It commits only the deterministic terminal fact and remains the only graph node with an edge to END; live narration is an independent best-effort read-model projection.

How the subgraphs work

Mandate compilation

compile_mandate translates graph state into a typed CompileMandateCommand and maps the result back to two small state fields. The node contains no LLM or persistence adapter logic; composition/graph.py constructs the application use case with the LLM compiler, unit of work, clock, IDs, and publisher. A rejected compilation reaches the single terminal reconciliation path immediately.

Evidence and audition fan-out

Provider discovery persists the offers and returns only DB-vetted audition offer IDs, then plan_evidence reloads repository facts and records the application decision. purchase_fresh is the explicit deployed default; the bounded reuse slice requires the typed reuse_prior mode and is never inferred from a zero benchmark allocation. The injected application policy, not SQL, authoritatively checks the 24-hour window, measured trust tier, exact profile/provider set, ledger proof, and completeness before a reuse decision advances to selection. For run_audition, route_after_plan sorts the already-vetted offer IDs and emits one Send("run_audition", packet) per plannable offer. Parallel successes and typed failures merge into ID-only reducers, so arrival order cannot change quorum or selection; the quorum node decides whether partial evidence is enough to continue.

Selection

select_provider is a thin wrapper over the deterministic scoring use case assembled in composition/graph.py. A winner creates the durable purchase-task identity and advances to payment proposal; no winner records a typed terminal reason and closes through reconcile.

Payment authorization and saga

Both legitimate and simulated proposals enter the same atomic AuthorizeAndReserve.execute use case. allow enters the compiled saga; escalate pauses at LangGraph interrupt() and an approved, fully typed resume re-enters the same authorization use case for a fresh policy check. The inner saga checkpoints the prepare_rail, execute_payment, and confirm_settlement boundaries. All payment and credential nodes have NO_RETRY; the execute and confirm nodes translate only timeout uncertainty into saga_outcome="unknown", after which rail reconciliation queries by the durable idempotency identity instead of blindly paying again.

reconcile_rail_status exposes only two graph destinations, but its application use case has four durable business branches: confirm a matched settlement, compensate a proven failure, hold an unknown settlement, or freeze/revoke on a settlement mismatch. The retire_credential graph node is a state marker because the real retirement work already completed atomically in ConfirmSettlement. A successful Rain checkout may return an untrusted provider body, which execute_payment persists separately and carries forward only by artifact ID for the later buyer classification step; payment authorization and settlement never read it.

Verification and terminal reconciliation

After a settled purchase, live parse_provider_response first gives the buyer role the persisted untrusted checkout artifact plus separately privileged mandate/proposal/offer facts. The strict buyer binding accepts flags only and rejects a proposed payment; record_injection_flag then ledgers those flags. Fixture mode omits this node and preserves the pre-existing constant fixture flags byte-for-byte. Both modes then reach the same deterministic attack gate. A normal pass through the gate proceeds to verification; the commissioned attack lane loops a malicious proposal through policy, and its resulting policy block rejoins verification because the legitimate purchase has already settled.

verify_outcome runs the provider on the hidden holdout and deterministic code computes quality basis points and pass/fail. Live mode then asks outcome_verifier for a schema-locked interpretation; the binding requires an exact copy of the computed verdict, quality, target, and latency before storing it under llm_explanation. Routing still reads only verification_passed: pass reconciles, while failure can only run remediate_outcome and then reconcile; neither branch can re-enter purchase.

Every normal, rejected, remediated, blocked, and manual-review path converges on the single reconcile node and then END. When close-out is allowed, Reconcile commits and publishes only the deterministic terminal event. Outside LangGraph, live-mode MissionNarrationProjector gives the narrator only committed ledger facts, validates a one-to-one UUID/timestamp/order mapping, and insert-only caches optional prose in the mission_narrations read model. EventReader nests that read model into the terminal event before SSE projection, so narration failure cannot reopen, rewrite, or extend the canonical ledger; fixture mode emits exactly the prior event stream.

Attack lane

The production attack switch is durable: SqlAttackSimulationSwitch stores it in the mission row's attack_simulation_enabled column. At run admission, GraphMissionRunner._execute reads that column and copies it into the initial MissionState.attack_simulation value. demo_attack_gate itself returns an empty update; route_after_gate reads the checkpointed state and sends the first enabled pass to propose_payment_attack. That node creates a fresh deterministic malicious proposal, marks attack_executed, and loops through the normal policy gate. It cannot bypass authorization or execute more than once in the same state history.

Durability and concurrency

Nodes stay thin: they build typed commands, call application use cases from GraphContainer, and map results into routing fields. Adapter work remains behind those use cases.

MissionRunner invokes and resumes with durability="sync", so checkpoint persistence is synchronous with graph progress. Production keeps one AsyncPostgresSaver open for the application lifetime, pins its search path to the langgraph schema, and runs setup() at boot; tests can use InMemorySaver. The compiled purchase subgraph inherits the parent checkpointer.

Every start and resume is protected by SingleFlight. It folds the mission UUID into a signed PostgreSQL advisory-lock key and holds a pg_try_advisory_xact_lock transaction for the entire invocation, refusing a concurrent runner with MissionBusyError. HTTP run admission also uses a distinct advisory-lock namespace plus an in-process live-task registry so it can return an immediate conflict without weakening cross-process exclusion.

Discrepancies from docs/design/01-architecture.md rev 5

The code remains the source of truth; these differences are documented, not corrected here.

  • The built node names are discover_providers, plan_evidence, and select_provider; the rev-5 diagram abbreviates them as discover, decide_evidence_plan, and select.
  • Rev 5 draws audition as a subgraph with visible policy/pay/invoke/measure steps. The built LangGraph has one fan-out target node, run_audition; those steps live behind RunAudition.execute, not as compiled graph nodes.
  • Rev 5 draws post-query confirm_settlement, compensate, hold, and security-incident nodes after reconcile_rail_status. The built graph has none of those outer nodes: ReconcileRailStatus.execute performs the four branches internally, then the node routes confirmed to retire_credential and every other branch to reconcile.
  • Rev 5 presents retire_credential as an action node. In the build it is only a topology/state marker; credential retirement already occurs inside the ConfirmSettlement application use case.
  • Rev 5 shows a separate malicious-output substitution step followed by a second proposal node. The build combines both into propose_payment_attack, which still calls the normal ProposePayment use case and then loops through normal authorization.
  • Live-LLM composition inserts parse_provider_response between retire_credential and record_injection_flag, before the attack gate and outcome verification; rev 5 does not show this buyer-role node. Fixture composition omits it and compiles the direct retirement-to-flag edge, so the as-built leaf count is 22 live versus 21 fixture. The outcome verifier remains an inline, non-authoritative layer inside VerifyOutcome; the narrator is now an out-of-graph terminal-event read-model projector and is not an additional LangGraph node or ledger event.
  • Rev 5's broad “failed / unknown / timeout” saga edge is more general than the code. Only execute_payment and confirm_settlement explicitly translate timeout outcomes to rail reconciliation; prepare_rail and unhandled non-timeout exceptions propagate under their one-attempt policies.
  • The durable attack_simulation_enabled value is read before graph start and copied to MissionState.attack_simulation. The demo_attack_gate node does not query the column directly, and unlike the optional alternative mentioned by rev 5, it does not call interrupt(); its router reads checkpointed state.