Represent required prerequisites in workflow state, and check them again in the service that performs the action. For example, enrollment can require that details for the selected plan were presented and acknowledged. The enrollment service should verify those facts even if a retry or another caller bypasses the usual conversational path.
Specify only the order that matters. The agent may compare plans or answer questions in different sequences, while enrollment remains unavailable until its prerequisites are satisfied. This is a partial order: some steps depend on others, but independent steps can remain flexible.
Persist state, handle concurrent requests, and use idempotency keys for external effects. Framework routing can guide execution, but it does not replace final authorization checks. LangGraph’s Graph API and Functional API describe routing and resumable execution considerations.
Put order rules where they can actually stop an action
The key design decision is to distinguish a workflow preference from an invariant.
A preference is “ask a clarifying question before searching” or “use the documentation tool before web search.” If the model occasionally takes a different harmless path, the system can recover. Put these preferences in the prompt, tool descriptions, planner instructions, and evaluation set.
An invariant is a condition that must remain true across valid system states or operations. “Do not enroll a user in a plan until the chosen plan's details have been presented and the required acknowledgement exists” is an invariant. “Do not delete a record before authorization” and “do not charge a card twice” are others. The code that performs the effect must verify the invariant from trusted state or an authoritative service. The graph is useful defense in depth, but it is not the final authority.
| Requirement | Where to express it | Where to enforce it |
|---|---|---|
| Show the selected plan's details before enrollment | State transition and user interface flow | Enrollment API verifies plan-specific proof |
| Obtain a legally required acknowledgement | Interrupt or approval screen | Server verifies identity, acknowledgement text version, timestamp, and scope |
| Use a current plan catalog | Retrieval or catalog node | Enrollment API checks catalog version and plan eligibility |
| Pick helpful explanations or comparison order | Model planning inside plan_exploration |
Output validation, with no privileged effect |
| Send an enrollment request once | Effect node and retry policy | Provider idempotency key plus durable effect record |
The table is intentionally asymmetric. A graph makes the intended path easy and auditable, while the effect boundary prevents a bad path from succeeding. This avoids the false choice between conditional edges and in-tool validation: use both, but give the validation the final say.
For a regulated or contractual enrollment flow, determine with domain counsel what “reviewed” legally means. A chatbot message existing in a transcript may demonstrate only that text was generated. The required evidence might instead be a user-interface disclosure event, an explicit acknowledgement, an accessibility-complete presentation, or a signed consent. Model state should store a reference to that authoritative evidence, not invent a stronger fact than the product can prove.
Store the facts required by each transition
Flags such as listed_plans=True and details_viewed=True are tempting, but they quickly lose the identity, version, actor, and time that the rule needs. They also invite a dangerous bug: showing details for Bronze sets details_viewed, then the agent enrolls the user in Gold.
Use a small typed state model with facts that match the policy. The following is illustrative pseudocode, not a complete LangGraph application.
from typing import Literal, TypedDict
Phase = Literal[
"intake", "plan_exploration", "awaiting_acknowledgement",
"enrollment_authorized", "enrolling", "completed", "blocked"
]
class ReviewProof(TypedDict):
user_id: str
plan_id: str
catalog_version: str
disclosure_version: str
presented_at: str
acknowledged_at: str | None
evidence_id: str
class EnrollmentState(TypedDict):
run_id: str
user_id: str
phase: Phase
catalog_version: str | None
selected_plan_id: str | None
review_proofs: dict[str, ReviewProof] # keyed by plan ID
approval_id: str | None
enrollment_effect_id: str | None
attempts: int
review_proofs[plan_id] is materially different from a global flag. It binds the evidence to the user, the selected plan, and the catalog and disclosure versions. In a real system, the state should hold a compact identifier or signed claim and fetch sensitive evidence from the system of record when needed. Do not place raw identity documents, full payment data, or unredacted transcripts in a general-purpose graph checkpoint.
Define phases for the few points where the business process truly changes. Do not make a phase for every conversational turn. A good test is whether a transition changes which side effects are legal. plan_exploration can contain flexible discussion. enrollment_authorized should mean a deterministic predicate was already evaluated and recorded, not merely that the model said it was ready.
Define transitions as typed contracts
Each transition should have a declared input, output, precondition, and owner. The agent may propose an action, but ordinary code decides whether the action moves the state. In Python, use typed objects or schemas rather than an unbounded free-text status field. In a database-backed implementation, make the state version and the evidence identifiers part of the transaction that advances the run.
def may_enroll(state: EnrollmentState) -> tuple[bool, str]:
plan_id = state["selected_plan_id"]
proof = state["review_proofs"].get(plan_id or "")
if state["phase"] != "enrollment_authorized":
return False, "The enrollment workflow has not reached authorization."
if plan_id is None or proof is None:
return False, "No plan-specific review evidence exists."
if proof["user_id"] != state["user_id"]:
return False, "Review evidence belongs to a different user."
if proof["catalog_version"] != state["catalog_version"]:
return False, "The plan catalog changed. Show current details again."
if proof["acknowledged_at"] is None:
return False, "Required acknowledgement is missing."
return True, "ok"
def enroll_tool(state: EnrollmentState) -> dict:
allowed, reason = may_enroll(state)
if not allowed:
return {"status": "refused", "reason": reason}
return enrollment_service.create(
user_id=state["user_id"],
plan_id=state["selected_plan_id"],
idempotency_key=f"enrollment:{state['run_id']}",
)
The example uses a single predicate for readability. Production code would also authorize the requesting principal, validate current plan availability and jurisdictional eligibility, validate the approval scope, and create an auditable effect record. It should get its clock and identity from trusted server services, not from the model.
Specify dependencies between steps
A fixed sequence is appropriate only when every case must follow the same path. Most useful agents have branches and loops. The goal is therefore not “tool one always calls tool two.” It is “no path reaches a privileged effect until its required facts are true.”
For plan enrollment, the required ordering can be represented as:
Authenticate -> load eligible plans -> explore and select
-> present required disclosures -> record acknowledgement
-> recheck authoritative prerequisites
-> create enrollment with duplicate protection
-> confirm result or enter recovery
The exploration branch is intentionally flexible. The model can explain a deductible, search a policy document, or ask the user to choose. It cannot call the enrollment effect just because it believes the conversation is complete. The validator owns that decision.
This design scales better than scattered flags because each workflow declares a prerequisite map or a state-transition table. For example, create_enrollment requires selected_plan_id, a current catalog_version, a valid review_proof for the same plan, and an approved acknowledgement. A different workflow, such as changing a mailing address, declares a different set. Share validator libraries for common requirements such as authentication and consent, but do not make all workflows mutate one global “prerequisites satisfied” object.
Use subgraphs or separate workflow modules when a rule family has its own lifecycle. Keep a compact interface between them: inputs, permitted outputs, evidence IDs, error codes, and effect ownership. This makes a compliance review feasible and prevents an unrelated node from changing a fact it does not own.
A concrete LangGraph layout
LangGraph's StateGraph can represent this design with normal edges for obligatory transitions and conditional edges for deterministic routing. Its Graph API documents that conditional routing functions examine state to choose the next node. It also cautions that multiple outgoing destinations execute in parallel, which matters when two nodes read and write shared state. LangGraph Graph API
For this workflow, use nodes such as these:
authenticate_and_startauthenticates the application user and createsrun_id.load_catalogsnapshots eligible plans and recordscatalog_version.explorelets the model use only read-only explanation, retrieval, and selection tools.present_disclosureproduces the exact selected-plan disclosure and records a presentation event.collect_acknowledgementaccepts a structured front-end event, validates it, and records a plan-specific proof.authorize_enrollmentruns pure business validation and routes either toenrollor back to the missing prerequisite.enrollinvokes the external enrollment API with an idempotency key.verify_effectqueries the authoritative provider result, then routes to confirmation, recovery, or compensation.
The router after explore can be flexible in one bounded sense: it may interpret a validated user choice and choose present_disclosure, or it can loop through more questions. The router after authorize_enrollment should be ordinary deterministic code. It returns only a known next state based on the validator result.
LangGraph Command can combine a state update with a goto, while a conditional edge is appropriate when routing alone is needed. These are control-flow tools, not substitutes for authorization. LangGraph Graph API
For a high-impact action, pause at an approval boundary. LangGraph's interrupt() saves graph state through its persistence layer and waits for a resume value, and it can be used inside a tool to review, edit, approve, or reject a proposed action. LangGraph interrupts The approval payload should identify the exact action, plan, catalog version, consequence, and expiry. On resume, re-run the server-side validator because the catalog, authorization, or user session may have changed while the run was paused.
Concurrency needs an ownership rule
Concurrency is where state flags become misleading. In one graph superstep, two branches can both read the same old state. A reducer can merge harmless facts, but it cannot make two independently authorized money-moving actions safe.
Treat facts and effects differently:
- Monotonic evidence such as “plan Gold disclosure was presented with catalog version 42” can be appended or merged by key if it is immutable and independently validated.
- Exclusive decisions such as “this user has one pending enrollment” need a single owner. Use an optimistic version check, database transaction, queue partition, or unique constraint in the authoritative service.
- External effects need a provider-recognized idempotency key or a durable local effect ledger. A graph-level mutex alone does not protect against process restarts, duplicate deliveries, or another application instance.
An enrollment tool should not trust graph state as its only source of truth. It should atomically check current prerequisites and claim an effect record, for example by inserting a unique (user_id, workflow_type, business_request_id) row before calling the external provider. If another worker already owns that key, it returns the existing result or an in-progress status. This is how the service remains correct even if the same run executes twice.
Avoid parallel tool calls when one proposed action depends on another proposed action's state update in the same turn. More importantly, design the privileged tool to reject the dependent call anyway. The original forum scenario is a good example: listing plans and getting Gold details in the same batch does not establish that the user was shown or acknowledged the result before enrollment.
Retries, crashes, and compensation are part of the order
“Call once” is not enough. A network timeout after a provider receives the enrollment request creates an unknown outcome: retrying blindly may duplicate it, while doing nothing may leave a user unserved.
Use this effect protocol:
- Validate current prerequisites and persist an
effect_intendedrecord containing the run ID, business request ID, selected plan, input hash, and idempotency key. - Call the provider with that idempotency key. Persist the returned provider ID and outcome as
effect_observed. - If the process crashes after the request, resume by looking up the provider result using the idempotency key before attempting another create.
- Retry only failures known to be safe to retry, with a bounded backoff and deadline. Treat invalid input, denied authorization, and failed prerequisites as non-retryable application outcomes.
- If a later step fails after a completed effect, run an explicit compensation only where the business process permits it, such as cancelling a pending enrollment. Record that compensation as another effect with its own idempotency key.
Current LangGraph fault-tolerance documentation supports per-node retry policies, timeouts, and error handlers after retries are exhausted. It describes error handlers as a way to route to recovery or Saga-style compensation. LangGraph fault tolerance Configure these mechanisms deliberately, but keep the idempotency contract in the external-effect code. A generic retry configuration cannot know whether a partner API accepted a request before its response was lost.
LangGraph checkpoints are useful for resuming a run, but they are not a substitute for an external effect ledger. The persistence documentation describes checkpointers as storing thread-scoped graph state, and its Functional API guidance says a task that started but did not finish can run again on resume. It recommends idempotent operations and idempotency keys for this reason. LangGraph persistence LangGraph Functional API
Place network calls and other side effects in durable task boundaries, then make each task small enough that its result has one clear meaning. On replay, previously completed task results can be restored from a checkpoint, but a partially completed side effect still needs the service-level lookup described above. This is a useful distinction: recovery of orchestration state is not proof of the external world's state.
Common designs that fail in production
| Fragile design | Why it fails | Better replacement |
|---|---|---|
| Prompt says “always call A before B” | The model can misunderstand, be jailbroken, or be invoked through another path | Keep the instruction, then enforce B's prerequisites in server code |
One boolean details_seen |
It does not identify the plan, user, disclosure version, or acknowledgement | Store immutable, plan-scoped review evidence |
| A single long node calls several services | Crash recovery can repeat hidden side effects | One idempotent effect per durable task or service operation |
| Global lock around every tool | Reduces throughput and still does not cover a provider call after process failure | Atomic claim plus idempotency at the effect boundary |
| Retry any exception | May turn an unknown external result into a duplicate action | Classify errors and query by idempotency key before retrying |
| Model decides whether approval is needed | A model can skip the decision under pressure or ambiguity | Deterministic policy maps action type and risk to approval requirements |
| Graph state is the audit record | State can be compacted, overwritten, or lack authoritative timestamps | Append an effect and evidence log in the system of record |
Test the invariant without a model in the loop
The most valuable tests should not require a model call. Unit-test may_enroll against every missing prerequisite, a mismatched plan ID, expired approval, changed catalog version, unauthorized user, and completed effect. Property-test generated event sequences to assert that no sequence reaches create_enrollment unless the predicate holds.
Then run integration tests with fault injection. Simulate a process crash before the provider call, after it accepts the call but before the response, during checkpoint persistence, and while a human approval is pending. Confirm that recovery queries the effect ledger or provider, never creates a second enrollment, and leaves an operator-visible status for ambiguous cases.
Add a small operational trace to every transition: run_id, state version, node, transition decision, rule IDs evaluated, evidence IDs, effect ID, idempotency key reference, retry attempt, and sanitized error class. Keep sensitive inputs out of general logs. This trace lets an operator answer “why was enrollment refused?” without turning chat history into the source of truth.
Evidence
Sources used for this answer.
Question signals show what people need. Primary documentation supports the answer. Both remain visible.
- 01Best Practices for Enforcing Tool Call Order in LangGraph?LangChain Forum · question signal · checked 4 Sept 2026
- 02LangGraph’s Graph APIdocs.langchain.com · implementation guidance · checked 4 Sept 2026
- 03Functional APIdocs.langchain.com · implementation guidance · checked 4 Sept 2026
- 04LangGraph interruptsdocs.langchain.com · implementation guidance · checked 4 Sept 2026
- 05LangGraph fault tolerancedocs.langchain.com · implementation guidance · checked 4 Sept 2026
- 06LangGraph persistencedocs.langchain.com · implementation guidance · checked 4 Sept 2026