Skip to main content
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 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 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.
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 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 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. 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 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_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

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. 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. 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 below
  • search_rate_items and get_rate_item — see Rate item options below
  • list_rate_categories
  • list_office_locations: see Office location on create_project below
  • list_client_contacts: see 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
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: 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: 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.

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:

Draft routes

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

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. 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. The current catalogue contains 32 entries. group provides grounding context only; it does not affect route matching or validation. 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. 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: 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

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. 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). 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). 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:

Request protocol

The caller sends a JSON object that the function validates against a strict schema before any provider call is made. 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: 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). Each entry in documents has these fields: 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. 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

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. 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

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: 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: 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: 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: 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. 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:
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. 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. 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:

Log retention

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