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_assistanton 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 instralos-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, whichapi-common’screateLinkStateReaderreads on every request made by a contractor person. Without this prefix, those reads are denied and the guard returns503 billing_unavailable.
Shell-side draft controller (PRD 04)
The shell owns all draft network calls and theAssistantController 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 byorganisation_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—truefor all new conversations and for backfilled historical conversations.falsemeans the conversation predates project enforcement and has not yet had its provenance resolved. A conversation withproject_scope_known = falseis treated as inaccessible by the runtime role.
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 carryproject_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.
{ "organisations": N, "conversationsBackfilled": N }.
Data model
Thestralos_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 (migration0006_turn_lease.sql).assistant_messages— ordered user, assistant, and tool messages with content blocks, citations, and tool-step progress (migration0005_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 (migration0005_task_progress.sql).assistant_help_documentsandassistant_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 inassistant_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:
- Build.
publish-artifacts.ymlchecks outstralos-help-centre@main, andscripts/build-lambda.sh(withHELP_CORPUS_DIRset) crawls it intohelpCorpus.jsoninside the deployment zip. The release promotes that zip by digest, so test and prod load the same corpus as dev. - 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 thehelp_loaderErrorsalarm fires instead of the corpus going silently empty. - Invocation.
scripts/load-help-library.shinvokes thelivealias after every deploy: from theverify-ai-gatewayjob indeploy-dev.ymlon dev, and fromsmoke.yml(dispatched by the central release) on test and prod. EventBridge also runs it daily, set byhelp_loader_schedule_expression(defaultcron(30 14 * * ? *), 14:30 UTC). The schedule is created disabled on test and prod and enabled by the activation step.
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_helpresults 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.
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 toPOST /api/assistant/conversations/:id/messages. The route:
- Acquires a per-conversation concurrency lock (one turn at a time per conversation).
- 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.
- 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.
- 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 onetoken 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 byASSISTANT_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
planclaim. Anything other thancontractor, 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
viaclaim.
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 callpreassess_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 asdraft_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_projectsandget_project: both includejobNumber(the organisation’s internal job number, e.g.JOB-0003, present once an estimate is marked Won) andreference(the client’s own reference) when present; either field is omitted from the result when the project has no value for itlist_estimates,get_estimate_summary, andlist_work_items— see Estimate selection belowsearch_rate_itemsandget_rate_item— see Rate item options belowlist_rate_categorieslist_office_locations: see Office location on create_project belowlist_client_contacts: see Client contact on create_project belowget_project_schedulelist_variations,get_variation, andget_projected_final_accountget_project_rate_settings: a project estimate’s ordered rate rules and pricing location, offered on default turns only; see Project rate rules
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:
- The estimate the user names explicitly.
- The
estimateIdfrom the UI context (the estimate the user has open in the app). - The project’s primary estimate, resolved via
list_estimates.
Write tools
Write tools never issue a target HTTP call directly. Each write tool stages exactly onepending
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
Neithercreate_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
TheitemKind 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.
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_contactfirst, 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_clientdraft 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/:idedit can insert or change the role flag. TheisClientvalue is fixed by the tool’s registry entry and is stated in the review card sentence the user confirms.
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
WhenresourceType 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 toolspreassess_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 ofinstructed_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
Migration0005_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 withresumeDraftId 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
Theassistant_drafts table records every write proposal from the model. The lifecycle is:
Draft routes
Accept safety rules
- The
ifVersionfield is required on accept. A stale version returns409. - 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
403from the target, which transitions the draft back topendingwith the error recorded inlast_error. - A target
5xx, network failure, timeout, or unexpected non-success response leaves the draft inapplyingand recordslast_error; the API projects this as an ambiguous hold. The user can acknowledge and retry withforce: true. - Scheduler
503 peer_unavailableis the definitive pre-insert exception and returns the draft topending; other unexpected non-success responses remain ambiguous. - The reconciler marks orphaned
applyingattempts ambiguous by settinglast_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, acreate_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:
- Terminally rejects the draft with reason
draft_staleusing 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 returns409, draft untouched). - Returns
200withoutcome: "draft_stale"and amissingFieldsarray naming the fields that must be supplied (e.g.["category"]).
Draft payload shape
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 bystralos-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_catalogprojects the available catalogue andnavigate_tovalidates 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.
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 aboveTAU_HOWTO (default 0.6). A confident answer is then assembled from up to three
parts, in order:
- 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 anavigateui_action— it names the page as context only. - 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.
- 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 formverb [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 APIgenerateContent 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 inimages 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 inapi-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 withoutmodelProfile 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 logstender_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’smodelfield reports the model actually used.
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_intervenedorcontent_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)
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: directSTRUCTURED_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 inlocal.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 onlyhttps://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 underStralos/Assistant. The exact
metric names are:
- Turn path:
TurnFirstTokenMs,TurnCompleteMs,TurnErrors,Turns,TurnTruncated,RateLimited, andBudgetExhausted. - Usage and cache:
TokensSpentandCacheHitRatePct. - Review and refusal trends:
Refusals,DraftAccepted,DraftAcceptedWithEdit, andDraftRejected. - Operations:
OrgOffboardPurgeandRetentionPurged.
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 noDELETE 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:
- Retention sweep: deletes conversations, action rows and counters past their windows.
- Offboarding sweep: checks every organisation that still has assistant rows against api-global’s organisation status:
active: clears any clock row inassistant_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.
Errors alarm pages the alerts topic.
Active-organisation guard
Every purge path (the scheduled Lambda, the CLI, andhandleOrganisationDisabled) 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 throughscripts/erase-user.mjs in stralos-core:
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_conversationswhereuser_idmatches, cascading their messages and drafts.assistant_draftswherecreated_bymatches, so a draft in another user’s conversation cannot survive.assistant_actionswhereuser_idmatches. The audit is append-only by grant, so it cannot be anonymised in place; the platform role’sDELETEremoves it.assistant_usage_counterswith scopeturn_userfor the user. Organisation-level counters stay.
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 inassistant_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 uselog_retention_days (default 14). The production environment
sets this to 30 days.
