> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Doug assistant service

> Architecture and operating contract for stralos-api-assistant, the server behind Doug, the Stralos AI assistant

`stralos-api-assistant` is the Fastify service that owns Doug, the Stralos AI assistant. It stores
conversations, streams turn responses over SSE, executes tools against downstream services, and
manages the confirm-before-write draft lifecycle.

* **Port** `3307` · **route prefix** `/api/assistant`
* **Database** `stralos_assistant` on the shared runtime Postgres server, RLS-scoped per organisation

## Authentication

The service uses the EdDSA-only auth plugin (M1b.2 end state), byte-identical to the plugin in
`stralos-api-global`. It verifies every bearer token asymmetrically against the Global API JWKS.
There is no shared-secret fallback and no log-only enforcement toggle.

`AUTH_JWKS_URL` is required in every environment. `buildApp` throws before registering any plugin
when it is missing or blank, so the service does not start. Terraform sets it on the API Lambda and
`stralos-local` sets it locally.

Both user tokens (`typ: "user"`) and service tokens (`typ: "service"`) are accepted. The strict
claim schema rejects any user token that carries a `scope` claim and any service token that carries
a `role` claim. Service tokens must include this service in their `aud` array.

After verifying a user token, the auth plugin parses the optional `billing` claim using
`parseBillingAccessClaim` from `@stralos-co-nz/api-common`. When the claim is present and valid,
the parsed `BillingAccess` value is attached to `AuthContext` as `billing`. Route handlers that
enforce billing-gated behaviour read this field from the request auth context; it is absent when
the token carries no `billing` claim.

The `x-forwarded-user` header used on service-to-service lifecycle calls is verified with the same
EdDSA verifier. A forwarded service token is rejected; only a valid user token produces an `ok`
result. An absent header and an invalid header are distinguished so the calling service can return
`actor_required` vs reject a forged forward explicitly.

