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/<role>.schema.json"]
SECURITY["backend/prompts/boilerplate/security_block.md"]
GENERATE["scripts/generate_prompt.py<br/>credentialed offline generation"]
ROLE_FILE["backend/prompts/roles/<role>.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.execute → LlmBuyerResponseParser |
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, andselect_provider; the rev-5 diagram abbreviates them asdiscover,decide_evidence_plan, andselect. - 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 behindRunAudition.execute, not as compiled graph nodes. - Rev 5 draws post-query
confirm_settlement,compensate, hold, and security-incident nodes afterreconcile_rail_status. The built graph has none of those outer nodes:ReconcileRailStatus.executeperforms the four branches internally, then the node routesconfirmedtoretire_credentialand every other branch toreconcile. - Rev 5 presents
retire_credentialas an action node. In the build it is only a topology/state marker; credential retirement already occurs inside theConfirmSettlementapplication 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 normalProposePaymentuse case and then loops through normal authorization. - Live-LLM composition inserts
parse_provider_responsebetweenretire_credentialandrecord_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 insideVerifyOutcome; 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_paymentandconfirm_settlementexplicitly translate timeout outcomes to rail reconciliation;prepare_railand unhandled non-timeout exceptions propagate under their one-attempt policies. - The durable
attack_simulation_enabledvalue is read before graph start and copied toMissionState.attack_simulation. Thedemo_attack_gatenode does not query the column directly, and unlike the optional alternative mentioned by rev 5, it does not callinterrupt(); its router reads checkpointed state.