PlanCover Docs

Architecture

One bounded loop, two model roles, and an authorisation boundary the graph cannot route around.

Four layers, each with one job. The boundaries are the point: they are what makes the system auditable.

The layers

LayerOwnsDoes not own
FastAPIValidation, auth context, streaming, cancellation, error mappingBusiness rules
LangGraphThe bounded loop, deterministic nodes, confirmations, checkpointsData access
Python servicesBusiness logic, permissions, PlanCover API callsPrompting
PineconeSearch and ranking over stable knowledgeSource of truth

Routes never touch Pinecone or a raw HTTP client. They call a service. This is not stylistic — it is what keeps a permission check from being skippable.

A planned turn, and one bounded loop behind it

Every turn is planned first. plan_turn makes one structured call on the gather model and merges it with member-cue rules ("my", "our", "am I", mera/meri, मेरा/मेरी) that can only move a turn towards the member's own data. The plan says whether the turn is a member question, a mixed one, a general one, small talk or an action, which parts of the member's record matter, and the searches to run. Code then chooses the reads, and the original bounded gather loop is kept for actions and for turns the router could not plan.

A signed-out member question pauses at auth_gate instead of being answered without the member's data; POST /chat/resume carries it on after sign-in. A signed-out mixed question answers its general part first, labelled as general, and then pauses.

Deviation from docs/PLAN.md v1

Plan §6 draws a route_request node fanning out to RAG, TOOLS and MIXED. Phase 2 dropped it for retrieval-as-a-tool; plan_turn brings a router back, but as the planning call itself rather than an extra one, so a member or knowledge turn is still two model calls (results/implementation-member-specific-turns.md). Plan §14 keeps no conversational memory at all; a graph with no persistence cannot pause for a human, because there is nothing to resume.

app/graph/workflow.py
START
  │
  ▼
prepare ──────────────────────────────────► escalate ──► END
  │
  ▼
plan_turn ─┬─ member, signed out ──► auth_gate ⇢ /chat/resume ──┐
           ├─ member / mixed / knowledge ──► prefetch ◄─────────┘
           │                                   │ ▲ one follow-up
           │                                   ▼ │
           ├─ action / no plan ──► gather ◄─► execute_tools ──► confirm
           │                         │             │
           └─ small talk ────────────┴─────────────┴──► build_context
                                                            │
                     mixed, signed out: auth_gate ◄── finalize ◄── answer
NodeDoesModel call
prepareClears the per-turn budget channels; short-circuits a request for a humanNone
plan_turnPlans the turn: router call merged with member-cue rulesGather model (structured)
prefetchTurns the plan into tool calls for execute_tools; one follow-up at mostNone
auth_gatePauses a signed-out member question on interrupt(); WhatsApp is sent to the appNone
gatherOne non-streaming model call, bound only to the caller's toolsGather model
execute_toolsToolNode, with the authorisation interceptor wrapping every callNone
confirmPauses on interrupt(), then executes the one approved actionNone
budget_exhaustedAnswers every outstanding tool call with a "budget spent" noticeNone
build_contextFixes the evidence set for the turn and reports it onceNone
answerStreams the grounded replyAnswer model
finalizeDrops unsupported citations, emits the surviving ones, records the verdictNone
escalateTerminal hand-over: an approved canned reply and an audit rowNone

Both prepare and gather can divert to escalate. Every other path converges on build_context, so a turn always ends in an answer or a hand-over.

The loop's exit condition is read from state, never asked of the model: spent tool steps, a spent model-call budget, or an assistant message with no tool calls. budget_exhausted exists because every outstanding tool_call needs a reply before the next provider request, so the notice is delivered as those replies rather than as prose.

Two model roles

The split is what makes a single loop safe to stream.

RoleStreamsTaggedJob
GatherNonostreamChooses and calls tools, never writes to the member
AnswerYesfinal_answerWrites the grounded reply

A single model that produced text and then decided to call a tool would already have leaked half an answer. Three independent guards prevent it: the gather model is constructed with disable_streaming, so it cannot emit chunks at all; its nostream tag makes LangGraph drop its messages from the message stream; and the runner only forwards tokens whose node is answer. Any one would do. All three is the point.

The authorisation boundary