See [Auth token architecture](https://help.stralos.app/internal/auth-token-architecture.md) for the full token claim contract
and JWKS rotation behaviour.

### Billing access guard

The auth plugin registers the billing access guard from `@stralos-co-nz/api-common/billing` as its
final step. For authenticated user write requests, the guard reads the organisation's entitlement
from the DynamoDB table named by `BILLING_ENTITLEMENTS_TABLE` and returns `403` when the entitlement
is not current. Service tokens are exempt.

Terraform sets the table name and grants `dynamodb:GetItem` when
`enable_billing_enforcement = true`. When the flag is false, the environment variable is empty and
the guard is a no-op. Enable the flag only after billing and the environment's entitlements table
are configured.

The policy's `dynamodb:LeadingKeys` condition allows two key prefixes on the billing table:

* `ORG#<orgId>`: the company's own billing entitlement row.
* `LINK#<linkId>`: contractor-company link state, which `api-common`'s `createLinkStateReader` reads
  on every request made by a contractor person. Without this prefix, those reads are denied and the
  guard returns `503 billing_unavailable`.

## Shell-side draft controller (PRD 04)

The shell owns all draft network calls and the `AssistantController` component.
`draft_review` SSE events are validated in the shell and converted into
`DraftReviewCardModel` objects attached to their anchor messages. On
conversation load, the controller re-fetches persisted drafts and rehydrates the
cards.

See [Doug assistant shell draft controller](https://help.stralos.app/internal/assistant-shell-draft-controller.md)
for the full draft lifecycle, accept/reject/edit flows, 409 re-fetch behaviour,
and deep-link resolution.

## Tenant boundary

All conversation and draft tables use forced row-level security keyed by `organisation_id`.
Tenant queries run through a transaction-scoped organisation context (`withTenant`) set from the
verified JWT. A same-org but different-user draft reads as zero rows and returns a 404, identical
to a foreign-org row (creator-private predicate on `created_by`). Request routes always use the
runtime pool, including for organisation-bearing service tokens. The platform pool is reserved for
explicit cross-tenant ingestion, reconciliation, and retention work.

### Project membership enforcement

`assistant_conversations` carries two project-scope columns:

* `project_ids uuid[]` — the set of project UUIDs whose data appears in the conversation. The
  assistant service populates this automatically as tools return project-linked content. It is
  updated inside the same transaction as the turn so the stored value always reflects the content
  that was persisted.
* `project_scope_known boolean` — `true` for all new conversations and for backfilled historical
  conversations. `false` means the conversation predates project enforcement and has not yet had its
  provenance resolved. A conversation with `project_scope_known = false` is treated as inaccessible
  by the runtime role.

A restrictive RLS policy (`project_membership`) on `assistant_conversations` prevents the runtime
database role from reading or writing a row unless its `project_ids` is a subset of the projects
the calling user currently has access to. The project list is supplied to the database session as the
`app.project_ids` GUC, resolved from `api-global` before each transaction. If a user loses access to
a project, any conversation that touched that project's data returns 404 immediately. Re-adding the
user to the project restores access on the next request.

Matching restrictive policies on `assistant_messages`, `assistant_drafts`, and `assistant_actions`
enforce the same boundary by requiring the associated conversation to be visible to the current
session. This means the database itself enforces the isolation; it is not dependent solely on
application-layer filtering.

#### Historical backfill

Conversations created before project enforcement carry `project_scope_known = false`
and are inaccessible until they are backfilled. The backfill script
(`scripts/backfill-project-provenance.mts`) assigns each historical conversation the full project set
of its organisation as conservative provenance, then sets `project_scope_known = true`.

The script requires explicit `BYPASSRLS` or superuser database connections for both the assistant
database and the Global database. A connection constrained by RLS could return an incomplete project
inventory, which would incorrectly narrow the provenance of historical text. The script validates
this constraint and aborts before modifying any row if either connection lacks the required privilege.

Run the backfill after the database migration and before enabling the new service, with traffic
paused. Pass `MIGRATION_DATABASE_URL` and `GLOBAL_MIGRATION_DATABASE_URL` to point the script at
the privileged migration credentials. The script is safe to resume: conversations already marked
`project_scope_known = true` are skipped.

```bash theme={null}
MIGRATION_DATABASE_URL=<assistant-privileged-url> \
GLOBAL_MIGRATION_DATABASE_URL=<global-privileged-url> \
node --import tsx scripts/backfill-project-provenance.mts
```

The script prints a JSON summary: `{ "organisations": N, "conversationsBackfilled": N }`.

## Data model

The `stralos_assistant` schema contains these tables:

* `assistant_conversations` — the conversation record with creator, route-context snapshot, title,
  project scope (see [Project membership enforcement](#project-membership-enforcement) below), and
  turn lease columns (`turn_lease_id`, `turn_lease_until`) used to serialise concurrent turns across
  Lambda instances (migration `0006_turn_lease.sql`).
* `assistant_messages` — ordered user, assistant, and tool messages with content blocks, citations,
  and tool-step progress (migration `0005_task_progress.sql`).
* `assistant_actions` — append-only on the runtime/request path for every tool call (redacted
  arguments, compact result summary, duration, and model id). The platform role retains deliberate
  deletion authority for retention work.
* `assistant_drafts` — the record-draft store (see [Confirm-before-write](#confirm-before-write)
  below). Carries a pending-continuation flag set on every successful acceptance and cleared by an
  ordinary user turn (migration `0005_task_progress.sql`).
* `assistant_help_documents` and `assistant_help_chunks` — the crawled help-centre corpus for full-text search.
* `assistant_usage_counters` — tenant-scoped rate and token-budget buckets, deleted by the platform
  retention sweep after they no longer contribute to a live window.

## Conversation routes

All conversation routes require an authenticated caller with an organisation. They are
creator-private: even an administrator sees only their own conversations, and foreign-user or
foreign-organisation rows return the same not-found response.

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/api/assistant/conversations` | Create a conversation and optionally snapshot route context. |
| `GET` | `/api/assistant/conversations` | List the caller's active conversations using keyset pagination. |
| `GET` | `/api/assistant/conversations/:id` | Hydrate a conversation and its messages. |
| `DELETE` | `/api/assistant/conversations/:id` | Archive a conversation. |
| `POST` | `/api/assistant/conversations/:id/messages` | Submit a turn and receive an SSE stream. |

Turn context (`route`, `projectId`, and `estimateId`) is an untrusted hint. Authorisation always
comes from the verified token and downstream service checks.

When the supplied `route` is an estimate-scoped path of the form
`/projects/:projectId/estimates/:estimateId/…`, the service extracts both UUIDs from the path and
uses them as the effective `projectId` and `estimateId` for that turn and for the conversation's
context snapshot. A bare `/projects/:projectId/estimate/…` path carries no estimate UUID and adds
nothing beyond the project ID already present. The extracted IDs remain untrusted hints; they steer
which record the model reads, and every read runs under the caller's own token.

## OpenAPI contract

`GET /api/assistant/openapi.json` serves the Assistant API's OpenAPI 3.1 contract. The route is
explicitly auth-exempt, like the health endpoint, and the document is serialised byte-stably from
the same route-descriptor registry used by the service. The repository keeps a committed snapshot
plus tests for deterministic output, served-byte parity, two-way route drift, and the shared
vendored OpenAPI block.

`GET /api/assistant/version` is an auth-exempt deploy-identity probe that returns
`{ version, commit, lambdaVersion }` with `Cache-Control: no-store`. It is an infra route, not a
documented API endpoint, and is excluded from the OpenAPI descriptors. Two callers depend on it: the
shell coordinator's API minimum-version gate (reads `version`) and `release-alias.yml`'s edge proof
(reads all three fields to confirm the alias pointer moved). See
[Release by alias](https://help.stralos.app/internal/deployment.md#release-by-alias-estimate-rates-and-assistant) for the full proof protocol and the
pre-M4 rollback constraint.

## Help-centre ingestion

The help corpus lives in `assistant_help_documents` and `assistant_help_chunks`. The crawl reads
`guides/` and `api/` MDX from the help-centre repository; `internal/` is excluded from the
user-facing corpus. It strips frontmatter, chunks by H2/H3 headings, and hashes each document.

`search_help` uses PostgreSQL full-text search and returns ranked chunks with citation IDs, titles,
URLs, and snippets. The help corpus is organisation-agnostic and runtime-read-only.

### Help-library loader Lambda

Deployed environments fill the corpus with the help-library loader Lambda (`help.handler`, defined
in `terraform/help.tf`). The help centre requires sign-in, so the corpus comes from git rather than
from the live site:

1. **Build.** `publish-artifacts.yml` checks out `stralos-help-centre@main`, and
   `scripts/build-lambda.sh` (with `HELP_CORPUS_DIR` set) crawls it into `helpCorpus.json` inside
   the deployment zip. The release promotes that zip by digest, so test and prod load the same
   corpus as dev.
2. **Load.** The loader persists the bundle as the platform database role. It is idempotent:
   pages with an unchanged content hash are skipped, and pages missing from the bundle are deleted
   only after the whole bundle has been read and validated. The loader fails when the bundle is
   missing or empty, or when the tables hold no chunks after a run (`EmptyHelpLibraryError`), so the
   `help_loader` `Errors` alarm fires instead of the corpus going silently empty.
3. **Invocation.** `scripts/load-help-library.sh` invokes the `live` alias after every deploy:
   from the `verify-ai-gateway` job in `deploy-dev.yml` on dev, and from `smoke.yml` (dispatched by
   the central release) on test and prod. EventBridge also runs it daily, set by
   `help_loader_schedule_expression` (default `cron(30 14 * * ? *)`, 14:30 UTC). The schedule is
   created disabled on test and prod and enabled by the activation step.

The loader needs only the platform database URL parameter. It has no S3, GitHub or service
credentials, because the corpus is in the zip.

### Local CLI

`pnpm ingest:help` runs the same crawl against a local `stralos-help-centre` checkout and writes
through the platform database connection. The corpus path resolves from `--corpus <path>`, then
`HELP_CORPUS_DIR`, then an auto-detected sibling checkout. The CLI and the loader both write the
`mintlify-local` source, so they own the same rows rather than duplicating pages.

### Help-centre links

`HELP_CENTRE_LINKS_ENABLED` in `src/help/links.ts` is the single switch for whether Doug links to
help-centre articles. It is currently `false`, because the help centre is not open to users. While
it is off:

* `search_help` results and advisory help citations carry no article URL.
* The pre-fetched help hint lists no article URLs.
* The fast path does not answer how-to questions itself; they go to the model, which summarises the
  pre-fetched help content. Navigate answers add no help-article link.
* The system prompt tells Doug to quote or summarise help articles and link only the in-app page.

To restore linking, set the constant to `true` and regenerate the system-prompt golden
(`pnpm gen:system-prefix-golden`) and the `search_help` tool snapshot.

## Turn and SSE

A turn is submitted to `POST /api/assistant/conversations/:id/messages`. The route:

1. Acquires a per-conversation concurrency lock (one turn at a time per conversation).
2. Checks the fast path — if the message classifies as a confident navigate or how-to intent it is
   answered directly from the route catalogue and help corpus with no model call.
3. On a fast-path miss, runs the agent loop. The loop calls the configured AI provider, executes
   any tool calls, and appends tool results until the model stops with text or the round limit is
   reached.
4. Streams all events to the client over SSE using the shared event vocabulary.

### SSE event vocabulary

| Event | Payload | Description |
| - | - | - |
| `token` | `{ delta: string }` | A streaming text chunk from the model. |
| `tool_start` | `{ toolCallId, tool, label }` | A tool call has begun. |
| `tool_end` | `{ toolCallId, ok, summary? }` | A tool call completed or failed. |
| `ui_action` | `{ action, … }` | Client-side action, e.g. `navigate` or `draft_review`. |
| `citations` | `{ messageId, refs[] }` | Source references for the completed assistant message. |
| `message` | `{ messageId, role }` | A message was persisted (anchor for citations). |
| `continuation_claimed` | `{ draftId }` | A resume turn consumed the pending continuation for the named draft. Emitted once, immediately after the claim commits and before any model call. Never emitted on an ordinary turn or when the claim is lost to a concurrent request (that path emits `error { code: "continuation_unavailable" }` then `done`). Once received, a retry must be sent as an ordinary turn; resending `resumeDraftId` returns `409 continuation_unavailable`. |
| `error` | `{ code, message }` | A terminal error. |
| `done` | `{ stopReason, usage }` | A server-completed turn ended; it is the last frame when emitted. A client disconnect or aborted fetch can close without this frame. |

A heartbeat comment frame (`: ping`) is emitted every 15 seconds to prevent proxy timeouts.
Proxy buffering is disabled via `X-Accel-Buffering: no`.

### Advisory event ordering

An advisory answer is assembled by the terminal tool rather than streamed from free model prose.
When it completes, the server emits the full answer as one `token` event, then `citations`, then
`message`, followed by any formalisation `ui_action`. Citations must arrive before `message`, because
the message event finalises and clears the in-flight bubble. This order is enforced across all
provider paths.

Because the handler hijacks the Fastify response for raw SSE, it copies the CORS headers computed
before the hijack into the stream response. The caller's bearer token is retained only for the turn
and is forwarded to downstream tools; it is never persisted.

### Agent loop safety

Provider rounds are bounded by `ASSISTANT_MAX_TOOL_ROUNDS` (default 8), per-call output by
`ASSISTANT_MAX_TOKENS` (default 2,048), and model history by
`ASSISTANT_HISTORY_MAX_MESSAGES` (default 40). A concurrent turn on the same conversation returns
`409 turn_in_progress`.

Turn serialisation uses two layers. An in-memory lock provides the fast path within a single instance. A database lease (`turn_lease_id` / `turn_lease_until` columns on `assistant_conversations`, added by migration `0006_turn_lease.sql`) extends this to multiple Lambda instances: one conditional UPDATE acquires the lease; any other instance with a live lease on the same conversation receives `409 turn_in_progress`. The lease is released inside `finishTurn` before the `done` frame so that an immediate follow-up is not refused, and again in the `finally` block for all other exits (abort, cancel, pre-stream replies, unexpected errors). A crashed turn's lease expires after 330 seconds (the 300-second Lambda timeout plus margin). Applied migrations are never edited; new columns always go in a new migration file, because drizzle's migrator matches by journal timestamp rather than file content.

The informal pre-assessment and formal-run tools poll for up to 180 attempts with a default
one-second delay. `ASSISTANT_PREASSESS_POLL_ATTEMPTS` and
`ASSISTANT_PREASSESS_POLL_DELAY_MS` can adjust those bounds. The default three-minute ceiling
covers the slower real-engine runs observed during local verification; CI fixtures settle
immediately.

Every turn creates a cryptographic nonce and fences untrusted user text, UI context, tool results,
and pre-fetched help/route hints before they enter the model prompt. The deterministic navigation
fast path answers confident navigation or how-to requests without a provider call; hits and misses
are still audited.

### System prompt and company context

`src/turn/systemPrompt.ts` builds the system prompt in two parts.

The **stable prefix** (`buildSystemPrompt`) is the same for every company. It names the assistant
Doug, who introduces himself on greeting or when asked. It also carries the confirm-before-write
rule, grounding and citation rules, and the untrusted-data preamble. Non-advisory turns add
paragraphs on estimate selection, contractor companies, API discovery, and multi-step tasks. The
contractor-company paragraph separates a contractor company (a Stralos company whose people work in
other companies through a company link) from a subcontractor (a Companies register entry used for
pricing). It tells the model to read `GET /api/global/org/context` for company names rather than
guess. Advisory turns omit it because their registry has no discovery tool to call that route.

The **company context** (`buildOrgContext`) is a short trusted paragraph appended after the prefix,
built from the verified token. It states:

* The user's role: admin, standard member, or service account.
* Whether this is a standard or contractor company, from the token's `plan` claim. Anything other
  than `contractor`, including an absent claim, is treated as standard.
* Whether the user is one of the company's own people or a contractor person, from the presence of
  a `via` claim.

The context uses fixed sentences and never includes names, so prompt-cache entries stay bounded to
the role, plan and person combinations.

The prefix tells Doug to call the user's business their **company** (never "organisation") and to
say **admin** (never "administrator"). Every other text the model reads from this service follows
the same rule: tool descriptions, out-of-scope reasons, the daily-limit message, and route catalogue
labels and descriptions. `test/companyWording.unit.test.ts` enforces this for every role, plan and
person variant.

## Tools

### Per-policy registry

The tool registry is narrowed for each turn policy. Default turns cannot call
`preassess_variation`, `get_assessment_run`, or `emit_variation_advisory`, preventing a
free-prose turn from retrieving and restating an entitlement verdict. Advisory turns cannot call
write tools; their only route to a draft is the server-owned formalisation handoff after an eligible
informal verdict.

### OpenAPI operation discovery

Three additional tools let the assistant search and execute the live OpenAPI 3.1 contracts of the four configured application services (`global`, `estimate`, `rates`, `scheduler`) without a hand-written tool per operation.

Each service's base URL is read at call time from `STRALOS_GLOBAL_API_URL`, `STRALOS_RATES_API_URL`,
`STRALOS_ESTIMATE_API_URL`, or `STRALOS_SCHEDULER_API_URL`. All four are required and have no
default. A missing or blank variable throws `ServiceUrlNotConfiguredError`, which names the variable
and is reported as a configuration error rather than an unavailable service. Terraform sets all four
on the API Lambda and `stralos-local` sets them locally.

| Tool | Purpose |
| - | - |
| `discover_api_operations` | Search operation IDs, summaries, tags, paths and request fields with pagination. An operation matches when any search word matches the start of a word in it; results rank by how many words matched, then by score. A few user words also search for the terms the contracts use (for example supplier, quarry and price find option and location operations; build-up line finds component; markup finds margin; calculation finds calculator). The service behind the current page gets a ranking boost. Unsupported operations sort after available ones, and blocked operations sort last. Unavailable services are reported explicitly. Blocked operations are included, marked `supported: false` and `outOfScope: true` with a plain user-facing reason. |
| `get_api_operation` | Retrieve the request schema and contract hash for a selected operation. |
| `execute_api_operation` | Validate `{ path, query, body? }` against that schema. An explicitly read-only GET returns bounded JSON. POST, PUT, PATCH, and GET operations marked as writes stage an ordinary review draft. No target write happens until the creator accepts that draft. |

Discovery tools and their system-prompt paragraphs are excluded from advisory turns; the advisory registry has no such tools.

#### Discovered draft behaviour

Discovered drafts persist the server-resolved operation identity, contract hash, and exact request. Their nested values are shown as labelled read-only fields. Revisions are restaged, not converted through the scalar inline editor.

At accept time the contract is fetched afresh. Only a genuine change to that operation rejects the proposal as `draft_stale`: the operation was removed, its identity or contract hash changed, it is no longer an available write, or the stored request no longer satisfies its schema. Anything else — the contract could not be fetched or parsed, the operation was quarantined as malformed, or its schema could not be compiled — leaves the draft pending and returns `503 { code: "api_contract_unavailable" }` so the user can retry.

A malformed operation (missing or duplicated `operationId`, or unreadable) is quarantined and logged; the rest of that service's contract is still served.

Request schemas are validated with Ajv (2020-12, formats, OpenAPI `discriminator`, no coercion or defaults); compiled validators are cached per contract hash. An operation that requires a credential-like field (for example a subcontractor portal token or a rekey `newToken`) is listed as unsupported, because the request credential guard would always refuse it.

Contract metadata is cached for 60 seconds. Token and record data are never cached there. Read responses are capped at 100 KB, request schemas at 100 KB, contracts at 8 MB, and proposals at 200 displayed values.

Only configured service origins are contacted. Model-selected URLs/headers, external or recursive schema references, DELETE, public operations, required custom headers/cookies and non-JSON request bodies are unsupported. A discovered operation never follows a redirect: a read reports "The X API redirected the request", and an accepted write answered with a 3xx is reported as ambiguous.

#### Operations the assistant never proposes (R1)

`BLOCKED_API_OPERATION_RULES` in `src/discovery/sensitive.ts` blocks writes to six families, matched on service + path template. Reads on the same paths remain available. Blocked operations stay visible in discovery but are flagged `outOfScope: true`; the model tells the user plainly that the operation is something they need to do themselves in the app and never attempts it, looks for a workaround, or stages a draft. The same text is used as the staging refusal and the `409 { code: "operation_blocked" }` accept refusal. A pending draft staged before the rule existed is terminally rejected before any contract fetch, claim or target call.

| Category | Paths blocked for writes |
| - | - |
| `users_roles_membership` | `/api/global/org/users*`, `/api/global/users`, project team/access-requests/self-join |
| `organisation_settings` | `/api/global/organisations*`, `/api/global/org/organisation*`, `/api/global/organisation-settings*` |
| `app_config` | `/api/global/app-config*` |
| `lifecycle_transition` | Any path ending in `/transition` across all services |
| `bulk_import` | Rates `rate-imports`/`project-rate-imports`/day-rate schedule import; estimate `import-project`; scheduler `plan-tasks/import` |
| `contractor_companies` | `/api/global/org/client-companies*`, `/api/global/org/contractors*`, `/api/global/org/contractor-invites*`, `/api/global/org/contractor-copy-jobs*`, `/api/global/org/contractor-copy*`, `/api/global/org/contractor-refresh*` |

The read-only `GET /api/global/org/context` is outside these prefixes and stays available.

### Read tools

Read tools query downstream services using the caller's forwarded bearer token. They never write
and return structured data to the model. The exact REST set is:

* `list_projects` and `get_project`: both include `jobNumber` (the organisation's internal job number, e.g. `JOB-0003`, present once an estimate is marked Won) and `reference` (the client's own reference) when present; either field is omitted from the result when the project has no value for it
* `list_estimates`, `get_estimate_summary`, and `list_work_items` — see [Estimate selection](#estimate-selection) below
* `search_rate_items` and `get_rate_item` — see [Rate item options](#rate-item-options) below
* `list_rate_categories`
* `list_office_locations`: see [Office location on create\_project](#office-location-on-create_project) below
* `list_client_contacts`: see [Client contact on create\_project](#client-contact-on-create_project) below
* `get_project_schedule`
* `list_variations`, `get_variation`, and `get_projected_final_account`
* `get_project_rate_settings`: a project estimate's ordered rate rules and pricing location, offered on default turns only; see [Project rate rules](#project-rate-rules)

The non-REST read/navigation tools are `search_help`, `get_route_catalog`, and `navigate_to`.

List responses are capped, composite reads make a bounded number of calls, and downstream
authorisation remains authoritative. `navigate_to` emits a UI action only after the path matches the
vendored route catalogue; chromeless capability routes are excluded.

#### Estimate selection

A project can hold several estimates: tender alternatives and, after award, a Delivery estimate.
`list_estimates` returns each estimate's `kind` (`tender` or `delivery`), `isPrimary`, `outcome`,
and `hidden` flag. The primary estimate — the one that opens on project load — is always listed
first. Saved-version rows (frozen snapshots) are excluded from the results.

On a project-filtered call (`list_estimates` with a `projectId`), the response also includes
`primaryEstimateId`: the UUID of the project's primary estimate. This is the fallback the model
uses when neither the user nor the UI context names a specific estimate.

The non-advisory system prompt instructs the model to resolve which estimate to use in this order:

1. The estimate the user names explicitly.
2. The `estimateId` from the UI context (the estimate the user has open in the app).
3. The project's primary estimate, resolved via `list_estimates`.

When an answer draws on more than one estimate, the model attributes each figure to its source
estimate. Advisory turns are unaffected: they already use the UI context's estimate and ask when
ambiguous.

### Write tools

Write tools never issue a target HTTP call directly. Each write tool stages exactly one `pending`
record-draft and emits a `draft_review` `ui_action` event so the UI can render a review card. The
only code path that performs a target write is `POST /api/assistant/drafts/:id/accept`.

The ten v1 write tools are:

| Tool | Target operation |
| - | - |
| `create_client` | Create a company record with the Client role in the merged Companies register (`POST /api/global/parties`) |
| `create_project` | Create a project for a client (office location and client main contact required; client reference optional; the legacy `generateJobNumber` input has no effect) |
| `create_client_contact` | Add a contact (a person) to a client (`POST /api/global/parties/:id/contacts`) |
| `create_work_item` | Add a work item to an estimate |
| `update_work_item` | Merge-patch fields on an existing work item |
| `create_rate_item` | Add a rate item to the organisation rate library (admin only at target) |
| `raise_variation_draft` | Raise a draft variation on an estimate |
| `add_variation_line` | Add a line to an editable variation |
| `run_variation_assessment` | Trigger the AI variation assessment on a variation |
| `create_assignment` | Create a resource assignment on a project schedule |

A turn stages at most three drafts (approval-fatigue cap). If a turn reaches the cap the tool
returns a message asking the user to narrow the request; already-staged drafts remain valid.
`ASSISTANT_MAX_DRAFTS_PER_TURN` can lower this cap but cannot raise it above three.

`choose_line_rate_option` stages one draft per build-up line to change which rate option (supplier,
location and price) the line is priced from; the model finds the lines and matching options with
the `list_build_up_lines` read tool. Its drafts have their own per-turn budget of 20, so a request
such as "change all bulk materials to come from a quarry" can pin many lines in one turn. Only
`choose_line_rate_option` drafts count against it; every other tool keeps the cap of three.
`ASSISTANT_MAX_LINE_CHOICE_DRAFTS_PER_TURN` can lower this budget but cannot raise it above 20.

#### Project rate rules

`set_project_rate_rules` and `reprice_lines_for_rate_rules` (`src/tools/writeTools.ts`) let Doug change a project estimate's rate rules, the ordered preference list that decides where lines are priced from. The system prompt has Doug use a rule only when the user says "default" or "rule", for example "make Fulton Hogan Ensors Road the default for bulk materials". Any other request to change where lines are priced from goes line by line through `choose_line_rate_option`.

`set_project_rate_rules` takes up to 10 rules. Each names exactly one scope (`rateGroup`, `categoryId`, or `rateItemId`) and a source: `supplier` and optional `location` as the names the user said, neither for your own company's rates, or `cheapestWithinKm`. The tool resolves names against the company's register and refuses an ambiguous or unknown name with candidates, so Doug can ask. It then merges the rules into the stored list, asks api-estimate for a rule-change preview, and stages a draft for `PUT /api/rates/project-rate-settings/rule-set` carrying `expectedSettingsUpdatedAt`, so a list changed since staging fails at accept with `409 settings_changed`. When the preview moves at least one price, it stages a second `reprice_lines_for_rate_rules` draft bound to the preview's `impactDigest`; the user accepts the rules first. Every figure and name on both cards comes from the services, so neither draft is editable. A submitted estimate is refused at staging.

Call `reprice_lines_for_rate_rules` alone only to re-price lines against rules that are already stored, for example when the paired re-price draft could not be staged earlier.

#### Markup not included on create\_work\_item and update\_work\_item

Neither `create_work_item` nor `update_work_item` accepts a `markupPct` field. The estimate API does not store markup on the work item record itself; markup belongs to the activities and materials within a work item. Staging a draft with a markup value would cause the accepted write to silently drop the field.

#### itemKind values for create\_work\_item and update\_work\_item

The `itemKind` field accepts `measured`, `provisional_sum`, and `provisional_quantity`. `lump_sum` is not a valid kind. A lump sum is an ordinary measured item — use `itemKind: "measured"` (or omit the field; it defaults to `measured`) and set the unit to `LS`.

`allowanceAmount` applies to provisional sums only. It sets the provisional sum's price; its quantity is always 1 and it takes no build-up, sub-items, or margin. For any other item kind `allowanceAmount` is ignored.

Changing an existing item to `provisional_sum` deletes its build-up and sub-items. When staging such an update, the draft review card must tell the user plainly that accepting it removes them.

Any `create_work_item` or `update_work_item` draft staged before this change that carries `itemKind: "lump_sum"` will be rejected as `draft_stale` at accept time. The user must start a new turn to restage the draft without that field, or with `itemKind: "measured"` and unit `LS`.

#### Client reference and job number flag on create\_project

`create_project` accepts two optional project identity fields:

* `reference` — the client's own reference for the project (e.g. a purchase-order or tender number). This field is optional. The assistant does not require it before proposing a draft.
* `generateJobNumber`: still part of the tool schema and shown on the review card as **Generate job number**, but api-global now ignores it. Every created project gets a quote number automatically, and a job number is allocated only when an estimate is marked Won.

Neither field changes the other required inputs (`clientId`, `name`, `latitude`, `longitude`, `officeLocationId`, `clientContactId`).

#### Office location on create\_project

`create_project` requires `officeLocationId`: the UUID of the organisation's own office or depot that runs the project, not the site address. api-global rejects a project create without it.

The model resolves the value with the `list_office_locations` read tool, which calls `GET /api/global/locations?owner=ours&status=active` and returns each active office's `id`, `name`, `kind`, and `city`. That set is exactly what api-global accepts as a project office; any other location ID is refused at accept time with `422 office_location_invalid`. The tool description tells the model to use the office automatically when the organisation has exactly one, and otherwise to ask the user which office runs the project.

The optional `preferResourcesFromOffice` boolean defaults to `true` at the target. The review card labels the fields **Office** and **Prefer resources from office**.

#### Client contact on create\_project

`create_project` requires `clientContactId`: the client's main contact for the project, which prints on the tender. api-global refuses a create without it (`422 client_contact_required`) and one that is not an active contact of that client (`422 client_contact_invalid`). The review card labels the field **Main contact**.

The model resolves the value with the `list_client_contacts` read tool, which calls `GET /api/global/parties/:id/contacts` and returns each active contact's `id`, `name`, `role`, `phone`, and `email`, capped at the shared result cap. The `create_project` description tells the model to:

* Suggest the contact when the client has exactly one, and check with the user.
* Ask the user which contact when the client has several.
* When the client has none, ask for the person's name and a phone number or email, propose `create_client_contact` first, and read the contacts again for the new id once the user accepts it.

`create_client_contact` takes `clientId` and `name`, at least one of `phone` or `email`, and an optional `role`. A proposal with neither `phone` nor `email` is refused when staged ("Please add a phone number or an email for the contact."), even though the plain contact route accepts a name alone. `list_client_contacts` is a default-turn read only: advisory turns are not given it, as they cannot call the write tools it feeds.

#### Classification on create\_rate\_item

`create_rate_item` takes a `categoryId` (a UUID) selected from the `list_rate_categories` read
tool, which pages the organisation's rate category catalogue from api-global. Each category row
includes the parent group and allowed unit codes, so the model can surface the right choices to the
user before staging a draft. The rates target derives the group and material status from the
category; `rateGroup` is not a client input and is not present in the tool schema. A pre-M13 stored
draft that still carries `rateGroup` in its payload is forwarded verbatim at accept time; the
target ignores that field.

The `list_rate_categories` response includes `parentGroup` for each category. The six recognised
group values are `labour`, `plant_vehicles`, `quarry_materials`, `other_materials`,
`subcontractors`, and `disposal`. The retired `isOutgoing` field is not included in the response;
the `parentGroup: "disposal"` value supersedes it for categories in the Disposal group.

#### Companies register and create\_client (M6)

The Companies register migration (M6) merged the separate clients table into a unified parties
register. `POST /api/global/clients` no longer exists. The `create_client` tool now targets
`POST /api/global/parties` and the registry supplies `isClient: true` as a fixed body field at
accept time. That endpoint requires at least one role flag; the roles default to false, so
`isClient` must be sent explicitly and cannot be omitted.

The role flag is applied at accept, not at stage, for two reasons:

* **Stored-draft compatibility.** Drafts do not expire. Any `create_client` draft staged before
  this release carries no role flag in its stored body. Applying the flag at accept repairs those
  drafts without requiring a migration.
* **Tamper resistance.** The tool body schema strips unknown keys, so neither the model nor a
  `PATCH /drafts/:id` edit can insert or change the role flag. The `isClient` value is fixed by
  the tool's registry entry and is stated in the review card sentence the user confirms.

The tool name stays `create_client` and `resource` stays `"client"` because both are stored on
every existing draft and action row. A rename would orphan pending drafts; the accept executor
would find no registry entry and the staleness guard would terminally reject the stored card.

#### Plant pool bookings on create\_assignment

When `resourceType` is `plant_placeholder`, `create_assignment` identifies the pool by a library plant rate item rather than a sub-category. The field `placeholderRateItemId` is required and must be a rate item UUID from the organisation's rate library (for example, retrieved via `search_rate_items`). The legacy `placeholderSubcategoryId` field is not accepted; any draft carrying it is rejected at the structural validation step.

An optional `quantity` field (integer, 1–20) allows the assistant to propose booking multiple pool units in a single draft. When omitted, `quantity` defaults to `1`. The review card summary reads "Create N plant pool bookings from DATE to DATE." and includes a `quantity` row in its field table. The scheduler service creates all N placeholder rows in one atomic transaction; the draft accept returns `{ "items": [...] }` rather than a single object when `quantity` is greater than `1`.

`quantity` is only valid on `plant_placeholder` bookings. Supplying it for any other `resourceType` is rejected with a structural validation error before the draft is staged.

#### Rate item options

`get_rate_item` is a composite read: it fetches the item record from `GET /api/rates/rate-items/:id`
and then pages all of its rate options from `GET /api/rates/rate-items/:id/options`. The deleted
legacy `/prices` route is no longer called.

The result contains an `item` object with its id and description plus category when available. The current Rates API no longer supplies item-level code or unit fields, so the assistant omits those absent keys. The `options` array carries the supplier code and priced unit for each choice.
Each option row includes:

| Field | Type | Notes |
| - | - | - |
| `id` | string | Option identifier |
| `scope` | string | `"organisation"` or `"supplier"` — who owns the price |
| `supplierId` | string \| null | Supplier `parties.id`; non-null exactly when `scope` is `"supplier"` |
| `locationId` | string \| null | Location id; null means the option applies to all of the owner's locations |
| `supplierName` | string \| null | Resolved name of the supplier company, or null for organisation-scoped options |
| `locationName` | string \| null | Resolved name of the location, or null |
| `sourceItemCode` | string \| null | Supplier's own item code, or null |
| `condition` | string | Condition label (e.g. `standard`, `night`) |
| `basis` | string | `fixed` or `multiplier` |
| `basisMultiplier` | number \| null | Multiplier factor; present only when `basis` is `multiplier` |
| `unit` | string | Pricing unit |
| `conversionFactor` | number \| null | Raw conversion scalar returned for the option. The assistant tool does not expose the numerator and denominator orientation fields, so callers must not infer its direction or perform a conversion from this value alone. Read the full Rates option contract when orientation matters. |
| `costRate` | number \| null | Effective cost rate. For multiplier options this is `derivedCostRate` when available, otherwise `costRate`. Null means the rate service cannot resolve a live figure. |
| `currency` | string | Currency code (e.g. `NZD`) |
| `provenance` | string | Origin of the rate (e.g. `price_list`, `manual`) |
| `validFrom` | string | ISO 8601 date-time from which the rate applies |
| `validTo` | string \| null | ISO 8601 expiry, or null for open-ended rates |
| `status` | string | `active` or other status values |
| `needsReview` | boolean | Whether the rate service has flagged this option for review |

When the option count exceeds the 50-row cap, the result includes `optionsTruncated: true` and
`summary.optionCount` reports the upstream total rather than the capped count.

### Variation-advisory tools

These six tools support turns classified as variation-entitlement, notice-window,
formal-assessment, or projected-total questions. The verdict and terminal tools
`preassess_variation`, `get_assessment_run`, and `emit_variation_advisory` are advisory-only.
The three premise/computed read tools remain available to default turns.

| Tool | Purpose |
| - | - |
| `get_contract_profile` | Required premise: read the project's contract basis. |
| `get_active_baseline` | Required premise: read the estimate's active frozen baseline. |
| `get_notice_status` | Compute notice-clock facts from stored dates. |
| `get_assessment_run` | Poll a formal assessment run and retrieve its engine verdict and evidence. |
| `preassess_variation` | Enqueue and poll an informal engine assessment without creating a variation. |
| `emit_variation_advisory` | Validate sources and compose the forced-terminal answer. |

#### Classification and premise gate

A deterministic classifier selects the advisory policy before a provider call. Judgement questions
such as “is this a variation?”, notice-window questions, questions about an existing formal
assessment, and projected-final-account questions are advisory. Plain lookups and explicit write
instructions remain default turns. A deliberative question such as “should we raise this?” is
advisory, while “run the assessment” remains a confirm-before-write instruction.

The terminal tool requires a contract profile and an active baseline fetched in the same turn and
checks that the estimate belongs to the project. Blank UI context does not satisfy this gate, and
context remains an untrusted hint rather than authorisation.

#### Server-owned answer and citations

The entitlement verdict comes from the same assessment engine as the formal Variations flow. The
server injects that verdict, deterministic notice and money facts, and the non-authoritative
disclaimer. Model-authored claims that state a verdict, date, or money figure are removed.

Every surviving claim needs a citation validated against material retrieved during the current
turn. Engine tag and document quotes must match the engine evidence; work-item and contract-profile
references must match fetched records; help quotes must match the returned help chunk. Invalid
references are dropped, and a claim left without support is not delivered. Citation identifiers are
rewritten to renderer-safe values before SSE delivery.

The server refuses and escalates instead of guessing when premises are missing or mismatched, the
engine returns insufficient information or no supported verdict, or no grounded claim survives.

#### Formalisation handoff

An informal verdict of `instructed_variation`, `notifiable_variation`, or
`other_price_adjustment` can stage a `raise_variation_draft` review card. `in_scope` and
`insufficient_information` cannot. The draft is validated against the frozen write-tool schema,
uses source `advisory_formalize`, and includes the pre-assessment and candidate-line context for
review. Nothing is written to the Estimate service until the user accepts the card.

At accept time the service compares the pre-assessment baseline with the current baseline. A change
does not block creation, but the accepted response reports `baselineDrift` so the shell can tell the
user to reassess. Accepting `run_variation_assessment` polls inside the request for up to five
seconds by default; if the run is still pending, the Variations tab completes the
persist-on-observe step.

## Persistent task progress

Migration `0005_task_progress.sql` adds message progress and a durable pending-continuation flag on drafts.

Tool steps are saved before execution and after the result, then hydrated with their original message on reload. A failed save is logged and never fails the turn. An unfinished step is shown as not having reported completion after reopening; it is never shown as success.

Each finished step's text is a plain sentence from `src/tools/progressNote.ts`, for example "Found 2 build-up lines", never JSON. Every registered tool has an entry (`test/progressNote.unit.test.ts` walks the registry); an unrecognised summary falls back to "Finished this step". Failed steps read "That step isn't available" (`unknown`), "The details didn't fit, so this step didn't run" (`invalid`), or "This step didn't work" (`threw`). The summary object stored as the audit row's `result_summary` is unchanged. A step saved earlier with raw JSON as its text has that text dropped on load (`src/turn/dto.ts`), so it shows its label alone.

## Approval continuation

Every successful acceptance — curated or discovered — marks a continuation pending in the same transaction as the accepted draft. The panel starts the next normal authenticated, budgeted SSE turn with `resumeDraftId` immediately after accept.

The server checks ownership, conversation, accepted status and the pending flag before streaming. A check failure returns JSON `404` or `409 continuation_unavailable` before any stream opens. When the check passes, the server consumes the flag and records the continuation message in one transaction, only when the turn commits to an answer (just before the model is called). A disconnect before that point leaves the continuation pending; a request that loses a race to a concurrent request receives an in-stream `error { code: "continuation_unavailable" }` then `done`.

An ordinary user message clears every pending continuation in the conversation in the same transaction as the message insert, so a stale approval can never replay after the user sends an unrelated message.

A continuation runs under the loop policy of the request that staged its draft — specifically, the newest non-continuation user message before the draft's anchor message — not the conversation's latest message. A draft proposed during request A and accepted after an unrelated request B continues under A's policy. The model is told which request the approval belonged to as a fenced hint.

No credentials are stored and no background worker runs after the browser disconnects. Every turn receives current draft states so the model can verify accepted writes and avoid repeating proposals.

## Confirm-before-write

The `assistant_drafts` table records every write proposal from the model. The lifecycle is:

```
pending → applying → accepted
                   ↘ pending   (target 4xx — user can fix and retry)
                   ↘ applying with `last_error`  (ambiguous hold after an indeterminate outcome)
       → rejected
       → superseded  (replaced by a newer proposal for the same resource)
```

### Draft routes

| Route | Description |
| - | - |
| `GET /api/assistant/drafts/:id` | Fetch a draft to render or rehydrate a review card. |
| `PATCH /api/assistant/drafts/:id` | Persist a user edit of the draft body (structurally re-validated; CAS on status and optional `ifVersion`). |
| `POST /api/assistant/drafts/:id/accept` | Execute the write against the target service with the caller's bearer token. |
| `POST /api/assistant/drafts/:id/reject` | Reject a pending draft; no target call is made (idempotent). |
| `GET /api/assistant/conversations/:id/drafts` | List all drafts for a conversation (panel rehydration after a refresh). |

### Accept safety rules

* The `ifVersion` field is required on accept. A stale version returns `409`.
* The accept executor reads the payload from the claimed row and never re-reads it (what is written is exactly what was reviewed).
* The caller's bearer token is forwarded to the target. The assistant service never mints tenant tokens.
* The target's own authorisation is the authority — a member who lacks permission will receive a `403` from the target, which transitions the draft back to `pending` with the error recorded in `last_error`.
* A target `5xx`, network failure, timeout, or unexpected non-success response leaves the draft in
  `applying` and records `last_error`; the API projects this as an ambiguous hold. The user can
  acknowledge and retry with `force: true`.
* Scheduler `503 peer_unavailable` is the definitive pre-insert exception and returns the draft to
  `pending`; other unexpected non-success responses remain ambiguous.
* The reconciler marks orphaned `applying` attempts ambiguous by setting `last_error`. It runs once
  during startup before the service listens and then periodically while the service is running.

### Accept-time staleness guard

Before claiming a draft, the accept route re-validates the stored body against the **current** tool
schema. A draft staged before a schema change — for example, a `create_rate_item` draft
pre-dating the since-retired `rateGroup` requirement, or a `create_work_item` / `update_work_item`
draft carrying the now-invalid `itemKind: "lump_sum"` — would otherwise be forwarded verbatim and
rejected by the target with a raw `400`. Because validation strips unknown keys, only a NEW
requirement or a removed enum value can make a stored draft stale; an added optional field never does.

When the stored body no longer satisfies the current schema, the accept route:

1. Terminally rejects the draft with reason `draft_stale` using a full compare-and-swap on both
   status and version, so an accept from a stale card can never accidentally reject a newer edit
   that already added the missing field (that case returns `409`, draft untouched).
2. Returns `200` with `outcome: "draft_stale"` and a `missingFields` array naming the fields that
   must be supplied (e.g. `["category"]`).

The accept route never re-stages a replacement. The user must start a new turn and restage the
draft with the required fields filled in.

### Draft payload shape

```json theme={null}
{
  "params": { "estimateId": "…" },
  "body": { "title": "…", "type": "instructed" },
  "reviewContext": { "candidateLines": […] }
}
```

Only `body` is sent to the target. `params` fills path parameters. `reviewContext` is card-only
context (e.g. suggested lines for a variation) and is stripped before the target write.
`originalPayload` is never mutated; it records the model's first proposal for audit and divergence
logic. The UI's before/after diff uses `beforeSnapshot`.

## Navigation catalogue

The service vendors the JSON route catalogue generated by `stralos-shell` (`src/navigation/routeCatalog.json`)
and loads it lazily. Tests enforce the pinned catalogue digest and, when the shell artifact is
available, byte parity. The runtime does not perform a cross-repository digest check at startup.

The catalogue is used in three places:

* **Fast path** — deterministic navigate and how-to responses score and resolve routes directly from the vendored catalogue.
* **Model tools** — `get_route_catalog` projects the available catalogue and `navigate_to` validates a concrete path against it; chromeless routes such as the contractor portal are excluded.
* **Advisory citation deep links** — `projectRoute()` resolves citation URLs (Estimate, Variations, Project Settings) from the catalogue rather than by hand-interpolation. This means a route re-path arrives with the catalogue re-vendor and cannot drift silently. The canonical Variations path as of the M3 navigation migration is `/projects/:projectId/commercial/variations`; any citation to the Variations page uses that path.

For the shell catalogue and `window.__STRALOS_NAV__` bridge, see
[Frontend architecture](https://help.stralos.app/internal/frontend-architecture.md#route-catalog).

The current catalogue contains 32 entries. `group` provides grounding context only; it does
not affect route matching or validation.

| Id | Label | Path template | Scope | Visibility | Group |
| - | - | - | - | - | - |
| `dashboard` | Dashboard | `/dashboard` | Global | Primary | — |
| `projects` | Projects | `/projects` | Global | Primary | — |
| `rates` | Rates Library | `/rates` | Global | Primary | Resources |
| `project-rate-import` | Import project rates | `/projects/:projectId/estimate/builder/rates/import` | Project | Hidden | — |
| `activities` | Construction Activities | `/activities` | Global | Primary | Resources |
| `people` | People | `/people` | Global | Primary | Resources |
| `crews` | Crews | `/crews` | Global | Primary | Resources |
| `plant` | Plant | `/plant` | Global | Primary | Resources |
| `companies` | Companies | `/companies` | Global | Primary | Resources |
| `company` | Company | `/companies/:id` | Global | Hidden | — |
| `locations` | Locations | `/locations` | Global | Primary | Resources |
| `location` | Location | `/locations/:id` | Global | Hidden | — |
| `location-new` | New location | `/locations/new` | Global | Hidden | — |
| `scheduler` | Company scheduler | `/scheduler` | Global | Primary | Resources |
| `global-settings` | Settings | `/settings` | Global | Primary | — |
| `project-overview` | Overview | `/projects/:projectId` | Project | Primary | — |
| `estimate` | Estimate | `/projects/:projectId/estimate` | Project | Primary | — |
| `delivery-estimate` | Delivery estimate | `/projects/:projectId/delivery` | Project | Primary | Delivery |
| `delivery-compare` | Compare to tender | `/projects/:projectId/delivery/compare` | Project | Primary | Delivery |
| `delivery-sign-off` | Sign-off | `/projects/:projectId/delivery/sign-off` | Project | Primary | Delivery |
| `delivery-variations` | Variations in Delivery | `/projects/:projectId/delivery/variations` | Project | Primary | Delivery |
| `plan` | Delivery programme | `/projects/:projectId/delivery/programme` | Project | Primary | Delivery |
| `schedule` | Schedule | `/projects/:projectId/delivery/schedule` | Project | Primary | Delivery |
| `variations` | Variations | `/projects/:projectId/commercial/variations` | Project | Primary | Commercial |
| `documents` | Documents | `/projects/:projectId/documents` | Project | Primary | — |
| `project-settings` | Project Settings | `/projects/:projectId/settings` | Project | Primary | — |
| `compliance` | Compliance | `/compliance` | Global | Primary | Resources |
| `calculator-new` | New calculation | `/projects/:projectId/estimate/builder/calculators/new/:calculatorType` | Project | Hidden | — |
| `calculator` | Calculation | `/projects/:projectId/estimate/builder/calculators/:calculatorId` | Project | Hidden | — |
| `subcontract-bid-review` | Review contractor return | `/projects/:projectId/estimate/builder/subcontractors/:packageId/review` | Project | Hidden | — |
| `contractor-portal` | Contractor Portal | `/contractor-portal/:portalToken` | Global | Chromeless | — |
| `work-items-export` | Work Items Export | `/projects/:projectId/estimate/builder/work-items/export` | Project | Chromeless | — |

`get_route_catalog` returns every non-chromeless entry. Its projection includes `group` only when
the catalogue entry has one and does not expose the `nav` field. `navigate_to` rejects paths that
do not match a known non-chromeless entry.

`navigate_to` also accepts canonical estimate paths of the form
`/projects/:projectId/estimates/:estimateId/…`. These are matched as their bare twin
`/projects/:projectId/estimate/…` before catalog lookup, mirroring the shell's own
`bareEstimatePath` rule. The path is passed to the client unchanged; only the matching uses the
bare form. The bare form `/projects/:projectId/estimate` continues to open the project's primary
estimate.

### How-to answer format

The how-to fast path runs only while help-centre links are enabled; see
[Help-centre links](#help-centre-links). While they are off, how-to questions go to the model.

A how-to fast-path hit requires the top help article to score at or above
`TAU_HOWTO` (default 0.6). A confident answer is then assembled from up to three
parts, in order:

1. **Page reference** — when exactly one route in the catalogue scores at or above `TAU_NAV` (default 0.8), the answer opens with the page name. If its path can be resolved from route context (currently the project id), the name is a deep link; otherwise it is bold text. The how-to fast path never emits a `navigate` `ui_action` — it names the page as context only.
2. **Help section link** — the answer then points to the specific authored section of the matched help article (e.g. "See the *Adding and editing lines* section of Variations in the help centre for the full walkthrough"). The section name comes from the heading path stored at ingest time. When the matched chunk is the article preamble with no sub-section, the article title is linked without a section qualifier.
3. **Capability offer** — when the query matches a supported write operation and that tool is present in the turn's scoped registry, a sentence is appended offering to start a draft. A non-matching query still receives the deterministic how-to answer; only the offer is withheld.

#### Capability offer grammar

Most offers match queries of the exact form `verb [determiner] singular-operation-noun`, anchored at both ends after stripping the how-to lead-in ("how do I …", "how to …"). The variation-line rule also accepts `verb [determiner] line (to|in|on|of|for) [determiner] variation`. No prenominal modifiers are accepted — quantities, participles, or qualifying adjectives cause the pattern to not match and the offer to be withheld. The five supported operations are:

| Tool | Example matching request |
| - | - |
| `add_variation_line` | add a variation line / add a line to a variation |
| `raise_variation_draft` | raise a variation / create a variation |
| `create_work_item` | add a work item / enter a work item |
| `create_rate_item` | add a rate item / create a rate |
| `create_assignment` | create an assignment / book a crew |

When the offer fires, the sentence asks for the tool's required fields and names any project or estimate context that is still missing. For `create_rate_item` the offer asks for the name, category, and unit; the model then resolves the required category id via `list_rate_categories` rather than asking the user to supply a rate group. If the offer is withheld because the query contains a modifier, uses an unsupported verb, or lacks the tool in the scoped registry, the how-to response still completes without a model call. A later ordinary turn can make an appropriate offer.

All audit records for how-to fast-path hits record the matched article id, the route id (if one matched), and the offered tool name (if an offer was made).

## AI providers

| Provider | Transport |
| - | - |
| `bedrock` | AWS Bedrock Converse |
| `codex` | Local Codex CLI |
| `openai` | OpenAI Responses API |
| `anthropic` | Anthropic Messages API |
| `google` | Gemini `generateContent` API |
| `fixture` | Deterministic CI and integration-test transcripts |

Live providers are selected by `AI_PROVIDER` or the SSM parameter named by
`AI_PROVIDER_SSM_PARAMETER`. Environment provisioning writes the shared provider parameter. The
Claude Code runner and its `claude-code` provider are retired. The deterministic fixture is test-only and is selected with the
direct value `AI_PROVIDER=fixture`; it is not an SSM selector value. All live providers use the
`DrivenTurnRunner`; the fixture selection uses a fresh fixture wrapper that delegates to the same
loop. The loop remains responsible for tool execution, validation, persistence, and SSE events
regardless of provider.

### Chat-only provider and model override

Doug's interactive chat turns can use a different provider or model from the shared setting.

| Variable | SSM parameter variable | Purpose |
| - | - | - |
| `ASSISTANT_CHAT_AI_PROVIDER` | `ASSISTANT_CHAT_AI_PROVIDER_SSM_PARAMETER` | Provider for chat turns only |
| `ASSISTANT_CHAT_MODEL_ID` | `ASSISTANT_CHAT_MODEL_ID_SSM_PARAMETER` | Model ID for chat turns only |

Both are optional, and unset is the normal state. Each falls back on its own: an unset provider
keeps the shared provider, and an unset model uses the chosen provider's shared model. `codex` can
be chosen only through the direct environment variable, not through SSM. Structured completions
never read these values; they always use the shared setting (or a [model profile](#model-profiles)).
An absent parameter is re-checked on the SSM cache's normal cadence rather than on every turn.

### Bedrock (Converse)

The Bedrock adapter calls the AWS Bedrock Converse API. Bedrock requires strictly alternating user/assistant roles in the message history. Adjacent same-role messages — for example a turn cancelled mid-tool followed by the next user message, or an unanswered user message — are merged at the Bedrock mapping boundary before the request is sent, with tool results ordered first within a merged block.

### Google (Gemini)

The Google adapter calls the Gemini Developer API `generateContent` endpoint with `x-goog-api-key`. The model resolves from `GOOGLE_MODEL_ID`, then the String parameter named by `GOOGLE_MODEL_ID_SSM_PARAMETER`; the key resolves from `GOOGLE_API_KEY`, then the SecureString named by `GOOGLE_API_KEY_SSM_PARAMETER`. Environment provisioning selects the provider by writing the shared provider parameter; the service reads it through `AI_PROVIDER_SSM_PARAMETER`.

Assistant messages map to Gemini's `model` role and tool results map to `user`. Tool declarations use `parametersJsonSchema`; the forced structured `emit` completion instead uses `responseJsonSchema`. The adapter strips schema constraint keywords that Gemini does not accept before transmission, while the complete local schema still validates the returned value. The serialised `responseJsonSchema` sent to Gemini is capped at 64 KB UTF-8; a schema that exceeds this limit is rejected before the provider call.

Gemini pairs a `functionResponse` by function name rather than tool-call ID, so rehydration recovers the function name from the stored tool-use block. A round ending with `MAX_TOKENS` withholds all returned tool calls to avoid persisting a tool use that can never receive a matching result.

Gemini 3.x stamps a `thoughtSignature` on each `functionCall` part and rejects a continuation that replays the call without it (`HTTP 400 INVALID_ARGUMENT`). The adapter extracts this value from the model response and echoes it back verbatim when replaying the call in the next request. The signature is opaque and provider-internal; no other part of the service reads or synthesises it.

The adapter makes at most four attempts, including the first, for `429`, `500`, `502`, `503`, `504`, and transient connection failures. It uses 500 ms exponential backoff capped at 8 seconds with equal jitter and honours `Retry-After`. `400`, `401`, `403`, `404`, `501`, and aborts are not retried. The caller's `AbortSignal` covers the entire request-and-backoff sequence.

## Private structured completions

`stralos-api-assistant` is the only deployed service authorised to select a model provider or read
model credentials. Other trusted same-account services that need AI-generated structured output
invoke a dedicated IAM-only Lambda rather than calling a model provider directly.

### Gateway topology

The `${service}-structured-completion` Lambda is deployed alongside the assistant API in the same
Terraform stack. It has no API Gateway, no Function URL, and no database credential. Its IAM role
can only read provider configuration and credentials from SSM, invoke the selected model provider,
and write to CloudWatch Logs and X-Ray. Trusted callers invoke it synchronously through IAM using
the function ARN published to the SSM parameter named in
`structured_completion_function_arn_ssm_parameter` Terraform output.

The function is deployed as a ZIP Lambda on the managed `nodejs24.x` runtime alongside the
assistant API handler. Both handlers are built from the same deployment package; `completion.handler`
is the entry point for the structured completion function.

### Streaming function

`${service}-structured-completion-stream` (`completionStream.handler`, `src/completions/stream.ts`)
is a sibling of the sync function with the same package, IAM role, memory, timeout, deadline and
configuration. It runs the same request, kill switch, providers and result, but answers as NDJSON
frames (`application/x-ndjson`) over Lambda response streaming, so a caller can show progress while
the model works. Callers invoke its `live` alias with `InvokeWithResponseStream` through IAM; it has
no URL, no API Gateway and no resource-based permission. The alias ARN is published to the
`.../structured-completion-stream-function-arn` SSM parameter. Its only caller is the Tender check
review worker in `api-documents` (see [Tender check streaming](https://help.stralos.app/internal/tender-check.md#streaming)).

| Frame | When | Shape |
| - | - | - |
| `start` | Once the provider and model are chosen, before the provider is called. | `{ type, requestId, provider, model }` |
| `thinking` | Summarised reasoning text (Anthropic only), at most one frame per 500 ms. | `{ type, text }` |
| `output` | Raw answer-JSON text, at most one frame per 250 ms. | `{ type, text }` |
| `ping` | After 15 seconds with no other frame. | `{ type }` |
| `result` | Last frame on success: the object the sync function returns. | `{ type, ...StructuredCompletionResponse }` |
| `error` | Last frame on failure. | `{ type, status, code, detail }` |

`reset` is reserved for a provider restarting its output and is not sent yet. Callers ignore frame
types they do not recognise; `result` and `error` keep their shapes. `thinking` and `output` are
display-only, so callers persist nothing but `result`. The handler ends the stream itself and never
throws, so every invocation that runs ends with `result` or `error`. If the reader goes away, the
completion still runs to its end and later writes are dropped.

A refused request's `error` frame carries the same status and code as the sync function's request
errors below. Other failures use:

| Code | Status | Meaning |
| - | - | - |
| `deadline_exceeded` | `504` | The provider call exceeded the execution deadline. |
| `disabled` | `503` | Structured completions are disabled by the kill switch. |
| `completion_failed` | `502` | Any other provider or handler failure. |

### Request protocol

The caller sends a JSON object that the function validates against a strict schema before any
provider call is made.

| Field | Type | Constraint |
| - | - | - |
| `protocolVersion` | `1` (literal) | Must equal `1` |
| `requestId` | UUID | Caller-supplied correlation ID, echoed in the response and success log |
| `workload` | string | Name identifying the calling workload; `[A-Za-z0-9][A-Za-z0-9._:-]*`, max 128 characters |
| `organisationId` | UUID or `null` | Organisation context for logging; `null` for workloads without a tenant |
| `system` | string | System prompt text; max 128 KB UTF-8 |
| `prompt` | string | User-turn prompt text; max 512 KB UTF-8 |
| `jsonSchema` | object | JSON Schema describing the expected output; max 64 KB UTF-8 |
| `schemaName` | string | Name of the schema's root type; same format as `workload` |
| `images` | array (optional) | One to eight image objects; see [Image input](#image-input) |
| `documents` | array (optional) | One to twenty PDF documents; see [Document input](#document-input) |
| `modelProfile` | string (optional) | Named model profile, currently only `tender-check`; see [Model profiles](#model-profiles). Omit it for the shared provider and model. An unknown value is a `400 invalid_request`. |

The function forces a single `emit` tool call against the schema and returns exactly one response
object. The caller cannot make it stream a response, call multiple tools, or name a provider or
model ID. `modelProfile` is the only way to ask for a different model.

#### Image input

Each entry in `images` has these fields:

| Field | Type | Constraint |
| - | - | - |
| `type` | `"image"` (literal) | Must equal `"image"` |
| `mediaType` | string | `image/png`, `image/jpeg` or `image/webp`. The leading bytes must match the declared type. |
| `data` | string | Raw padded standard base64, with no `data:` prefix and no whitespace. At most 4 MiB of base64 characters per image. |
| `label` | string (optional) | Up to 200 characters. When present, the user turn carries `Image N: <label>` before the image. |

Images go into the user turn in the order supplied, before the prompt text, so the prompt can refer
to `Image 1`, `Image 2`, and so on. All images together may total at most 4.5 MiB of base64
characters. That leaves room for the largest legal prompt, system and schema within the 6 MB
synchronous Lambda invoke payload limit.

Bedrock Converse, Anthropic, Google and OpenAI accept images. The local Codex provider does not, and
the function refuses with `422 image_input_unsupported`.

#### Document input

Both the sync and streaming functions accept native PDFs. The schedule check review in
`api-documents` uses them (see [Native PDFs](https://help.stralos.app/internal/schedule-check.md#native-pdfs)). Each entry in
`documents` has these fields:

| Field | Type | Constraint |
| - | - | - |
| `url` | string | An https presigned S3 GET URL (with `X-Amz-Signature` and `X-Amz-Expires` in the query) whose text starts with `https://<host>/`, where `<host>` is one of the allowed document hosts. Up to 4,096 characters. |
| `mediaType` | string | Must be `application/pdf`. |
| `title` | string (optional) | Up to 200 characters. Passed to the provider as the document's title. |

Only the URL travels on the invoke; the provider fetches the PDF itself. The user turn carries each
document in order, then the images, then the prompt last, so the prompt can refer to documents by
title. Page and byte limits are the caller's to count, because only the caller has the bytes:
Claude allows 600 pages and 32 MB per request, counting every PDF and image.

The allowed hosts come from `STRUCTURED_COMPLETION_DOCUMENT_HOSTS` (comma-separated). Terraform sets
it to the estate documents bucket's virtual-hosted regional host,
`stralos-<env>-<region_key>-documents.s3.<region>.amazonaws.com`. When it is unset, every document is
refused. User info, ports, fragments, spaces and backslashes are refused too.

A document URL is a bearer credential. It is never logged or echoed: the log line records
`documentCount` only, and when a request carries documents, every URL is redacted from a provider
error before it is thrown.

Only the `anthropic` provider reads native PDFs, as `document` blocks with a `url` source. Every
other provider (Google, Bedrock, OpenAI, Codex and the fixture) refuses with
`422 documents_unsupported` before any model call; resend without `documents` and put the text in
the prompt instead.

### Model profiles

A model profile is a name that the function maps to a provider, model and reasoning level. The
caller names the profile; only the function's own configuration decides what runs. A request
without `modelProfile` is selected exactly as before and sends no reasoning level.

The only profile is `tender-check`, used by the Tender check review in `api-documents`.

| Setting | Value |
| - | - |
| Reasoning level | `medium` |
| Provider | `google` by default. Set the String parameter `/stralos/<env>/<region_key>/ai/tender-check/provider` to `anthropic` to use the Claude Messages API instead. |
| Model | Read from the String parameter `/stralos/<env>/<region_key>/ai/tender-check/model`. It holds a Gemini model ID for the Google arm or a Claude model ID for the Anthropic arm. |

Terraform passes the two parameter paths as `TENDER_CHECK_PROVIDER_SSM_PARAMETER` and
`TENDER_CHECK_MODEL_ID_SSM_PARAMETER`; `TENDER_CHECK_PROVIDER` and `TENDER_CHECK_MODEL_ID` set
direct values locally. `stralos-core`'s environment setup creates both parameters with the value
`UNSET`; write the real values after that apply.

Fallbacks keep a check running when configuration is incomplete:

* With provider `anthropic`, an unset model, a model that is not a Claude model ID, or an unusable
  Anthropic credential falls back to the Google arm and logs `tender_check_provider_unavailable`.
* On the Google arm, an unset model (or a Claude model ID) falls back to the shared provider and
  model and logs `tender_check_model_unset`. The response's `model` field reports the model
  actually used.

The Anthropic arm streams the provider response so a long answer completes within the
400-second execution deadline. The caller still receives one response.

### Response

| Field | Type | Description |
| - | - | - |
| `protocolVersion` | `1` | Echoes the protocol version |
| `requestId` | string | Echoes the caller's `requestId` |
| `output` | unknown | Parsed JSON matching the supplied schema |
| `provider` | string | Provider name selected for this invocation |
| `model` | string | Model ID used |
| `durationMs` | number | Wall-clock time for the provider call |
| `usage` | object | `inputTokens`, `outputTokens`, and optional `cacheReadTokens` |

The function logs one `structured_completion` CloudWatch structured log event per successful
invocation, including `workload`, `organisationId`, `provider`, `model`, `durationMs`, and `usage`.
Prompt and output text are never logged.

### Output limits and error conditions

The output must be JSON-serialisable and must not exceed 5 MB UTF-8. The function throws for:

* Structured completions disabled (kill switch active)
* Schema or prompt validation failure
* Safety refusal by the provider (`guardrail_intervened` or `content_filtered`)
* Response truncated before the emit tool completed (`max_tokens`)
* Provider returned zero or more than one emit result
* Output exceeds the 5 MB serialised limit
* Execution deadline exceeded (400-second Terraform default, configurable via `structured_completion_deadline_milliseconds`; the handler's unset fallback is 280 seconds)

Request errors throw `StructuredCompletionRequestError`, so the invoker receives `FunctionError`
with `errorType: "StructuredCompletionRequestError"`. The `errorMessage` starts with
`[<status> <code>]`, so callers can read the stable code without parsing free text. The detail never
echoes prompt text or image bytes.

| Code | Status | Meaning |
| - | - | - |
| `invalid_request` | `400` | Schema validation failed, including an unknown `modelProfile` or image `data` that is not raw padded base64 |
| `too_many_images` | `400` | More than eight images |
| `image_too_large` | `413` | One image exceeds 4 MiB of base64 characters |
| `images_too_large` | `413` | All images together exceed 4.5 MiB of base64 characters |
| `unsupported_media_type` | `415` | An image's `mediaType` is not PNG, JPEG or WebP or its bytes do not match it, or a document's `mediaType` is not `application/pdf` |
| `image_input_unsupported` | `422` | The selected provider cannot read images (Codex) |
| `too_many_documents` | `400` | More than 20 documents |
| `document_url_not_allowed` | `400` | A document URL is not an allowed presigned S3 GET URL for the documents bucket |
| `documents_unsupported` | `422` | The selected provider cannot read native PDFs (every provider except `anthropic`) |

The caller must change the request before retrying any of these.

### Background-completion kill switch

Background structured completions use an independent SSM kill switch, separate from the
interactive assistant switch. Disabling Doug does not stop background document processing or
the rates import; disabling background completions does not hide the assistant panel.

Resolution order: direct `STRUCTURED_COMPLETIONS_ENABLED` environment variable, then the SSM
parameter named by `STRUCTURED_COMPLETIONS_ENABLED_SSM_PARAMETER`, then enabled. Unset,
unreadable, and unparseable values fail open. After SSM has returned `false`, a later failed read
keeps completions disabled until a successful read changes the operator's intent.

### Terraform variables

| Variable | Default | Description |
| - | - | - |
| `structured_completions_enabled` | `true` | Seed value for the SSM kill switch; Terraform does not revert operator changes after the first apply |
| `structured_completion_memory_size` | `1024` MB | Lambda memory; must be 512–10240 MB |
| `structured_completion_timeout` | `420` seconds | Hard Lambda timeout; must be 30–450 seconds |
| `structured_completion_deadline_milliseconds` | `400000` | Provider deadline, shorter than the Lambda timeout so callers receive a failure response rather than a Lambda timeout; must be 1000 ms or more and less than the timeout |
| `structured_completion_max_output_tokens` | `8000` | Maximum provider output tokens; must be 1–64000 |
| `structured_completion_reserved_concurrency` | `30` | Account-level Lambda concurrency reserved for structured completion callers |

### CloudWatch alarms

The structured completion Lambda is included in `local.alarmed_lambda_functions` alongside the
assistant API Lambda. Both `Errors` and `Throttles` alarms are created for it using the same
settings as all other alarmed functions: five-minute window, threshold of 1, and
`treat_missing_data = "notBreaching"`.

## Hardening controls

### Kill switch

`GET /api/assistant/config` is auth-exempt and reports the same enabled state enforced by the turn
route. When disabled, the config endpoint returns `200` with `{ "enabled": false }`, while the turn
endpoint returns `503 assistant_disabled` before a model call. The shell then hides the assistant.

Resolution order is the direct `ASSISTANT_ENABLED` environment variable, then the SSM parameter
named by `ASSISTANT_ENABLED_SSM_PARAMETER`, then enabled. SSM reads use a 30-second cache by
default, so a change stops new turns within the TTL plus one in-flight turn. Unset, unreadable, and
unparseable values fail open. After SSM has successfully returned false, a later failed read keeps
the assistant disabled until a successful read changes the operator's intent.

All SSM reads in the service delegate to one process-wide `ssmParameters` singleton from
`@stralos-co-nz/api-common`. Reads share an entry only when the parameter name, decryption mode,
and TTL match. Database configuration uses the normal five-minute identity; the kill switch
deliberately uses a separate 30-second identity.

At cold start, `lambda.ts` calls `preloadApiSsmParameters()` before serving any request. It reads
every parameter listed in `src/config/ssm-preload.ts` with batched `GetParameters` calls (ten per
call), so no request waits on SSM. The three database URL parameters are required: if one cannot be
loaded, Lambda INIT fails. Everything else is optional and fails softly: the kill switch, AI
provider, model IDs, provider API keys, the chat override, hardening-limit overrides, and the
PostHog token. After that, an expired entry is served while it refreshes in the background, so the
kill switch still takes effect within its 30-second TTL. A new `*_SSM_PARAMETER` read on the request
path must be added to `src/config/ssm-preload.ts`; `test/ssmPreload.unit.test.ts` fails until it
is. The API Lambda role grants `ssm:GetParameters` alongside `ssm:GetParameter` for the preload.

### Turn guard and budgets

The pre-stream guard runs in this order:

| Check | Default | Response |
| - | - | - |
| Kill switch | Enabled | `503 assistant_disabled` |
| Message cap | 120,000 characters | `413` |
| Per-user turns | 20 per minute | `429 rate_limited` |
| Per-organisation turns | 120 per minute | `429 rate_limited` |
| Rolling organisation token budget | 2,000,000 per 24 hours | Degraded mode or `429 assistant_daily_limit` |

Rate and budget counters are atomic database operations keyed by authenticated user and
organisation, not client IP or process. A counter-store outage fails closed with
`503 assistant_unavailable`.

Each turn reserves the full 150,000-input plus 8,000-output token ceiling under a
per-organisation advisory transaction lock. Unused tokens are refunded to the exact hourly bucket
that was charged. This prevents concurrent instances on either side of an hour boundary from
overcommitting the rolling budget.

When fewer than 8,000 tokens remain, the turn is refused. Above that floor but below the full
reservation, the service sheds optional context and reduces the output allowance. Message, turn
context, and individual document inputs are each capped at 120,000 characters. The loop allows at
most 20 tool calls and, through the existing loop setting, eight provider rounds by default.

Hardening limits resolve a direct environment variable first, then a committed default. A deployed
service can hydrate an SSM override at cold start when no direct variable is set. Changes to those
SSM limits therefore take effect on a later cold start or restart; the kill switch is the separate
TTL-refreshed control.

### Credential redaction

Audit arguments, summaries, details, and structured tool logs pass through layered redaction:
values under credential-like keys are replaced with `[redacted]` at any depth, the current bearer
value is scrubbed even from embedded strings, and JWT-shaped or `Bearer …` values are removed
structurally. Read-path projections apply the scrubber again before returning audit data.

### Help-fetch SSRF controls

The crawler accepts only `https://help.stralos.app` on port 443, follows at most three redirects,
and validates every hop. All DNS answers must be public; the selected address is pinned for the
connection and the peer address is rechecked. Loopback, link-local, private, reserved, and cloud
metadata ranges are rejected for IPv4 and IPv6, with response time and size limits applied.
`search_help` itself performs no network request during a turn; it reads the ingested corpus.

### Safe error handler

`registerSafeErrorHandler` (from `@stralos-co-nz/api-common`) intercepts any error that route handlers rethrow and is not already classified by the service's own HTTP error helpers. It replaces the raw error message with fixed copy and a reference ID before the response is sent, so driver text (for example, Postgres messages) never appears in an API response body.

Two error classes are handled:

| Condition | Status | Response shape |
| - | - | - |
| Saturated database (`pg` SQLSTATE `53300`) | `503` | `{ code: "service_unavailable", reference: "<id>" }` with `Retry-After: 5` |
| Any other unclassified error | `500` | `{ code: "internal_error", reference: "<id>" }` |

The `reference` value equals the `x-request-id` header for that request. Clients that receive either response can include the reference when reporting the error.

This handler replaced Fastify's default `errorHandler`, which forwarded `error.message` verbatim. The change was prompted by a 2026-09-18 incident in which a saturated dev database caused every API response to include raw Postgres text.

### Lambda pool bounds

Each Lambda container creates three Postgres pools (owner, runtime, and platform). Without explicit bounds, `pg` defaults to `max: 10` per pool and waits indefinitely for a free slot. With three pools and many concurrent cold-start containers, this allowed far more connection attempts than the database could accept.

The pools created in the Lambda entry point (`src/lambda.ts`) use `LAMBDA_POOL_BOUNDS`:

| Bound | Value | Reason |
| - | - | - |
| `max` | `2` | One for the request transaction, one spare so a helper that checks out during the transaction can queue rather than deadlock |
| `connectionTimeoutMillis` | `5000` ms | Fails a saturated-database checkout in 5 s; the safe error handler returns `503` with `Retry-After: 5` rather than holding the request until API Gateway times out at 29 s |

The local development server and the CLI scripts retain `pg`'s defaults because they serve concurrent requests from a single process, where a pool of 2 would only serialise work.

### Metrics and model tier

The service emits CloudWatch Embedded Metric Format records under `Stralos/Assistant`. The exact
metric names are:

* Turn path: `TurnFirstTokenMs`, `TurnCompleteMs`, `TurnErrors`, `Turns`, `TurnTruncated`,
  `RateLimited`, and `BudgetExhausted`.
* Usage and cache: `TokensSpent` and `CacheHitRatePct`.
* Review and refusal trends: `Refusals`, `DraftAccepted`, `DraftAcceptedWithEdit`, and
  `DraftRejected`.
* Operations: `OrgOffboardPurge` and `RetentionPurged`.

CloudWatch alarms are defined in `terraform/alarms.tf` and cover two complementary failure modes:

**Lambda alarms** — one `Errors` alarm and one `Throttles` alarm per Lambda function in
`local.alarmed_lambda_functions`. Both use `treat_missing_data = "notBreaching"` (absence of
traffic is not a failure) and trip on the first event in a five-minute window. They catch the
function throwing: crashes, timeouts, OOM, and cold-start init failures.

**API Gateway 5xx alarm** — catches the failure mode the Lambda `Errors` metric cannot see.
`stralos-api-assistant` is a Fastify app: its error handler catches application and dependency
failures and returns them as HTTP 5xx responses, which are successful Lambda invocations. The
`AWS/ApiGateway 5XXError` alarm (REST API v1, `ApiName` + `Stage` dimensions) detects these.
Threshold `>= 1` with `treat_missing_data = "notBreaching"`.

All alarms publish to the per-environment alerts SNS topic on both alarm and OK transitions. The
effective topic ARN is read from the core `environment-setup` remote state output
`admin_alarms_topic_arn`. That topic is in the same account as the applying stack, so a dev, test,
or prod apply wires alarms to the correct same-account topic automatically. The
`alarm_sns_topic_arn` Terraform variable is an optional override (default `""` = use the core
per-environment topic); set it only to point alarms at a different same-account topic. Terraform
validates the effective topic ARN and fails the plan if the topic account differs from the applying
account.

A Terraform `check` block reads the live Lambda function list at plan time and reports a named
finding if any in-scope function is missing from `local.alarmed_lambda_functions`.

The intent-routing seam is also implemented. The shipped `deterministic` tier adds no model call
and passes the route/help classifier result through unchanged. A cheaper classifier can later
implement `IntentTier` and register at boot without changing the full agent loop; that adoption
decision remains deferred.

## Admin activity

`GET /api/assistant/activity` and `GET /api/assistant/activity/fatigue` require an organisation
administrator; a member receives `403`. Both read the action audit rather than conversation or
message content.

The activity list is newest first and keyset-paginated. Accepted query parameters:

| Parameter | Description |
| - | - |
| `tool` | Return only rows where the tool name matches exactly. |
| `excludeTool` | Exclude rows where the tool name matches exactly. Use this to filter internal tooling from the trail. |
| `from` | Inclusive lower bound, as an ISO 8601 datetime with offset. |
| `to` | Exclusive upper bound, as an ISO 8601 datetime with offset. |
| `outcome` | One of `ok`, `error`, or `refused`. |
| `cursor` | Opaque keyset cursor from the previous page's response. |
| `limit` | Page size from 1 to 200 (default 50). |

The response includes a `retentionDays` field reporting how long action rows are kept. Draft lifecycle rows include the target, operation, applied record, and whether the accepted payload differed from the proposal.

The fatigue endpoint uses a 30-day window and reports insufficient data below 20 accepted drafts.
At or above that sample, accepting at least 90% without an edit sets `flagged: true`; the shell
renders **Review recommended**. This is an oversight signal and never blocks a turn or acceptance.

## Retention and offboarding

Retention and organisation purges run under the platform role, not the tenant runtime role; the runtime role has no `DELETE` on any assistant table. For the cross-service picture, see [Data retention](https://help.stralos.app/internal/data-retention.md).

| Data | Retained for | Variable (Terraform) |
| - | - | - |
| Conversations, with their cascaded messages and drafts | 365 days from conversation `updated_at` | `ASSISTANT_CONVERSATION_RETENTION_DAYS` (`retention_conversation_days`) |
| Action audit rows (`assistant_actions`) | 730 days | `ASSISTANT_ACTION_RETENTION_DAYS` (`retention_action_days`) |
| Rate and budget counters | 48 hours | Fixed |
| All data of a `disabled` organisation | 90 days from the first `disabled` sighting | `ORG_OFFBOARDING_PURGE_AFTER_DAYS` (`org_offboarding_purge_after_days`) |

`assistant_actions` deliberately has no foreign key to conversations, so its longer audit window survives a conversation purge.

### Scheduled retention Lambda

A dedicated Lambda (`retention.handler`, same deployment package as the API Lambda) runs weekly from EventBridge on `retention_schedule_expression` (default `cron(0 15 ? * SUN *)`). It runs with `reserved_concurrent_executions = 1`, so an overlapping retry cannot race a purge in progress. It refuses to start without the platform database URL, `SERVICE_MINT_FUNCTION_ARN` (the `mint-for-assistant-offboarding` alias), and `STRALOS_GLOBAL_API_URL`.

Each run performs two steps:

1. **Retention sweep:** deletes conversations, action rows and counters past their windows.
2. **Offboarding sweep:** checks every organisation that still has assistant rows against api-global's organisation status:
   * `active`: clears any clock row in `assistant_org_offboarding`, so a reactivated organisation starts again from zero.
   * `disabled`: records the first sighting (`disabled_observed_at`), and purges all of the organisation's assistant data once that sighting is at least the configured window old. The effective window is up to seven days longer because of the weekly cadence.
   * Any other status (`provisioning`, `failed`, or unknown): touches nothing.

If either step fails, the Lambda throws after attempting both, so the CloudWatch `Errors` alarm pages the alerts topic.

### Active-organisation guard

Every purge path (the scheduled Lambda, the CLI, and `handleOrganisationDisabled`) takes a required api-global status lookup and refuses unless the organisation is explicitly `disabled`. A lookup that fails, times out, or returns any other status refuses the purge. The guard runs before the export, if any, and again immediately before the delete.

### Per-user erasure

The retention Lambda also accepts one manual event, invoked by support through `scripts/erase-user.mjs` in `stralos-core`:

```json theme={null}
{ "kind": "erase-user", "userId": "<uuid>", "requestId": "<non-empty string>" }
```

A malformed `userId` or an empty `requestId` is refused before any database connection opens. Any other event, including the weekly EventBridge invocation, runs the scheduled job.

A valid event deletes, in one platform-role transaction and across every organisation:

* `assistant_conversations` where `user_id` matches, cascading their messages and drafts.
* `assistant_drafts` where `created_by` matches, so a draft in another user's conversation cannot survive.
* `assistant_actions` where `user_id` matches. The audit is append-only by grant, so it cannot be anonymised in place; the platform role's `DELETE` removes it.
* `assistant_usage_counters` with scope `turn_user` for the user. Organisation-level counters stay.

The operation is idempotent: a second run reports zero counts.

### CLI and offboarding seam

`pnpm sweep:retention --dry-run` reports the selected organisation scope and configured windows without counting or deleting rows. `pnpm sweep:retention` performs the window sweep and reports counts (`--org <uuid>` narrows it to one organisation). `pnpm sweep:retention --offboard <uuid> [--export <file>]` purges one disabled organisation, writing the redacted export first when `--export` is given. The scheduled Lambda is the normal path; the CLI is for one-off runs and formal portability requests.

`handleOrganisationDisabled` is the offboarding seam for a future `organisation.disabled` ingest subscription or a manual platform operation. If a delivery callback is configured, the redacted export is delivered before the transactional purge. Completion is recorded outside the deleted tenant tables, as an operational log line and an `OrgOffboardPurge` metric, because a final audit row written into a table being purged would be deleted with it.

## Audit trail

Every tool call is recorded in `assistant_actions` with the tool name, redacted arguments, compact
result summary, duration, and model id. Full tool payloads are not retained in the audit row. The
`source` column on `assistant_drafts` records whether a draft was
created by a direct model tool call (`tool_call`) or by a server-side advisory formalisation path
(`advisory_formalize`).

## Backend API contract notes

`stralos-api-assistant` is a sender against four downstream services. The sending-side contract coverage is documented in `SENDING_CONTRACTS.md` at the repository root. Contract IDs link the notes on both sides; check the coverage limits before treating a passing mocked sender test as cross-service compatibility proof.

| Contract | Direction | Receiver notes |
| - | - | - |
| BC-01: Rate reads and rate-item creation | `api-assistant` → `api-rates` | [Rates service](https://help.stralos.app/internal/rates-service.md) |
| BC-02: Estimate reads and draft writes | `api-assistant` → `api-estimate` | [Testing strategy — backend contract tests](https://help.stralos.app/internal/testing-strategy.md#backend-contract-tests) |
| BC-03: Project reads and project/party creation | `api-assistant` → `api-global` | — |
| BC-04: Schedule reads and assignment creation | `api-assistant` → `api-scheduler` | — |

Sender tests are not consistently tagged `[backend-contract]` and run as part of the normal CI suite, not via `pnpm test:contract`. A failure fails the sender's own test job; a receiver change does not automatically trigger sender CI. Independent green suites do not prove compatibility. When changing a listed method, path, parameter, required header, status code, or response field, update and run both repositories' relevant suites and keep fixtures compatible with the receiving API.

## Cost monitoring

### Bedrock cost alarm

`terraform/cost.tf` defines an account-wide `AWS/Billing EstimatedCharges` alarm for Amazon
Bedrock. AWS billing alarms live in `us-east-1` and can only notify a topic in that same region,
so this alarm cannot use the regional `ap-southeast-2` alerts topic from the core stack.

| Variable | Default | Description |
| - | - | - |
| `bedrock_monthly_cost_threshold` | `500` | Month-to-date USD threshold. Set to `0` to disable the alarm. |
| `cost_alarm_emails` | `[]` | Email addresses to subscribe to a us-east-1 cost-alerts SNS topic this stack creates. Each subscription is created `PENDING` and delivers nothing until the recipient confirms the AWS confirmation email. Ignored when `cost_alarm_sns_topic_arn` is set. |
| `cost_alarm_sns_topic_arn` | `""` | Optional override: an existing us-east-1 SNS topic ARN. Must be a full topic ARN in `us-east-1`; a subscription ARN or a topic in another region is rejected at plan time. Takes precedence over `cost_alarm_emails`. |

When `cost_alarm_emails` is set and `cost_alarm_sns_topic_arn` is empty, the stack creates a
`<prefix>-cost-alerts` topic in `us-east-1` and subscribes each address. A green `terraform apply`
does not mean recipients are paged — confirm subscriptions are active with:

```bash theme={null}
aws sns list-subscriptions-by-topic --region us-east-1 --topic-arn <cost-alerts topic ARN>
```

### Log retention

Lambda CloudWatch log groups use `log_retention_days` (default `14`). The production environment
sets this to `30` days.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.