Tool authorisation
What a tool declares, the chain every call passes through, and the closed set of ways one can fail.
A tool call is not a function call. It is a request from an untrusted party that has to survive five checks before anything happens.
The framework has no opinion about any of those checks, so a tool is not just a
LangChain tool. It is a ToolSpec: the callable plus the facts that decide
whether it may run at all.
What a tool declares
| Field | Type | Why it exists |
|---|---|---|
tool | BaseTool | The callable, its description and its argument schema |
risk | read · write · send | What a call can do, which is what decides confirmation |
roles | frozenset[Role] | Who may call it — both the binding filter and the executor check |
idempotent | bool | Retry-safety, declared by semantics rather than inferred |
confirmation_override | bool | None | Raises the bar above the risk tier. Can never lower it |
ownership | (auth, args) -> bool | May this caller touch this resource |
requires_credential | bool | True for anything that calls PlanCover |
provides_evidence | bool | True for a tool whose whole job is returning passages |
timeout_seconds | float | None | Per-tool ceiling; falls back to TOOL_TIMEOUT_SECONDS |
summarise | (args) -> str | One sentence a human can approve or reject |
The argument schema is taken from the tool's own typed signature, minus the
injected runtime parameter — the model sees the business arguments and nothing
else.
The chain
model proposes a tool call
│
├─ 0. known tool? unregistered names never reach a handler
├─ 1. role permission this role may use this tool at all
├─ 2. argument validation arguments match the declared Pydantic shape
├─ 3. credential present? a signed-out caller is told that, not "not found"
├─ 4. resource ownership this user may touch this portal / claim
├─ 5. business rules does this need a human, and is one already waiting
│
▼
timeout ─► call ─► sanitize ─► audit ─► the model sees a safe resultThe credential check sits third rather than last, and the order is load-bearing.
AuthContext.portal_refs is derived from the credential, so a caller without
one owns nothing — and asking about ownership first answers "that record does not
exist" to somebody whose real problem is that they are signed out. It is ahead of
the confirmation gate for the same reason: there is no sense asking a human to
approve an action that cannot run. Only the presence of a credential is checked
here; the token itself is exchanged inside the service when the call runs.
Two invariants hold at every step.
It never raises into the graph
Every failure becomes a typed ToolFailure with a stable code, including an
unexpected exception, which becomes tool_failed. A refusal is still an
executor decision, so it is still streamed as a tool.started / tool.completed
pair and still written to tool_audit.
A confirmation-requiring call is never executed in the same pass as anything
else. Step 5 short-circuits it into a pending_action; the confirm node
interrupts and only then executes.
Failure codes
A closed set. The answer model, the event contract and the audit table all key off these exact strings, so adding one is a contract change.
| Code | Raised when | Reported as |
|---|---|---|
unknown_tool | The name is not in the registry | The assistant does not have that capability |
forbidden | The caller's role may not use this tool | Their role cannot do this |
invalid_arguments | Pydantic validation failed | Which field is wrong, so the model can correct it |
not_found | The ownership check said no | The record does not exist, or is not theirs |
confirmation_required | Another action is already awaiting approval | Handle that one first |
credential_unavailable | The caller has no stored PlanCover session | Sign in again |
upstream_unavailable | A dependency failed rather than returned nothing | It is temporarily unavailable |
upstream_timeout | The call exceeded its timeout | It took too long, and nothing was changed |
tool_failed | Anything else | It failed, and nothing was changed |
not_found, not forbidden
A failed ownership check reports not_found on purpose. Whether a claim or a
portal exists is itself information the caller is not entitled to, and
forbidden would confirm it.
Two layers of RBAC
Both exist, and they are not redundant.
The model is only shown what it may use
tools_for(role) filters the toolset before bind_tools, so a tool the caller
may not use is invisible rather than refused. Not offering a capability is
stronger than declining it after the fact, and it keeps a refusal out of the
conversation entirely.
The executor re-checks the role anyway
"Not exposed" is not an authorisation control. A model can name a tool it was never shown — from its own priors, from a retrieved document, from an earlier turn — and the interceptor, not the binding, is what stops that call.
Risk tiers and confirmation
| Risk | Meaning | Needs a human |
|---|---|---|
read | No side effect | No |
write | Creates or changes a record upstream | Yes |
send | Messages a real person — WhatsApp, email, SMS. Not undoable | Yes |
A tool may raise the bar for itself with confirmation_override=True — a bulk
export, say. It can never lower it: a write stays confirmable whatever it
declares. The confirmation is a deterministic step in the graph, not a sentence
the model decides to say.
Retry-safety is a semantic property
Never infer safety from the HTTP verb
PlanCover sends a WhatsApp message with a GET, and deletes an employee with a
GET. Verb-based reasoning classifies both as safe reads.
Retry-safety is declared per tool and holds only for idempotent reads. There are
no idempotency keys on the PlanCover API, so a retried write can duplicate a
record with nothing to deduplicate it against. The same rule shapes the graph:
RetryPolicy sits on the idempotent nodes only, never on execute_tools.
When a call times out, the failure it reports is marked retryable if and only if the tool declared itself idempotent. Retry-safety is a property of the tool, never of the failure.
Credentials
The model proposes:
get_plan_details(portal_ref="PRT-40122")It never proposes, and never sees, an access_token argument. AuthContext
carries an opaque credential_ref, which the service exchanges for a token
inside the call itself. This is why a prompt injection in a retrieved document
cannot exfiltrate a token: the token was never in the model's reach.
A tool that declares requires_credential refuses with
credential_unavailable when the caller has none — it must never become an
unauthenticated upstream request — and the executor puts an auth.required
frame on the stream, which is what raises the client's sign-in card. All thirteen
PlanCover read tools set the flag; retrieval and the callback fixture do not.
That refusal is now the fallback, reached only on the gather path. A planned
member question never gets that far signed out: plan_turn decides first, and
auth_gate pauses the turn on an interrupt that records no mobile number, OTP,
identifier or question text. The client shows a resumable sign-in card, the member
signs in over /auth/*, and POST /chat/resume carries the same turn on (see
Streaming).
Member reads chosen by code
For a planned member or mixed turn the model does not choose the reads.
tools/member_reads.py maps the plan's topics to calls, fills each portal_ref
from the caller's own portal_refs (at most three portals and six reads), and
prefetch puts them on the graph as an internal tool-call message — so they go
through this same executor, with every check above, the audit row and the
tool.started / tool.completed pair.
| Topic | Reads |
|---|---|
| cover | get_covered_benefits, get_plan_details (+ the planned searches) |
| family | get_insured_members (+ the planned searches) |
| policy | get_policy_details, get_policy_validity |
| claims | list_claims; get_claim_status for a named claim, or one follow-up when there is exactly one |
| ecard / documents | get_ecard / get_policy_documents |
| hospitals | find_network_hospitals, only when a pin code or city was given |
| company | get_company_details |
| (no portal on record) | list_my_policies |
A mixed turn adds search_policy_knowledge in the same step. Only read-only,
credentialed reads can be planned; a test checks every name against the registry.
The sanitizer
Everything a tool returns passes through one function before the model, the wire or the audit table sees it. Three jobs, in order:
Redact
Using the same redact_value the logger uses, so a tool result, a log line and
a trace payload are redacted by one implementation. PlanCover returns mobile
numbers and email addresses on ordinary reads.
Strip document URLs
Every e-card, PDF and document endpoint returns one, and tool URLs are forbidden
in prompts, state, logs and traces. The key is dropped whole rather than masked:
leaving "document_url": "[url removed]" tells the model a link exists and
invites it to ask. The document_id beside it survives, and the link is
resolved at emit time, outside the graph.
Truncate
GET /blacklisted-hospitals is 749 KB and unpaginated. Without
TOOL_MAX_RESULT_CHARS, one tool call can consume the whole context window. The
cut says so, so the model knows it saw a part — and the summary line is never
truncated away, so a reader of the first line still gets a true statement.
Tool arguments get the same treatment before they reach the wire or the audit row. Arguments come from the model, so they hold no credential by construction — but a member who typed their mobile number into a free-text field put one there, and this is where it stops.
The tools that exist today
Fifteen are registered: thirteen PlanCover reads, the retrieval tool, and one
framework fixture. The thirteen are curated rather than generated — of the API's
161 endpoints only 89 are verified working, 29 verifiably fail (the 16
/cases/* and /case-agent/* routes among them, which reject our token as a
realm failure) and 42 have never been executed, and a model choosing from 161
tools chooses worse than one choosing from thirteen.
Every PlanCover read shares one shape, so the tables below record only what
differs: read risk, every role, idempotent, requires_credential, no
confirmation. Ownership is owns_portal wherever there is a portal_ref — a
portal the caller does not hold reports not_found. The claim tools need no
ownership check: PlanCover scopes claims to the member's own token upstream.
Policies
list_my_policies is the entry point. Every other policy tool needs a
portal_ref, and this is the only place one comes from.
| Tool | Arguments | Answers |
|---|---|---|
list_my_policies | — | Which policies cover this member |
get_policy_details | portal_ref | Policy number, insurer, start and end dates |
get_policy_validity | portal_ref | "Am I still covered?" |
get_plan_details | portal_ref | Sum insured, top-up, enhanced cover — how much, not what |
get_covered_benefits | portal_ref | What this plan covers and excludes, live rather than from the corpus |
get_insured_members | portal_ref | The member, and any dependants |
get_company_details | — | The company this group policy belongs to |
get_covered_benefits is preferred over the corpus whenever the member is asking
about their cover; search_policy_knowledge still owns conditions, waiting
periods and procedure, which the live benefit list does not carry.
Claims
| Tool | Arguments | Answers |
|---|---|---|
list_claims | — | Every claim on the account, newest first — and the claim number the other two need |
get_claim_detail | claim_number | One claim in full: amounts, hospital, dates, who holds it |
get_claim_status | claim_ref | The tracking stages, for "why is my claim still pending" |
Three tools rather than one because the API answers the three questions in three different shapes. Either a claim number or an id works for both lookups — the two upstream endpoints want different ones and the service translates. Neither returns a settlement date, so the model is told not to predict one.
Documents
| Tool | Arguments | Answers |
|---|---|---|
get_ecard | portal_ref | The member's health e-card |
get_policy_documents | portal_ref | Insurer document, TPA and schedule |
A handle, never a link
PlanCover's own /storage/… document URLs need no credential at all — verified:
HTTP 200, no token, for a PDF carrying the member's name, employee id, policy
number and insurer. So these two return an opaque handle instead — the URL
encrypted under ENCRYPTION_KEY, bound to this member, expiring in minutes —
and GET /documents/{handle} re-checks the caller before streaming the bytes.
The model, the transcript, the checkpoint and the logs never see a link, and the
model is told to name the document and leave the handle out of its reply.
Network hospitals
| Tool | Arguments | Answers |
|---|---|---|
find_network_hospitals | portal_ref, pin_code?, city? | Cashless network hospitals near the member, nearest first |
One of pin_code or city is required, and a pin code must be exactly six
digits; either mistake is answered with invalid_arguments rather than a search.
Upstream returns 250 records (~84 KB) filtered by region whatever it is asked, so
the service re-ranks — exact pin, then city, then district — and cuts to ten. The
cap is not tidiness: TOOL_MAX_RESULT_CHARS is 4000, and 84 KB truncates
mid-record into a corrupted list of half-addresses. The ten are a regional list
rather than an exhaustive one, which is what the model must say when a hospital
the member named is not among them.
Retrieval, and the callback fixture
| Tool | Arguments | Risk | Roles |
|---|---|---|---|
search_policy_knowledge | query | read | All |
request_callback | reason, preferred_window? | send | Employee, Company HR |
search_policy_knowledge is the retrieval tool, and the only spec that sets
provides_evidence. On a planned turn plan_turn's router writes its queries and
prefetch runs them; on the gather path the planning model calls it itself. It returns its passages
as evidence rather than as text in the tool reply, so the context builder owns
the budget and finalize owns the citations. A dead index is reported as
upstream_unavailable with an explicit instruction not to answer from memory. It
needs no credential: the corpus is the same for every caller. It takes no filters
either — a model given document_type and policy_ref narrowed searches nobody
asked it to narrow — and the caller's company scope is built from AuthContext,
never from the model.
request_callback is a fixture, not product
request_callback makes no network call. It exists to exercise interrupt,
resume and audit on a confirmed send, and it is still the only tool above the
read tier, so deleting it would leave nodes/confirm.py, the confirmations
table and POST /chat/confirm with nothing exercising them. It goes when the
first real write tool lands. Its read sibling echo_policy_summary is gone: it
returned invented policy data beside get_policy_details.
Role mappings are provisional across the whole registry — every PlanCover read is
open to every role — because the production RBAC matrix is still an open question
in docs/PLAN.md §27.
The audit trail
One row per executor decision, including the ones that refused to execute,
unique on (conversation_id, tool_call_id). The uniqueness is not decoration:
resuming an interrupt re-runs the whole node, and without it an approved write
would audit twice and the trail would claim two actions where one happened.
A failed audit write degrades to a log line rather than failing the member's turn.