Tools are not dispatched by hand. execute_tools is LangGraph's ToolNode, constructed with an async interceptor:

app/graph/nodes/execute_tools.py
ToolNode(
    as_langchain_tools(resolved),
    awrap_tool_call=authorise_and_execute,
    handle_tool_errors=False,
    name=NODE_NAME,
)

awrap_tool_call is the only path a tool call can take, which is what makes the five-step chain in Tool authorisation unskippable. handle_tool_errors=False matters as much: the interceptor maps every failure onto a stable code itself, instead of letting the framework flatten it into an untyped string that neither the event contract nor the audit table can read.

The node is built with every registered tool, not just the caller's, so a call to a forbidden tool reaches the interceptor to be refused and audited rather than vanishing.

Auth never reaches the checkpoint

The authenticated caller travels as LangGraph runtime context — a frozen dataclass passed at invoke time — not as graph state.

app/graph/context.py
@dataclass(frozen=True)
class ChatRuntimeContext:
    auth: AuthContext
    request_id: str
    conversation_id: UUID
    channel: str
    settings: Settings
    rag: RagService

Runtime context is per-run and is not checkpointed, so the AuthContext cannot reach Postgres, a prompt or a trace. That turns "no credentials in persisted state" into a property of the design rather than a rule somebody has to remember. The context carries no token in the first place: AuthContext holds an opaque credential_ref, which the executor exchanges at call time.

State, and where it lives

The process holds no conversation memory. The thread does, in Postgres.

The thread is the conversation row

The client picks its own conversation_id. The server maps it to a row that is unique on (user_ref, conversation_ref) and uses that row's id as the thread_id. Two members may both call a thread "t1" and never meet, and neither can resume the other's checkpoint by guessing a name.

Checkpoints are LangGraph's own tables

AsyncPostgresSaver owns checkpoints, checkpoint_blobs, checkpoint_writes and checkpoint_migrations, and migrates them itself, so they stay out of Alembic. python -m app.graph.setup runs that migration under a Postgres advisory lock, because two replicas starting at once would otherwise race the same DDL.

Governance tables are ours

conversations, tool_audit, confirmations and rate_limits are Alembic's. They govern a conversation rather than storing it: who a thread belongs to, what the executor actually did, what is waiting on a human, and who is over quota.

Checkpointed state is encryptable

Checkpoints hold member questions and tool results — PII, on a database we do not operate. CHECKPOINT_ENCRYPTION wraps the serializer in AES, keyed by the same 32 bytes ENCRYPTION_KEY already validates at startup.

Loading a checkpoint rebuilds only the classes in CHECKPOINT_TYPES (app/graph/setup.py), besides LangGraph's own safe set; anything else comes back as plain data. A new Pydantic model or enum in graph state goes on that list, and a test fails until it does.

History is still accepted on the wire and still capped, but the server is now authoritative: a thread with no checkpoint is seeded from history plus the new message, and a thread that already has one takes only the new message.

Request body
{
  "conversation_id": "web-V1StGXR8Z5",
  "channel": "web",
  "message": { "role": "user", "content": "Why is my claim still pending?" },
  "history": [{ "role": "assistant", "content": "…" }],
  "client": { "locale": "en-IN", "timezone": "Asia/Kolkata" }
}

The confirmation pause

A write or a send never runs in the same pass as anything else. The interceptor parks it as a pending_action with an "awaiting confirmation" reply, and the confirm node calls interrupt() before any side effect — resuming re-runs the node from the top, so anything executed before the pause would run again on every resume.

The pause is visible on the wire as action.confirmation_required, and the run is resumed by POST /chat/confirm. The confirmation row is the authority for who may answer: it records the member who was asked and when the request expires, so an approval survives a process restart and cannot be given by somebody else.

Untrusted content

Retrieved documents and tool results are data, never instructions. The context builder and the sanitizer fence both in the delimiters the system prompt names, so a document containing something shaped like a prompt is read as a document containing something shaped like a prompt.

The builder is plain Python with no model call and no I/O. Given the same state it produces byte-identical messages, which is what makes an answer reproducible when something goes wrong. It owns ordering (fixed, never scored), deduplication, the evidence and history budgets, and that fence.

On this page