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

# Admin console API

> Architecture and operating contract for stralos-admin-api

`stralos-admin-api` is the management-plane API for organisations, users,
memberships, platform configuration, and new-organisation seed templates. It is a
standalone Fastify service with its own Postgres store.

## Capabilities

| Area | Scope |
| - | - |
| M1 | Admin store, CRUD routes, audit log, bootstrap and recovery CLIs, and the outbox schema. Operator authentication originally used email, password, and TOTP; sign-in is now Google OAuth2 with PKCE (see [Admin console frontend](https://help.stralos.app/internal/admin-console-frontend.md#sign-in-flow)). |
| M3 | Transactional event enqueueing, EdDSA event signing, outbox flushing, retry and conflict handling, plus reconciliation primitives. |
| M4 | New-organisation provisioning saga, `seed_template.provisioned` event, seed snapshot table, cross-aggregate delivery dependency, reconciliation improvements (conflicted-settle, `seed_template` aggregate lock sharing), and `homeRegionKey` targeting fix. |
| Taxonomy M4 | `parentGroup` on rate-category templates, the new rate-sub-category template table and CRUD routes, and the updated seed snapshot that freezes the full three-level category tree (category → sub-category → `parentGroup`). |
| M5 | Transformation-aware runtime bootstrap, synchronous outbox drain, parity gate, deployment infrastructure, and finalisation tooling. The one-time bootstrap import is complete everywhere, and its cutover Lambda was removed on 2026-10-11. |
| Taxonomy M13 | One-time migration purging pre-cutover pending seed snapshots for unprovisioned organisations. See [Taxonomy M13 migration runbook](https://help.stralos.app/internal/runbooks.md#taxonomy-m13-migration-admin-api) for the quiesce precondition and triage steps. |
| Taxonomy M14 | `parentGroup` is now wire-required on `POST /api/admin/rate-category-templates`. The name-guess fallback (`guessParentFromName`) and the database column default for `parent_group` have both been removed. |
| Rates Core Disposal | `disposal` added as a sixth `parentGroup` token. `isOutgoing` removed from `rate_sub_category_templates`. Migration `0023_rates_core_disposal.sql` re-parents existing outgoing quarry sub-categories under a new `disposal` category template and backfills the retired boolean from frozen `org_seed_snapshots` and outbox rows. New seed snapshots emit `parentGroup` on each sub-category item; old frozen snapshots do not and the consumer falls back to the enclosing category. |
| Company locations retirement | The `company_locations` `org_seed_template` seed key was retired first. Existing organisation snapshots remain immutable. The remaining three historical seed keys — `takeoff_categories`, `allowances_assumptions`, and `tags_clarifications` — are now also retired for writes; their tenant-owned runtime lists remain active. |
| M0a | Machine-readable OpenAPI 3.1 contract for 28 descriptor-backed protected operator operations, served at `GET /api/admin/openapi.json`. This is a documentation deliverable only; it is excluded from the copilot tool substrate. |
| IDP-M2b | Cognito user provisioning saga, SES invite email dispatch, federated identity-link recording, invitation-generation stale-acceptance guard, and the runtime→admin report channel. |
| IDP-M3a | Per-region `target_region_key` on outbox rows, region-scoped predecessor blocking, multi-region user fan-out, and `wrong_region` rejection code on the report channel. |
| IDP-M3b | `directory.regions_replaced` events, directory backfill, and single-writer directory configuration. |
| IDP-M4c | Monotone `sessionGeneration` on user events and runtime-requested regional session revocation. |
| Signup | Self-service creation of a new organisation, its first administrator, and the administrator's active membership. |
| PRD 07 | Organisation slug: `organisations.slug` column (NOT NULL, globally unique, immutable), `POST /organisations` slug derivation from name, `0025_idp_org_slug.sql` backfill migration, and `slug` on every `organisation.*` event payload. See [Organisation slug](https://help.stralos.app/internal/org-security.md#organisation-slug). |
| PRD 03 M3 | Release journey probe: `GET /api/admin/service/probe`, authenticated by an api-global service token. The probe returns deploy-time constants and the caller's own nonce. It reads no tenant row, no operator row, and no catalogue item. Enabled by `service_token_source_map` in Terraform. |
| Org self-service | Organisation administrators can administer their own users via a third inbound principal. Four routes — `GET /api/admin/org/billing-seat-usage`, `GET /api/admin/org/users`, `POST /api/admin/org/users` (invite), and `PUT /api/admin/org/users/:userId` (name edit) — are guarded by `auth/orgAdminToken.ts` (verifies an api-global access token) and `auth/orgAdmin.ts` (re-derives authority from the admin store). The four rules governing this surface: (1) an existing user invited into a second org is always `invited`, never silently active — `POST /org/users` passes `status: "invited"` explicitly on the existing-user branch, closing the gap where a standalone user (no prior memberships) could otherwise receive an active grant; (2) a user belonging to two or more organisations may not be edited by an org administrator (`422 multi_org_user_not_editable`); (3) email changes are not available on this surface; (4) an unverified user (one whose `lastLoginAt` is null) may hold exactly one organisation — an org-admin invite for such a user returns `422 unverified_user_single_org`. Rule 4 counts only non-disabled memberships, so a user whose only prior membership was revoked is invitable again. A user already in the calling organisation returns `409 already_member`. `GET /api/admin/org/billing-seat-usage` returns `{ usedSeats: number }` and is intentionally accessible when billing is blocked (grace period expired), returning no names, emails, or membership records. The user-list and invite routes return `422 billing_blocked` after grace period expiry. |
| Live organisation switcher | `GET /api/admin/org/organisations` returns the list of active organisations the signed-in user belongs to. Unlike the user-management routes it requires no administrator role — any active member may call it. The route is the source of truth for the in-app organisation switcher. The query is pinned to the region whose key verified the token, so cross-region grants are never disclosed or selectable. Results are ordered by name, then by id, and include `organisationId`, `organisationName`, `countryCode`, and `role` for each membership. |
| Organisation country | `countryCode` (ISO 3166-1 alpha-2) is stored on organisations and exposed on three surfaces: (1) the signup completion request accepts an optional `countryCode` from the signup form; (2) `GET /api/admin/org/organisations` returns it alongside each switcher entry for use as a map location hint; (3) `PUT /api/admin/org/organisation` lets organisation administrators update their country via the profile endpoint, alongside `name`. The field is optional everywhere — existing organisations without a value return `null`. |
| IDP-M7 | Relay-authenticated invitation acceptance: `POST /api/admin/memberships/accept`. The central login service captures the user's consent and relays it here as a signed JWS command; admin performs the `invited → active` transition and delivers `membership.status_changed` through the normal outbox. This path coexists with the pre-M7 runtime-report path; the two must not both be live for the same membership. |
| PRD 05 §8.4 | Relay-authenticated session revocation: `POST /api/admin/sessions/revoke-all`. The central login service cannot write to the admin database under D41's grant, so a sign-out-everywhere request for a central browser session travels here as a signed body-bound JWS command — the third relay family, after signup and membership acceptance. Admin performs the same `revokeAllSessions` transaction as the runtime-report path and returns `200` once the write is committed. See [Relay-authenticated session revocation](#relay-authenticated-session-revocation-prd-05-§8-4). |
| Rates-reimage L11 / Seed templates by business type | Rate category templates gain `allowedUnitCodes`, `isCartageVehicle`, and `businessType`. Name uniqueness is now per business type rather than global. The authored sub-category level is retired; existing frozen snapshots may still carry the legacy shape and remain supported by tenant ingest. Seed snapshots select categories by the new organisation's business type; new snapshots no longer produce `subCategories` or populated `vocab`. The `org_seed_template` scope is retired for writes (`takeoff_categories`, `allowances_assumptions`, and `tags_clarifications` are refused on create and update; delete remains open). A new unauthenticated `GET /signup/business-types` endpoint serves the signup-form dropdown. `organisations.business_type` is captured at signup completion. Migration `0029` expands only unchanged seeded disposal templates to include `each` and `lot`; migration `0030` adds the missing landscaping `Aggregate` template under `quarry_materials`. |
| Disposal substance templates | Admin-managed default disposal substances per business type. A `disposal_substance_templates` table mirrors `rate_category_templates`: keyed by business type, seeded by migration `0040` with 21 workbook defaults for every live business type, CRUD at `/disposal-substance-templates` (operator-only writes, soft delete, audit). Adding a business type via `POST /global-lists` seeds that type's defaults in the same transaction. Disposal substances are frozen into the new nullable `org_seed_snapshots.disposal_substances` column at org create and emitted in `seed_template.provisioned` as the optional `disposalSubstances` field. A null snapshot (no business type, or a snapshot written before this change) omits the field; the tenant falls back to its built-in defaults. Every org creator (`POST /organisations`, self-service signup, `db:init`) now requires a live `businessType`; missing → `422 business_type_required`, unknown → `422 unknown_business_type`. `db:init` reads `ADMIN_INIT_BUSINESS_TYPE` (Terraform `db_init_business_type`), needed only when the tenant does not yet exist. Migration `0046` replaces the 21 defaults with 12 revised ones for each business type whose live rows are still exactly the 21; an operator-edited set is kept. |
| Platform enum write freeze | The `platform_enum` scope is **retired for writes in full**. Every create, update, and delete on every key (`project_statuses`, `work_item_statuses`, `activity_statuses`, `activity_units`) returns `409 platform_enum_write_forbidden`. A single gate function (`assertPlatformEnumIsWritable`) is called first by all three verbs. Reads and the signup route are unaffected. Region fan-out (`global_list.item_upserted`, `global_list.item_removed`) for platform enum items is unreachable through the API; the fan-out branches are retained as the reopening path. |
| Operator billing triage | Two new operator routes expose Stripe billing state for each organisation: `GET /organisations/:id/billing` (read) and `POST /organisations/:id/billing/refresh` (force a Stripe data refresh). Both are operator-authenticated, proxy to a region-specific billing service via a signed short-lived JWT command, and annotate the response with live seat counts. The refresh path writes a two-phase audit trail. See [Operator billing triage](#organisation-billing-triage). |
| Billing triage and discount codes | Operator billing management via the regional billing service. Two surfaces: organisation billing triage (`GET /organisations/:id/billing`, `POST /organisations/:id/billing/refresh`) and discount code management (`GET /discount-codes`, `POST /discount-codes`, `POST /discount-codes/:id/active`). Self-service signup now accepts an optional `discountCode` on completion, which is stored as `organisations.signup_discount_code` and returned by the billing triage read. Migration `0032_signup_discount_code.sql` adds the column. |
| Contractor companies | A contractor company (on the contractor plan) can create companies for its clients and place its own people in them, and any company can send a contractor company a link by email. `stralos-admin-api` owns the links (`company_contractor_links`) and the contractor memberships (`memberships.via_link_id`), and syncs billing state to `stralos-api-billing`. Migrations `0041_contractor_companies.sql` and `0042_login_role_contractor_links_read.sql`. See [Contractor companies](#contractor-companies). |
| Operator session renewal | `POST /api/admin/auth/refresh` extends a live operator session and mints a fresh bearer without requiring another Google sign-in. The token TTL stays at 30 minutes. A single Google sign-in is bounded at 12 hours (`OPERATOR_SESSION_MAX_LIFETIME_MS`); a renewal is clamped to that ceiling. The route requires a valid operator session — the deny-list, expiry, and operator-active checks all run before the renewal. See [Operator session renewal](#operator-session-renewal). |
| Project lead departure | Durable two-phase reservation protocol that prevents a user disable or membership revoke from leaving a project without a lead. The transport routes through `RUNTIME_INGEST_URLS` (the API ingest map). See [Project lead departure](#project-lead-departure). |

### Worker schedules

| Worker mode | Schedule | Terraform variable required |
| - | - | - |
| Event flush | Every minute | `enable_event_sync = true` |
| Reconciliation | Daily | `enable_event_sync = true` |
| Provisioning saga | Every minute | `enable_event_sync = true` |
| Cognito provisioning sweep | Every minute | `enable_event_sync = true`, `cognito_user_pool_id` set |
| Cognito pool reconciliation | Daily | `enable_event_sync = true`, `cognito_user_pool_id` set |
| Invite dispatch | Every minute | `enable_event_sync = true`, `mail_hosted_zone_id` set |
| Directory backfill | One-shot operator action | CLI only; not a recurring schedule |

The Terraform stack deploys separate Lambda entry points for the API, database
migration, event worker, and operator operations. API Gateway
exposes only the operator API. The operator function requires explicit
IAM invocation and has no public route or recurring trigger.

The Cognito and invite schedules are separate EventBridge rules rather than modes
inside the flush Lambda. This keeps the outbox flusher from being blocked by a
Cognito outage, and prevents the pool reconciler from running at flush frequency.

## OpenAPI contract

The admin API publishes a machine-readable OpenAPI 3.1 document at `GET /api/admin/openapi.json`.
This endpoint is unauthenticated (no operator session required), built once at startup, and returns
byte-stable bytes per deploy. It is a documentation contract only — it is explicitly excluded from
the copilot tool substrate because the described operator surface accepts operator sessions, while
the copilot acts as a tenant user. The separate organisation self-service routes are not part of
this document.

The document covers 39 operations across nine resource groups:

| Tag | Operations |
| - | - |
| `operators` | `admin.operators.list`, `admin.operators.create`, `admin.operators.disable` |
| `organisations` | `admin.organisations.list`, `admin.organisations.get`, `admin.organisations.create`, `admin.organisations.update`, `admin.organisations.disable`, `admin.organisations.billing.get`, `admin.organisations.billing.refresh` |
| `users` | `admin.users.list`, `admin.users.get`, `admin.users.create`, `admin.users.update`, `admin.users.disable`, `admin.users.changeEmail` |
| `memberships` | `admin.memberships.list`, `admin.memberships.grant`, `admin.memberships.updateRole`, `admin.memberships.revoke` |
| `global-lists` | `admin.globalLists.list`, `admin.globalLists.create`, `admin.globalLists.update`, `admin.globalLists.delete` |
| `rate-category-templates` | `admin.rateCategoryTemplates.list`, `admin.rateCategoryTemplates.create`, `admin.rateCategoryTemplates.update`, `admin.rateCategoryTemplates.delete` |
| `disposal-substance-templates` | `admin.disposalSubstanceTemplates.list`, `admin.disposalSubstanceTemplates.create`, `admin.disposalSubstanceTemplates.update`, `admin.disposalSubstanceTemplates.delete` |
| `app-config` | `admin.appConfig.list`, `admin.appConfig.set` |
| `billing` | `admin.discounts.list`, `admin.discounts.create`, `admin.discounts.setActive` |

Success response bodies are currently phased (`x-stralos-response-status: undocumented`); the
operation IDs and request schemas are stable and machine-readable. The security scheme is
`operatorAuth` (HTTP bearer JWT); every protected operation returns a 401 when a valid operator
session token is absent. There is no per-route 403 — any active operator may call any operation.

The document version (`info.version`) is `1.0.0`. This is the descriptor-contract version, managed
independently of the service or deploy version; it advances only on an intended contract change.

## Authentication boundary

The admin API separates these authenticated channels:

* **Operators** — Stralos employees. Sign-in uses Google OAuth2 (authorisation code + PKCE, server-side redemption) restricted to the `stralos.co.nz` Google Workspace domain. The service verifies the JWKS signature, RS256 only, the allow-listed issuer, the exact audience, the ceremony nonce, `email_verified`, and the `hd` Workspace claim alongside the address domain. A successful ceremony returns a short-lived operator session JWT signed by the admin service's own keypair (audience `stralos-admin-<env>`); a Google ID token is never accepted as a session. This preserves logout, immediate operator disable, and revocation — none of which a foreign token can express. MFA is now an organisational control: the service delegates second-factor enforcement to Google Workspace 2SV policy and cannot verify which factors were used. In dev, test, and prod, `google_auto_provision_operators` is enabled: any verified `stralos.co.nz` Workspace account is seeded as an operator row on first sign-in, audited as `operator.provisioned`. The Workspace domain gate is the bounding control. The bootstrap CLI remains available for environments where auto-provisioning is off. While the console remains open, the browser renews its bearer token via `POST /api/admin/auth/refresh` without repeating the Google ceremony. Each renewal extends the session by 30 minutes, clamped to 12 hours from the original sign-in.
* **Runtime report flusher** — a machine principal. Reports use a dedicated JWT type (`stralos-report+jwt`) verified against a per-region JWKS.
* **Organisation members and administrators** — tenant end-users. These callers present an ordinary api-global access token (`typ: "user"`, audience `stralos-api`) verified by `auth/orgAdminToken.ts`. The user-management routes (`/api/admin/org/users`) additionally re-derive administrator authority from the admin store via `auth/orgAdmin.ts`. The organisation switcher route (`GET /api/admin/org/organisations`) makes no role check beyond a valid token; any active member may call it.
* **Release journey gate** — a machine principal used by the promotion pipeline (PRD 03). It presents a service token issued by api-global (`typ: "service"`, audience `stralos-admin-api`, scope `admin:probe-read`) and is verified against a per-region JWKS configured by `ADMIN_SERVICE_TOKEN_SOURCES`. This principal can reach only the service probe route. It reads no tenant row, no operator row, and no catalogue item.
* **Login relays** — api-login sends signed, body-bound commands for self-service signup completion, membership acceptance, and session revocation. The three command families share a signing key but use distinct token types and audiences; no family's captured command is valid as another.

The login route, health check, event JWKS endpoint, and `GET /api/admin/openapi.json` are
unauthenticated. Operator routes require a live operator session. Organisation self-service routes
(`/api/admin/org/*`) are exempt from the operator hook and require a valid org user token instead. The service probe routes (`/api/admin/service/*`) are exempt from the operator hook and require a valid service token instead.

The org self-service surface is not covered by the `openapi.json` descriptor contract; it uses a separate authentication path and is not part of the copilot tool substrate.

`POST /api/admin/memberships/accept` uses the relay-authenticated signed-command channel. It rejects an operator session token and is excluded from the OpenAPI descriptor contract. See [Relay-authenticated invitation acceptance](#relay-authenticated-invitation-acceptance) below.

## Operator session renewal

`POST /api/admin/auth/refresh` extends a live operator session and returns a fresh bearer token without another Google sign-in. An operator who keeps the console open is renewed automatically rather than being redirected to Google every 30 minutes.

### How it works

The route requires a valid operator bearer token. The deny-list, expiry, and operator-active checks all run in the auth pre-handler before the route body executes.

Inside the route body, the service:

1. Re-reads the operator row and confirms the operator is still `active`. A disable that races this route does not result in a freshly extended session.
2. Calls `renewSession`, which extends `operator_sessions.expires_at` in a conditional `UPDATE`. The `WHERE` clause repeats the unrevoked and unexpired predicates, so a logout that races the renewal wins — the revoked row is not resurrected.
3. Mints a new bearer token with a TTL equal to the time remaining on the renewed session.

### Session lifetime ceiling

The token TTL is 30 minutes. A session's maximum lifetime from the original Google sign-in is 12 hours (`OPERATOR_SESSION_MAX_LIFETIME_MS`). Each renewal is clamped to that ceiling rather than extended past it. Once the ceiling is reached, the next renewal attempt returns `401` and the operator must sign in again via Google.

### Renewal request and response

```http theme={null}
POST /api/admin/auth/refresh
Authorization: Bearer <operator-token>
```

| Status | Body | Meaning |
| - | - | - |
| `200` | `{ token, tokenType, expiresAt, operator: { id, email, name } }` | Session extended. Use the returned `token` for subsequent requests. |
| `401` | `{ message: "Session is no longer valid." }` | The operator is disabled, the session is expired or revoked, or the 12-hour ceiling has been reached. |

The route is not auth-exempt. It is excluded from the OpenAPI descriptor contract, which covers only protected operator operations that accept a live session.

## Managed records

The admin store currently owns:

* organisations and their home region (including `signup_discount_code` if a discount code was submitted at signup, and `country_code` if a country was supplied at signup or updated via the org profile endpoint);
* global user identities (including Cognito provisioning state and invite tracking);
* organisation memberships and roles (including invitation generation);
* the admin-side platform configuration contract;
* platform list definitions and legacy new-organisation seed vocabulary;
* rate-category seed templates (keyed by business type, with ordered allowed units);
* disposal substance seed templates (keyed by business type; seeded with 21 workbook defaults per type by migration `0040`, replaced with 12 revised defaults by migration `0046` where unedited);
* per-organisation seed snapshots (immutable, created at org-create time);
* operator credentials and sessions;
* audit records;
* provisioning-event outbox and aggregate-version records;
* Cognito operation queue (`cognito_operations`); and
* legal acceptance records (`legal_acceptances`) — an append-only history of each user's Terms and Privacy acceptances, keyed by user and cascaded on user erasure.

Revoking a membership sets its admin record to `disabled`; it does not delete the
row. Disabling a user or changing an email updates the admin store and enqueues the
corresponding event. Enable event synchronisation and its recurring workers
separately for each environment that should deliver these changes.

`default_markup_percent` and `pricing_average_speed_kmh` are retired admin
configuration keys. Attempts to set them return `422` with code
`config_key_removed`. Default markup is organisation-owned in the Global API.

## Platform catalogue

The admin `global_lists` store separates code-coupled platform values from
legacy new-organisation seed vocabulary and signup configuration:

| Scope | Purpose | Mutation boundary |
| - | - | - |
| `platform_enum` | Canonical project/activity/work-item statuses and activity-basis values | **Retired for writes in full.** Every verb on every key (`project_statuses`, `work_item_statuses`, `activity_statuses`, `activity_units`) returns `409 platform_enum_write_forbidden`. The scope remains readable. The values are coupled to Postgres enums and runtime code; changing one requires a coordinated migration rather than an API edit. Unit catalogue rows are outside this store. |
| `org_seed_template` | Vocabulary formerly copied into a new organisation's immutable seed snapshot | The scope is **retired for writes** as of Rates-reimage L11. All four known keys (`company_locations`, `takeoff_categories`, `allowances_assumptions`, `tags_clarifications`) return `422 retired_seed_template_key` on create or update. Delete remains open so rows in environments that seeded before this change can still be removed. The scope stays readable; frozen `org_seed_snapshots` rows carry vocab forever. `takeoff_categories`, `allowances_assumptions`, and `tags_clarifications` remain live org-scoped lists in the tenant runtime — what is retired is seeding them from here. |
| `signup_vocab` | Options served to the pre-authentication signup form | Free CRUD for the `business_types` key. Values are public by construction (the endpoint is unauthenticated). Adding a value whose `listKey` is `business_types` also seeds the 12 default disposal substance templates (`src/lib/disposalSubstanceDefaults.ts`) for that type in the same transaction, so a new business type is never left seeding organisations with no disposal substances. |

Migration `0005_seed_platform_lists_retire_markup.sql` seeds the canonical
project statuses, activity statuses and basis values, and work-item statuses
with stable IDs. Existing rows are preserved by `ON CONFLICT DO NOTHING`.

### Delete behaviour

`DELETE /global-lists/:id` soft-deletes the item. The row is stamped with `deleted_at` and `deleted_by` and kept for history. It is hidden from list results and from the signup business-type check. A repeat `DELETE` on an already-deleted item returns `204` with no change and no second audit record. Any other write (`PUT`) to a soft-deleted item returns `409` with code `deleted`.

Unique constraints are enforced among live rows only. A value that was previously deleted can be re-created under the same `listKey` and `scope`.

### Global list item fields

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Stable row identifier. |
| `scope` | `platform_enum`, `org_seed_template`, or `signup_vocab` | Selects the mutation and delivery boundary. |
| `listKey` | string | Registered catalogue key. |
| `value` | string | Stored key used by runtime code. |
| `label` | string | Required full human-readable label. |
| `sortOrder` | integer | Display order within the list. |
| `deletedAt` | timestamp | Set when the item is soft-deleted. `null` for live items. |
| `deletedBy` | UUID | Operator id of the person who deleted the item. `null` for live items. |

The runtime Global API owns the immutable `unit_catalogue`. It is deliberately
outside the admin settings and event-projection contracts; do not add unit
catalogue rows to admin list or seed-template payloads.

There are currently no writable `app_config` keys. Unknown keys return
`422 unknown_config_key`; the two retired pricing keys return
`422 config_key_removed`.

## Rate category templates

Rate category templates define the default rate categories a new organisation starts with. Each template belongs to a **business type** and carries the units each category may be priced in. Names are unique per business type — a global unique would let exactly one type own a name such as "Internal labour".

### Template fields

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Stable row identifier. |
| `businessType` | string (1–40 chars) | The business type this template belongs to. Required on create. Cannot be changed on update — moving a template between business types requires delete and recreate. Must match a value in `global_lists(scope='signup_vocab', listKey='business_types')`. |
| `name` | string (1–200 chars) | Display name. Unique per `businessType`. |
| `parentGroup` | `ParentGroup` | Required on create; optional on update (PATCH semantics). |
| `allowedUnitCodes` | string\[] (1–64 items) | Ordered list of unit codes the category may be priced in. The first entry is the tenant UI's default. Must have at least one entry. Codes are validated for shape (non-empty string) only — the unit catalogue lives in the tenant runtime. |
| `isCartageVehicle` | boolean | Valid only under `plant_vehicles`. Rates under this category show Payload (t) and Bin volume (m³). Defaults to `false`. |
| `deletedAt` | timestamp | Set when the template is soft-deleted. `null` for live templates. |
| `deletedBy` | UUID | Operator id of the person who deleted the template. `null` for live templates. |

`parentGroup` classifies the template within six taxonomy nodes:

| Parent group token | Display label |
| - | - |
| `labour` | Labour |
| `plant_vehicles` | Plant & Vehicles |
| `quarry_materials` | Bulk Materials |
| `other_materials` | Materials |
| `subcontractors` | Subcontractors |
| `disposal` | Disposal |

The create body is strict — unknown fields are refused with `400`. This prevents silent acceptance of misspelled field names such as `allowedUnits` or `unitCodes`.
`parentGroup` is required on create; omitting it returns `400`. On update it is optional (omit means keep the current value).

`allowedUnitCodes` must contain at least one code. The API validates the array for shape (non-empty string, unique, minimum one entry) but not for membership against the unit catalogue — the catalogue lives in api-global's tenant database and is not reachable from the admin service. The admin console picker reads its options from `UNIT_CATALOGUE` in `@stralos-co-nz/ui` (27 units); a code not in that list but valid on the wire will be accepted and passed through to the tenant ingest, which silently drops codes it does not recognise.

`isCartageVehicle` is invalid on any parent group other than `plant_vehicles`. The API returns `422 cartage_requires_plant_vehicles` when the combination is submitted. The admin console enforces this client-side to provide an immediate error rather than waiting for a server rejection.

### Delete behaviour

`DELETE /rate-category-templates/:id` soft-deletes the template. The row is stamped with `deleted_at` and `deleted_by` and kept for history. It is hidden from list results and excluded from the new-organisation seed snapshot. A repeat `DELETE` on an already-deleted template returns `204` with no change and no second audit record. Any other write (`PUT`) to a soft-deleted template returns `409` with code `deleted`.

Unique constraints are enforced among live rows only. A name that was previously deleted can be re-created under the same `businessType`.

Organisations already seeded before a template was deleted are unaffected. Their categories came from a frozen `org_seed_snapshots` copy taken at create time.

### Sub-category templates (retired)

The authored sub-category level (`rate_sub_category_templates`) is retired. The tenant flattened its category tree (api-global 0036), so the authored third level existed only to be promoted back to one. Existing frozen `org_seed_snapshots` rows can still carry `subCategories` entries, and tenant ingest continues to promote them. The routes, the `General` sub-category auto-creation, and the cascade from category reparenting are no longer in effect.

### Seeding behaviour

Rate category templates do not emit standalone provisioning events. They are captured in the seed snapshot at organisation-create time, while new snapshots emit an empty `vocab` array. Immutable older snapshots may still replay vocabulary. Changes do not affect organisations created before the edit.

Migration `0029_disposal_units_each_lot.sql` changes every `disposal` template whose stored array is still exactly `["cubic_metre", "tonne"]` to `["cubic_metre", "tonne", "each", "lot"]`. Templates with any operator-edited unit list are preserved, and cubic metre remains the first/default unit. Migration `0030_landscaping_aggregate.sql` adds `Aggregate` to the landscaping starter set under `quarry_materials` with cubic metre and tonne. Because snapshots are immutable, both changes affect future organisations only.

## Disposal substance templates

Disposal substance templates define the default disposal substances a new organisation starts with. Each template belongs to a **business type** (the same key used by rate-category templates) and carries the density and compaction values used in disposal calculations.

Migration `0040` seeded 21 workbook defaults for every live business type. Migration `0046` replaces them with 12 revised defaults, but only for a business type whose live rows are still exactly the 21 that `0040` seeded (same names, values, reinstatement flag and sort order, nothing added or removed). Any operator-edited set is kept. The old rows are soft-deleted (`deleted_by` null) before the new ones are inserted, and the migration is re-runnable. Existing organisations and frozen seed snapshots are not changed.

When a new business type is added via `POST /global-lists`, the 12 current defaults from `src/lib/disposalSubstanceDefaults.ts` are inserted in the same transaction so no type is ever left without a starter set.

### Template fields

| Field | Type | Notes |
| - | - | - |
| `id` | UUID | Stable row identifier. |
| `businessType` | string (1–40 chars) | The business type this template belongs to. Required on create. Cannot be changed on update — moving a template between business types requires delete and recreate. Must match a value in `global_lists(scope='signup_vocab', listKey='business_types')`. |
| `name` | string (1–120 chars) | Display name. Unique per `businessType` (case- and edge-space-insensitive). |
| `looseDensity` | number | Positive, max 10, three decimal places. |
| `bulkingPct` | number | 0–200, three decimal places. |
| `compactionPct` | number or null | 0–100, three decimal places. `null` when the substance cannot be used for reinstatement. |
| `canUseForReinstatement` | boolean | Required on create. A substance that carries a `compactionPct` must have this set to `true`; setting it to `false` while supplying a compaction value returns `400`. |
| `sortOrder` | integer | Display order within the business type. Omitting it on create appends after the last live row. |
| `deletedAt` | timestamp | Set when the template is soft-deleted. `null` for live templates. |
| `deletedBy` | UUID | Operator id of the person who deleted the template. `null` for live templates. |

The create body is strict — unknown fields are refused with `400`.

### Delete behaviour

`DELETE /disposal-substance-templates/:id` soft-deletes the template. The row is stamped with `deleted_at` and `deleted_by` and kept for history. It is hidden from list results and excluded from new-organisation seed snapshots. A repeat `DELETE` on an already-deleted template returns `204` with no change and no second audit record. Any other write (`PUT`) to a soft-deleted template returns `409` with code `deleted`.

Unique name constraints are enforced among live rows only.

### Seeding behaviour

Disposal substance templates do not emit standalone provisioning events. They are frozen into the seed snapshot at organisation-create time alongside rate categories. Changes affect future organisations only — organisations already created own their substances in the tenant and are never modified from here.

Existing organisations created before migration `0040` received their disposal substances from the tenant's own built-in defaults. A null `disposalSubstances` field in a seed snapshot has the same effect: the tenant falls back to its own defaults.

## Deploy-time tenant bootstrap (`db:init`)

The `db:init` bootstrap provisions the founding tenant for a brand-new environment: one organisation, one administrator, one membership, and the Cognito intent that will give that administrator an account. You can run it locally with `pnpm db:init`; deployments invoke the same operation through the migration Lambda. It is idempotent, so a second run is a no-op that exits 0.

### Configuration

All three naming variables must be set together or omitted together. Omitting them is a valid state (the command skips gracefully and exits 0). Setting only some of them is a hard error, because that is the shape a templating mistake produces.

| Variable | Required | Purpose |
| - | - | - |
| `ADMIN_INIT_ORGANISATION_NAME` | Together with the other two | Organisation display name. |
| `ADMIN_INIT_ADMIN_EMAIL` | Together with the other two | Administrator email address. Stored lower-cased. |
| `ADMIN_INIT_ADMIN_NAME` | Together with the other two | Administrator display name. |
| `ADMIN_INIT_ORGANISATION_TIMEZONE` | No | Organisation timezone. Defaults to the server default when omitted. |
| `ADMIN_INIT_HOME_REGION_KEY` | No | Home region for the new organisation. Defaults to this deployment's own `REGION_KEY`. Must be a key present in `RUNTIME_INGEST_URLS`. |
| `ADMIN_INIT_BUSINESS_TYPE` | Only when creating | Business type of the founding organisation. Must be a live `business_types` value. Required only when `db:init` would create the tenant; environments already initialised never need it. An environment that needs to create but omits it fails with `422 business_type_required`. |

Terraform maps these environment variables from `db_init_organisation_name`, `db_init_admin_email`, `db_init_admin_name`, `db_init_organisation_timezone`, `db_init_home_region_key`, and `db_init_business_type`. The first three must be set together or all omitted. Plan-time guards also reject empty values, malformed email addresses, and a home-region key that is absent from the configured region registry. Only the migration Lambda receives the resulting `ADMIN_INIT_*` environment variables.

There is no password variable and none should be added. The administrator's credential is the temporary password delivered by the Cognito invite email after the sweeper creates the account. A plaintext password in configuration would sit at rest in SSM and be silently ignored wherever Cognito owns the credential.

### What it creates

In a single transaction with a transaction-scoped advisory lock (key `5150120002`):

1. Checks whether a user with `ADMIN_INIT_ADMIN_EMAIL` already exists. If one is found, the run returns `already_initialised` and exits 0 without writing anything.
2. Calls `createTenantInTx` — the same function used by self-service signup — to insert the organisation, the admin user (`provisioning='pending'`), an active administrator membership, the seed snapshot, audit records, and outbox events in one atomic write.
3. Enqueues a `create` row in `cognito_operations` in the same transaction.

Because the Cognito intent and the user row commit together, there is no state in which a user exists that nothing will ever provision.

### Local versus deployed behaviour

The Postgres writes are identical in both environments. The difference is who else is running:

* **Deployed** — the events worker's Cognito sweeper picks up the `create` operation, calls `AdminCreateUser`, marks the user `provisioned`, and only then is the invite eligible to be released.
* **Local** — there is no sweeper. The operation sits `pending` indefinitely and nothing calls Cognito. The regional runtime's OTP sign-in authenticates from Postgres, so the administrator can log in regardless.

The absence of the sweeper is the whole difference. No `if (cognito)` branch exists in the bootstrap, deliberately: two bootstrap paths could drift.

### Exit codes and output

| Outcome | Exit code | `status` in JSON output |
| - | - | - |
| Tenant created | 0 | `created` |
| Already initialised (keyed on admin email) | 0 | `already_initialised` |
| Variables unset — skipped | 0 | `skipped` |
| Partially configured or runtime error | Non-zero | — |

### Deployment rail

After every Terraform apply, `scripts/finalize-deploy.sh` checks `db_init_configured`. When it is true, the script invokes the immutable migration Lambda version with `{"mode":"db-init"}`. Both the normal migration mode and `db-init` run migrations first, so the bootstrap cannot reach an unmigrated schema.

The invocation occurs before writers are restored. The finaliser requires a structured `created` or `already_initialised` response; a Lambda error, an older artifact that does not support the mode, or an unexpected `skipped` response fails closed and leaves writers disabled. Because the schema has already changed at this point, recovery resumes the finaliser and safely repeats the idempotent operation.

Idempotency is keyed on the administrator's email address, not the organisation name. A name is configuration and gets edited; keying on it would create a second tenant on the next deploy instead of recognising the first.

### Lambda alias release

API Gateway integrations for the main admin API Lambda target `aws_lambda_alias.live`, not `$LATEST`. The optional dedicated signup method targets `aws_lambda_function.signup` instead. Publishing a new main API artifact with `update-function-code --publish` cuts a numbered version but does not move the alias, so the main API edge keeps serving the previous deployment's bytes until the alias is explicitly updated.

`scripts/promote-alias.sh` moves the `live` alias onto the version the finaliser just published and verifies the move via two independent read-backs: the alias must resolve to that version, and `get-function-configuration --qualifier live` must carry this run's `CodeSha256`. The finaliser calls `promote-alias.sh` immediately after promoting the API function code and before recording the `artifacts_promoted` progress marker. A failure here fails the deployment closed.

Terraform cannot perform this step. `aws_lambda_function.api` carries `ignore_changes = [source_code_hash]`, so any version Terraform publishes snapshots the code already on the function, not the newly promoted artifact. Only the finaliser, which owns the promotion, knows the correct target version.

`scripts/promote-alias.sh` refuses `$LATEST` as a target and uses the alias revision ID for optimistic concurrency, so a concurrent `release-alias.yml` rollback cannot be silently overwritten by a deploy. `assert_promoted_writers`, which runs after writers are restored, verifies the alias qualifier as well as `$LATEST` to confirm the edge is serving the promoted artifact.

## Organisation lifecycle

An organisation is created with status `provisioning`. It is not usable until the
provisioning saga (see [below](#provisioning-saga)) confirms that both required
runtime projections have landed and flips the org to `active`.

The full lifecycle is:

| Status | Meaning |
| - | - |
| `provisioning` | Create transaction committed; waiting for the saga to confirm both projections. |
| `active` | Both `organisation.created` and `seed_template.provisioned` acknowledged by the runtime and independently confirmed via the replica read. |
| `failed` | A required outbox row was dead-lettered (`conflicted`). Recoverable: reconciliation re-arms the conflicted row, and the saga flips the org to `active` once redelivery converges. |
| `disabled` | Disabled by an operator action. |

Status is not freely settable via the API. Activation and failure transitions are
owned exclusively by the provisioning saga.

### Organisation slug

Every organisation has a `slug`: a globally unique, URL-safe identifier used as the tenant key in `login.<env>/t/<slug>`. The slug is set at create time and cannot be changed afterwards.

`POST /organisations` accepts an optional `slug` field. When the caller omits it, the API derives a slug from the organisation name. If derivation fails (the name reduces to a reserved word or fewer than two characters), the API returns `422 org_slug_underivable` and the operator must supply an explicit slug. A slug that is already taken returns `409 org_slug_taken`.

The slug is carried on every `organisation.*` event payload so the runtime can resolve a tenant to its home region. See [Organisation slug](https://help.stralos.app/internal/org-security.md#organisation-slug) for the full format rules, reserved words, and event-delivery behaviour.

## Self-service signup

The login site exposes `/signup`. The browser first submits only the prospective administrator's email address to `stralos-api-login`. The start response is deliberately uniform: it does not reveal whether the address is already registered or whether a message was sent.

The signup form also fetches the available business types via `GET /signup/business-types`. This endpoint is unauthenticated and read-only. It returns only values from `global_lists(scope='signup_vocab', listKey='business_types')` — operator-editable entries — and returns `503` when signup is not configured. Values in this list are public by construction; do not add anything here that an anonymous caller should not see.

After the user supplies the emailed code, their name, the organisation name, their type of business, and a password, api-login relays the completion request to admin-api. Admin verifies the code and creates these records in one transaction:

* the organisation in `provisioning` status, with the required `businessType` recorded, `signupDiscountCode` set when a `discountCode` was submitted, and `countryCode` set when a `countryCode` was supplied;
* an active, provisioned user;
* an active administrator membership;
* a `legal_acceptances` row recording the accepted Terms and Privacy versions;
* the seed snapshot (categories selected by `businessType`), audit records, and required outbox events; and
* the directory replacement for the user's region set.

The completion request requires three fields for Terms and Privacy acceptance:

| Field | Type | Notes |
| - | - | - |
| `acceptedTerms` | `true` (literal) | Must be exactly `true`. A missing or `false` value returns `400`. |
| `termsVersion` | string (1–32 chars) | The Terms version label shown on `stralos.app/terms`. Admin records the version it is told; api-login is the authority on which version is current. |
| `privacyVersion` | string (1–32 chars) | The Privacy Policy version label shown on `stralos.app/privacy`. Same recording contract as `termsVersion`. |

These fields are required on every completion request — they cannot be omitted or defaulted. The `legal_acceptances` table is append-only: a later re-acceptance of a newer version adds a new row, so the table is the full history of what each user agreed to and when. The acceptance row is written in the same transaction as the user; there is no user without an acceptance record, and no acceptance record without a user. The `ON DELETE CASCADE` constraint on `user_id` ensures that user erasure (the retention Lambda) removes both the user row and the acceptance history together.

Apply migration `0039_legal_acceptances.sql` before promoting code that writes the new columns.

The optional `discountCode` field on the completion request accepts a code in the format `[A-Za-z0-9-]+` (1–100 characters). Admin stores it on the organisation as `signup_discount_code` but does not validate it against the billing service at signup time. The billing triage read returns it as `signupDiscountCode` so operators can see what code the organisation signed up with.

The completion request requires a live `businessType`. A missing type returns `422 business_type_required`; an unknown type returns `422 unknown_business_type`.

The signup page presents its completion state after this transaction and directs the administrator to sign in; signup itself does not issue a browser session. Independently, the normal provisioning saga activates the organisation after its runtime projections are confirmed.

### Password requirements

The completion request enforces the Cognito user pool's full password policy before the one-time code is consumed or a Cognito account is created. A password that does not satisfy every rule returns `400` with per-rule field errors.

| Rule | Constraint |
| - | - |
| Minimum length | 12 characters |
| Maximum length | 256 characters |
| Uppercase letter | At least one required |
| Lowercase letter | At least one required |
| Number | At least one required |
| Special character | At least one from the pool's documented 32-character set |
| Leading or trailing space | Not allowed |

The special-character set is the 32 printable ASCII punctuation characters Cognito's pool policy declares as satisfying `RequireSymbols`. Space is deliberately excluded: the pool permits a space inside a password but does not document it as satisfying the symbols requirement. The same rule is duplicated in `stralos-api-login` and `stralos-login-web`; change all three together.

### Email alias recovery

The Cognito pool declares `AliasAttributes: ["email"]`, making the email address a second unique namespace alongside the UUID username. A signup attempt that fails after creating a Cognito account but before completing the database transaction leaves an orphaned account holding the email alias. Previously, retries received `AliasExistsException` from `AdminCreateUser` and returned `503` permanently.

The completion path now resolves this: when `AliasExistsException` is raised, admin-api calls `AdminGetUser` to locate the alias holder and adopts it if all four conditions hold:

1. The email attribute on the Cognito account matches the address being signed up.
2. There is no `users` row for the address or for the holder's username.
3. A `signup_intents` row records that this flow originally minted that username for this address, with a status of `completing`, `expired`, or `failed`.
4. No enabled tenant was ever created from that intent.

Any condition that does not hold returns `409`. A claimed alias cannot be cleared by retrying — the adoption path recovers only orphans this flow demonstrably created, and nothing is ever deleted.

See [Get started with Stralos](https://help.stralos.app/guides/admins/getting-started.md) for the user-facing steps and [Admin provisioning — signup channel](https://help.stralos.app/internal/admin-provisioning.md#signup-channel) for the server-to-server boundary.

## Seed template snapshot

When an organisation is created, the API atomically:

1. Validates that a live `businessType` was supplied. A missing value returns `422 business_type_required`; an unknown value returns `422 unknown_business_type`. This applies to `POST /organisations`, self-service signup completion, and `db:init`.
2. Inserts the organisation row with status `provisioning`.
3. Reads the `rate_category_templates` rows and the `disposal_substance_templates` rows for the organisation's `businessType` and freezes them into an immutable `org_seed_snapshots` row (the `templateSnapshotId`).
4. Enqueues both `organisation.created` and `seed_template.provisioned` outbox rows in the same transaction.

Because the snapshot is written at create time, later edits to rate-category templates cannot mutate a pending provisioning event. Reconciliation re-arms the existing outbox row, so redelivery reuses the frozen payload and never rereads live templates.

The `seed_template.provisioned` payload carries:

| Field | Description |
| - | - |
| `organisationId` | The new org's id. |
| `templateSnapshotId` | The `org_seed_snapshots.id` the event was built from. |
| `rateCategories` | New snapshots contain `{ templateId, name, parentGroup, allowedUnitCodes, isCartageVehicle }`. The schema keeps the fields optional and still accepts legacy `subCategories` for immutable older snapshots. |
| `vocab` | Empty for new snapshots, but still emitted because the tenant consumer iterates it. Immutable older snapshots may contain seed-vocabulary items. |
| `disposalSubstances` | Present only when the snapshot has a business type (i.e. created after migration `0040`). Each item carries `{ name, looseDensity, bulkingPct, compactionPct, canUseForReinstatement }`. When absent, the tenant falls back to its own built-in defaults. Immutable older snapshots never carry this field. |

**Permanent backwards compatibility.** Every `org_seed_snapshots` row already written is immutable and may carry `subCategories` and `vocab`. The payload type, the emission schema, and the tenant consumer must all keep accepting both fields forever. What changed is only what a new snapshot contains. `parentGroup`, `subCategories`, `canHaveTravel`, and `canHaveHaulage` are permanently optional in the schema — a field added to a contract over immutable stored payloads is optional forever.

`parentGroup` on each legacy sub-category item is likewise permanently optional. Snapshots that carry no sub-category `parentGroup` remain valid; the consumer falls back to the enclosing category's `parentGroup`.

The `seed_template` aggregate uses the org id as its `aggregateId`, giving the
runtime a clean per-org high-water key. The `seed_template` aggregate maps onto the
organisation advisory lock so delivery and reconciliation for both aggregates
serialise on a single lock.

## Provisioning saga

The provisioning saga is a scheduled sweep (not a request-path operation) that drives
orgs through `provisioning → active` (or `provisioning → failed`). It is invoked as:

* **EventBridge**: every minute.
* **Lambda**: pass `{ mode: "saga" }` to the flush Lambda handler.
* **CLI**: `pnpm flush -- --saga`.

For each org in `provisioning` or `failed` status, the saga evaluates two gates
inside a serialised transaction:

**Gate 1 — outbox delivery**: both required outbox rows (`organisation.created` and
`seed_template.provisioned`) are `processed` (the flusher's terminal state, meaning
the runtime ingest returned 2xx).

**Gate 2 — replica confirmation**: the runtime replica read independently confirms
that both projections are applied at or above the required aggregate version.

When both gates pass, the saga:

1. Updates the org status to `active`.
2. Writes an audit record (`organisation.activated`).
3. Enqueues `organisation.updated { status: active }` via the normal allocator path
   (version-ordered after `organisation.created`, so the flusher delivers them in
   order).

When Gate 1 finds a `conflicted` required row (definitive projection failure), the
saga marks the org `failed`, writes an audit record, enqueues
`organisation.updated { status: failed }` for replica consistency, and fires an
operator alert once (on the `provisioning → failed` edge only).

A `failed` org is recoverable. Reconciliation re-arms the conflicted row; once
redelivery converges and the replica confirms, the saga activates the org. Recovery
is always forward — no revert.

### Concurrency

Each org evaluation holds a `FOR UPDATE` row lock with a status re-check, so two
concurrent saga runs cannot double-flip. The saga uses the normal allocator path and
does not hold the flusher/reconciler per-aggregate advisory lock.

## Event delivery

Applicable provisioning-aggregate mutations enqueue versioned events in the same
transaction as the admin-store change. Rate-category templates do not emit standalone
events; they are captured in the seed snapshot at organisation-create time. New snapshots
carry no `org_seed_template` vocabulary, although immutable older snapshots may still
replay it (see [Rate category templates](#rate-category-templates)). Authentication,
session, and operator changes do not enqueue provisioning events.

The flusher signs event envelopes with the event keypair, delivers pending rows, and
records retry, processed, or conflict outcomes. The dev EventBridge rule invokes it
every minute.

Each outbox row carries a `target_region_key`. The flusher, reconciler, and predecessor-blocking query all filter on this column, so every region has its own independent delivery stream for the same aggregate. The delivery address for each event type is:

| Event scope | Target region |
| - | - |
| `user.*` | All regions the user's `user` aggregate has ever been emitted to, plus all regions the user currently has a non-disabled membership in. |
| `membership.*`, `organisation.*`, `seed_template.*` | The organisation's `home_region_key` at the time of the mutation. |
| `global_list.*`, `app_config.*` | Every configured region. |

The flusher enforces two delivery-ordering dependencies:

* **Per-aggregate predecessor blocking**: a row is not delivered while any lower-version
  row for the same aggregate and target region is non-`processed`.
* **Cross-aggregate seed dependency**: a `seed_template.provisioned` row is not delivered
  until its org's `organisation.created` row is `processed`.

The full event type catalogue is:

| Event type | Trigger |
| - | - |
| `organisation.created` | New org created. |
| `organisation.updated` | Org renamed, timezone, region, or country changed, or status flipped by the saga. |
| `organisation.disabled` | Org disabled by an operator. |
| `user.created` | New user created. |
| `user.updated` | User details updated. |
| `user.disabled` | User disabled. |
| `user.email_changed` | User email changed (carries `previousEmail`). |
| `membership.granted` | Membership created. |
| `membership.revoked` | Membership revoked. |
| `membership.role_changed` | Membership role changed. |
| `membership.status_changed` | A runtime-reported invitation acceptance was validated and applied. Carries the same `MembershipPayload` as other membership events. |
| `global_list.item_upserted` | Platform-enum list item created or updated. **Unreachable while the write freeze is in force** — the API refuses every write on `platform_enum` before the fan-out branch is reached. The branch is retained as the reopening path. |
| `global_list.item_removed` | Platform-enum list item removed. **Unreachable while the write freeze is in force** — same reason as `item_upserted`. |
| `app_config.set` | App-config key set. |
| `app_config.deleted` | App-config key deleted. |
| `seed_template.provisioned` | Seed snapshot delivered for a new org (M4). |
| `directory.regions_replaced` | Full region-set replacement for a user's directory entry (M3b). The payload carries the user's complete set of routable regions; `[]` is valid and means no presence in any region. This is always a full replacement, never a delta. |
| `contractor_link.upserted` | A contractor company link was created or changed while live (invited, accepted, or taken over). Sent to the company's home region. |
| `contractor_link.ended` | A contractor company link ended, for any reason. Sent to the company's home region. |

Every user event (`user.created`, `user.updated`, `user.disabled`, `user.email_changed`) carries a
`sessionGeneration` field (M4c): a non-negative integer that the runtime applies as a
monotone `greatest(existing, incoming)`. The session generation is the retained regional
sign-out-everywhere counter; incrementing it invalidates regional Global API sessions in every
region the event reaches. It does not invalidate the separate central login-service session store.
Legacy outbox rows may carry no `sessionGeneration`; the runtime treats an absent value as a
no-op for the generation column.

## Project lead departure

When an operator disables a user or revokes a membership, the admin API contacts the affected runtime region to verify that the user is not the last project lead on any active project before the disable or revoke takes effect. This check prevents a project from being left without a lead.

The check runs inside the same database transaction as the admin-store write. Before the transaction commits, the API:

1. Enqueues the outbox event (`user.disabled` or `membership.revoked`) as normal.
2. Records a durable `project_lead_departure_intents` row for every affected region. This row is written independently of the identity transaction so that recovery can proceed even if the canonical transaction is rolled back.
3. Sends a signed `projectLeadReservation=prepare` request to the runtime ingest URL for each target region. The runtime checks whether the user holds any last-lead position and returns `409` if so.

If any region responds `409`, the route returns `409` with code `project_last_lead` and the message "Appoint a replacement before deactivating the last project lead." The admin-store transaction is rolled back and no event is enqueued.

### Transport configuration

The departure check uses the same `RUNTIME_INGEST_URLS` map as ordinary event delivery. It does **not** use `RUNTIME_REPLICA_URLS`. A deployment that configures only `RUNTIME_INGEST_URLS` (the API ingest map) without `RUNTIME_REPLICA_URLS` (the worker replica map) will have departure checks work correctly. Using the replica URL for this call was a bug; the runtime ingest endpoint is the correct target for the reservation protocol.

The `RUNTIME_INGEST_URLS` variable must be set for departure checks to function. A missing or empty map causes the route to return `503` with "Project leadership authority is not configured."

### Durable recovery

The `project_lead_departure_intents` table provides crash recovery for the two-phase departure protocol:

| Column | Purpose |
| - | - |
| `event_id` | The outbox row this intent is paired with. |
| `user_id` | The user whose lock serialises recovery. |
| `region_key` | The target region for the reservation. |
| `jws` | The signed event envelope sent to the runtime. |
| `checked_at` | Rotated on each recovery attempt to prevent one bad region from starving others. |

The recovery pass (`recoverProjectLeadDepartures`) runs under the same per-user derivation lock as the canonical outbox writers. When the paired outbox row has reached `processed`, the intent is considered complete and deleted. When the outbox row is absent (the canonical transaction rolled back), the recovery sends a `projectLeadReservation=cancel` request to release any runtime reservation and then deletes the intent.

The recovery pass is invoked by the event flusher Lambda on every scheduled tick. No separate EventBridge rule is required.

### Capabilities table entry

| Area | Scope |
| - | - |
| PRD 01 D3 / project-lead departure | `project_lead_departure_intents` table, durable two-phase reservation protocol, and recovery pass. Departure transport uses `RUNTIME_INGEST_URLS` (not the worker replica map). |

## Runtime report channel

The admin API accepts signed runtime→admin reports at `POST /api/admin/reports/runtime`. Reports are
sent by `stralos-api-global` to relay state transitions that originated in a region's runtime
rather than in an operator action. They use a dedicated JWT type (`stralos-report+jwt`) and are
verified against a per-region JWKS distinct from the admin signing key.

The route requires IAM authorisation and SigV4 plus an EdDSA JWS signed by the runtime keypair.
A distinct issuer and audience, the protected-header type, the environment claim, and
signing-key separation prevent a user access token from being accepted as a report.

The channel requires `report_flusher_role_arn` and a per-region `report_source_map` containing
`{ issuer, jwksUrl }`. The route is unavailable when the channel is not configured.

| Report type | Triggered by | Effect |
| - | - | - |
| `user.activation_reported` | A user completes their first login in a region. | Marks the user active if not already; emits `user.updated`. |
| `membership.acceptance_reported` | A user accepts an invitation in a region. | Sets membership status to `active`; emits `membership.status_changed`; recomputes the user's directory region set. |
| `user.session_revocation_requested` | A user requests sign-out everywhere for retained regional sessions (M4c). | Increments `sessionGeneration` on the admin user row; emits `user.updated` to all regions in the user's sticky region set, including the reporting region. |

The `user.session_revocation_requested` report carries only `{ userId }`. It holds no ordering
value and no generation: two distinct requests both increment the counter (the counter is
monotone, so two increments are correct and safe), and redeliveries are caught by the
`runtime_report_seen` primary key, which replays the recorded outcome rather than incrementing
again.

A report is a command rather than a direct regional write. The admin API validates it against
current admin state and applies accepted changes through its normal versioned outbox path.

### Report outcomes and rejection codes

Membership-acceptance reports return `applied`, `noop`, or `rejected`. A rejection includes one
of the following codes:

| Code | Description | Anomaly | Corrective event |
| - | - | - | - |
| `unknown_user` | The user does not exist in the admin store. | Yes | No |
| `unknown_membership` | The membership does not exist. | Yes | No |
| `membership_mismatch` | The membership belongs to another user. | Yes | Yes |
| `user_disabled` | The user is disabled. | No | Yes |
| `membership_revoked` | The membership is revoked. | No | Yes |
| `organisation_not_active` | The organisation is not active. | No | Yes |
| `stale_invitation` | The report names an older invitation generation. | No | Yes |
| `wrong_region` | The reporting region has never received the aggregate. | No | No |

### Membership payload fields

All membership events carry `invitationGeneration` in addition to the core fields. This is the current incarnation counter for the membership's invited state: it starts at 1 and increments each time a membership transitions back to `invited` (for example, a revoke followed by a re-grant). The `membership.status_changed` event emitted on an acceptance carries the generation that was current when the user consented. A report whose generation does not exactly match the current value is rejected as `stale_invitation`.

| Field | Notes |
| - | - |
| `membershipId` | Stable membership row identifier. |
| `userId` | The user's global admin identifier. |
| `email` | The user's email at the time of the event. |
| `organisationId` | The organisation the membership belongs to. |
| `role` | `admin` or `member`. |
| `status` | `invited`, `active`, or `disabled`. |
| `invitationGeneration` | Monotone incarnation counter. Always present on every membership event. |

## Relay-authenticated invitation acceptance

`POST /api/admin/memberships/accept` is the IDP-M7 acceptance path. The central login service (`stralos-api-login`) captures the user's consent during sign-in and relays it here as a signed JWS command. Admin performs the `invited → active` transition in a transaction and delivers `membership.status_changed` through the normal outbox path.

This path exists because the login service can write nothing to the admin database under M7: its `memberships` grant is `SELECT` on enumerated columns only. The consent therefore travels as a relay command, using the same pattern as self-service signup.

### Transport

The route uses a dedicated relay command model, separate from both the operator session and the runtime report channel:

| Element | Value |
| - | - |
| Header | `x-stralos-membership-command` |
| JWS `typ` | `stralos-membership+jwt` |
| JWS audience | `stralos-admin-membership-<env>` |
| Signing key | The same estate-wide relay key as signup |

A separate `typ` and audience from signup's command family are deliberate. Both families share a signing key, so audience and `typ` together are the entire separation: a captured signup command must not function as an acceptance command, and vice versa.

The request body is bound by hash in the JWS for the same reason signup's is. Without hash binding, a captured token would authenticate the relay but not the specific request body — it could be replayed against a different `(userId, organisationId)` pair, accepting an invitation the user never clicked.

### Invitation acceptance request and response

```http theme={null}
POST /api/admin/memberships/accept
x-stralos-membership-command: <signed JWS>
Content-Type: application/json

{ "userId": "<uuid>", "organisationId": "<uuid>" }
```

| Status | Body | Meaning |
| - | - | - |
| `200` | `{ outcome: "accepted", membership: { id, organisationId, role, status } }` | Transition applied. |
| `200` | `{ outcome: "already_active", membership: { id, organisationId, role, status } }` | Idempotent retry — membership was already active. This is a success, not a conflict: the user consented, and a `409` would send the caller down a failure path for a request that succeeded. |
| `401` | `{ message: "..." }` | `x-stralos-membership-command` header is absent. |
| `403` | `{ message: "..." }` | JWS verification failed, wrong command type, or body-hash mismatch. |
| `404` | — | No `invited` membership exists for that `(userId, organisationId)` pair. Deliberately the same response for "no such invitation" and "someone else's invitation": the route is not a membership-existence oracle. |
| `422` | — | The user or organisation is disabled, or the membership status is not `invited`. |
| `503` | `{ message: "..." }` | The route is not configured (no `verifyCommand` function wired in). |

### Relationship to the runtime report path

The pre-M7 acceptance path is `membership.acceptance_reported` on the runtime report channel: the runtime flips its own replica, reports the fact, and admin applies a CAS. The M7 path is the reverse: consent arrives centrally first and the region learns through the normal admin → region projection.

**The two paths must not both be live for one membership.** A regional `accept_membership_invite` CAS would conflict with the projection that the central acceptance already authorised. The store module records this ownership constraint.

The `membership.status_changed` event emitted on a successful M7 acceptance carries the same `MembershipPayload` shape as other membership events, including `invitationGeneration`.

### Audit

The acceptance is audited with actor `user-consent:<userId>@<organisationId>` and `operator_id = NULL`. The subject of this action is a person, not an operator.

## Relay-authenticated session revocation (PRD 05 §8.4)

`POST /api/admin/sessions/revoke-all` is the sign-out-everywhere path for central browser sessions. The central login service cannot write to the admin database under D41's grant — its role has `SELECT` on `users.session_generation` and nothing more — so a user's sign-out-everywhere request travels here as a signed JWS command. This is the third relay family, after signup and membership acceptance.

### Why a relay rather than a report

`stralos-api-global` reaches the session-revocation write through its runtime-report channel: it enqueues a `user.session_revocation_requested` report into its regional runtime database, and the flusher delivers it to `POST /api/admin/reports/runtime`. `stralos-api-login` has no regional runtime database — it is the login host — so the report channel is not available to it.

### Transport and command authentication

| Element | Value |
| - | - |
| Path | `POST /api/admin/sessions/revoke-all` |
| Header | `x-stralos-session-revocation-command` |
| JWS `typ` | `stralos-session-revocation+jwt` |
| Command type | `session.revoke_all_requested` |
| Signing key | The same estate-wide relay key as signup and membership acceptance |

The `typ`, audience, and command-type enum are the entire separation between the three relay families: all are signed with the same key. A captured command of any family must not be usable as another. The test suite proves a command from one family is refused by every other.

The request body is bound by SHA-256 hash inside the JWS. Without this binding, a captured token would authenticate the relay but not the specific request body. An attacker holding their own relay command could replay it against a different `userId` and sign out anyone in the estate. Body-hash binding is what makes the PRD 05 D14 guarantee ("can revoke none but the CALLER's own sessions") hold across the relay hop.

### Session revocation request and response

```http theme={null}
POST /api/admin/sessions/revoke-all
x-stralos-session-revocation-command: <signed JWS>
Content-Type: application/json

{ "userId": "<uuid>" }
```

The `userId` field is the only field. The login service resolves it from a validated refresh token; it is not a caller-supplied target. There is deliberately no operator-override field: an operator who needs to end a user's sessions uses `POST /users/:id/disable`, which is audited against the operator's own identity.

| Status | Body | Meaning |
| - | - | - |
| `200` | `{ outcome: "revoked", userId: "<uuid>", sessionGeneration: <n> }` | Revocation applied. The generation is incremented and `user.updated` is enqueued. |
| `401` | `{ message: "..." }` | `x-stralos-session-revocation-command` header is absent. |
| `403` | `{ message: "..." }` | JWS verification failed, wrong command type, or body-hash mismatch. |
| `404` | `{ message: "..." }` | No user with the given id. |
| `503` | `{ message: "..." }` | The route is not configured. |

The response is `200` rather than `202` because the write completes before the response. After that success, the login service returns `202 {}` to the browser because regional revocation still converges asynchronously. An unavailable or refused relay returns a dependency error instead.

### Shared transaction

The revocation transaction is `store/sessionRevocation.ts` — the same implementation the runtime-report path uses via `applySessionRevocation`. Both paths increment the same monotone counter, write the same audit record, and enqueue `user.updated` to the same sticky set of regions. Two implementations would risk one forgetting the `user.updated` enqueue, which is the only mechanism by which regions learn the new generation.

On the relay path the `user.updated` event carries no additional region in the fan-out set, because the login host has no regional session state. The sticky set (the union of every region the user has ever been projected into) is sufficient: a user who holds a live central session has completed the sign-in ceremony and therefore has at least one active membership region.

### Idempotency

The revocation command has a `commandId` (`jti`) and a 120-second expiry but no ledger. A replay is harmless because the counter is monotone — a second increment is a strictly stricter session cutoff and can never resurrect a session. This reasoning is the same one applied to two distinct simultaneous `POST /api/login/auth/sessions/revoke-all` requests, which the report channel also counts twice intentionally.

### Audit

The revocation is audited with actor `relay:<relayKey>` and `operator_id = NULL`. The audit action is `user.sessions_revoked`, which is distinct from the report channel's `report.applied.user.session_revocation_requested`, so the audit log unambiguously identifies which channel performed each write.

## Organisation billing triage

The billing triage surface lets operators inspect and refresh an organisation's billing state from Stripe through the regional billing service.

### Routes

| Method | Path | Operation ID |
| - | - | - |
| `GET` | `/api/admin/organisations/:id/billing` | `admin.organisations.billing.get` |
| `POST` | `/api/admin/organisations/:id/billing/refresh` | `admin.organisations.billing.refresh` |

Both routes require an active operator session. They return `503` with `code: "billing_unavailable"` when `BILLING_ADMIN_URLS` is not configured for the environment.

### Billing triage response fields

| Field | Notes |
| - | - |
| `configured` | Whether billing is configured for this organisation's region. |
| `enforced` | Whether billing enforcement is active. |
| `access.mode` | `"full"`, `"read_only"`, or `"blocked"`. |
| `access.reason` | Reason for a restricted mode, or `null`. |
| `access.seats` | Purchased seat count. |
| `status` | Stripe subscription status, or `null`. |
| `currentPeriodEnd` | ISO timestamp of the subscription period end, or `null`. |
| `paymentFailedAt` | ISO timestamp of the first payment failure, or `null`. |
| `readOnlyEndsAt` | ISO timestamp when read-only access expires, or `null`. |
| `readOnlyGraceDays` | Configured grace period in days, or `null`. |
| `cancelAtPeriodEnd` | Whether the subscription cancels at period end. |
| `price` | Current plan price details, or `null`. |
| `discounts` | Active Stripe discounts applied to the subscription. |
| `provider.mode` | `"test"` or `"live"`, indicating the Stripe environment in use. |
| `provider.customerId` | Stripe customer ID, or `null`. |
| `provider.subscriptionId` | Stripe subscription ID, or `null`. |
| `provider.customerUrl` | Link to the customer in the Stripe dashboard, or `null`. |
| `provider.subscriptionUrl` | Link to the subscription in the Stripe dashboard, or `null`. |
| `signupDiscountCode` | Discount code submitted at signup, or `null`. |
| `usedSeats` | Count of active and invited memberships. |
| `activeUsers` | Count of memberships with `status = 'active'`. |
| `invitedUsers` | Count of memberships with `status = 'invited'`. |
| `availableSeats` | `max(0, access.seats - usedSeats)`. |
| `overSeatLimit` | `true` when `usedSeats > access.seats`. |

The `provider.mode` field distinguishes a Stripe test-mode environment from a live one. A `"test"` value means the organisation's billing data lives in Stripe test mode; credentials and events from that environment do not affect real payment instruments.

### Refresh

`POST /api/admin/organisations/:id/billing/refresh` forces a re-read from Stripe via the regional billing service. Admin audits intent before the request and the outcome after. A failed request is audited as `organisation.billing.refresh_failed`. The read and refresh paths share the same signed command channel (`stralos-billing-admin-command+jwt`, audience `stralos-billing-admin-<env>`, expiry 30 seconds).

### Configuration

Set `billing_admin_urls` in Terraform (type `map(string)`, region key → HTTPS endpoint). The value is encoded as `BILLING_ADMIN_URLS` on the API Lambda. An empty map disables both billing triage routes.

## Discount codes

The discount code surface lets operators manage customer discount codes through the regional billing service.

### Routes

| Method | Path | Operation ID |
| - | - | - |
| `GET` | `/api/admin/discount-codes` | `admin.discounts.list` |
| `POST` | `/api/admin/discount-codes` | `admin.discounts.create` |
| `POST` | `/api/admin/discount-codes/:id/active` | `admin.discounts.setActive` |

All routes require an active operator session. They return `503` with `code: "discounts_unavailable"` when `BILLING_ADMIN_URLS` is not configured.

### List

`GET /api/admin/discount-codes` returns a paginated list of discount codes. Use the optional `startingAfter` query parameter (format `promo_[A-Za-z0-9]+`) for cursor-based pagination.

### Create

`POST /api/admin/discount-codes` creates a new discount code. The request body is validated before the billing service is contacted, so a malformed request returns `400` without incurring a provider round-trip.

| Field | Type | Required | Notes |
| - | - | - | - |
| `requestId` | UUID | Yes | Idempotency key. Used as the audit aggregate ID. |
| `code` | string | Yes | 1–100 characters, `[A-Za-z0-9-]+`. |
| `name` | string | Yes | Display name, 1–40 characters. |
| `percentOff` | number | One of `percentOff`/`amountOff` | Percentage discount, exclusive with `amountOff`. |
| `amountOff` | integer | One of `percentOff`/`amountOff` | Fixed discount in the currency's smallest unit, up to 100,000,000. Requires `currency`. |
| `currency` | string | When `amountOff` is set | ISO 4217 lowercase currency code. |
| `duration` | `"once"`, `"forever"`, or `"repeating"` | Yes | How long the discount applies. |
| `durationInMonths` | integer | When `duration = "repeating"` | 1–120 months. |
| `maxRedemptions` | integer | No | Maximum times the code may be redeemed, 1–1,000,000. |
| `expiresAt` | integer | No | Unix timestamp after which the code can no longer be redeemed. |

Either `percentOff` or `amountOff` must be set, but not both. A `"repeating"` duration requires `durationInMonths`; other durations must omit it. A fixed amount requires `currency`; a percentage must omit it.

### Activate or deactivate

`POST /api/admin/discount-codes/:id/active` activates or deactivates a code without affecting existing redemptions. The path parameter must match `promo_[A-Za-z0-9]+`.

| Field | Type | Notes |
| - | - | - |
| `active` | boolean | `true` to activate; `false` to deactivate. |
| `requestId` | UUID | Idempotency key. Used as the audit aggregate ID. |

### Audit trail

Every create and set-active operation is audited twice: once as `discount.<operation>.requested` before the billing service call, and once as either `discount.<operation>.completed` or `discount.<operation>.failed` after. List operations are not audited. This two-phase record ensures a process failure between the request and the provider call leaves a traceable outcome in the audit log.

A provider error returns `503` with `code: "discount_request_failed"`. A provider validation error (for example, a duplicate code) returns `400` with the same code. The signed command uses `typ: stralos-billing-discount-command+jwt`, audience `stralos-billing-discounts-<env>`, and a 30-second expiry.

## Contractor companies

A contractor company works inside other companies, its client companies, through a company link. A contractor company on the contractor plan can create a company for a client and place its own people there. Any company can send a contractor company a link by email. `stralos-admin-api` is the system of record for links and contractor memberships. The tenant-facing routes are reached through the Global API relay; see [Contractor companies](https://help.stralos.app/api/global.md#contractor-companies) in the Global API reference.

### Who can act

Each route re-derives authority from the admin store:

* **Contractor admin**: an admin of the token's company in their own right (the membership has no `via_link_id`), whose company is on the contractor plan. Manages client companies and the contractor's people in them.
* **Company admin**: an admin of the token's company in their own right. Manages the contractors linked to that company.

A contractor person who is an admin inside a client company acts for their contractor company there, not for the client company, so the company-admin routes refuse them with `403 company_admin_required`. A missing contractor plan returns `403 contractor_plan_required`.

### Links

| Field | Values | Meaning |
| - | - | - |
| `origin` | `created`, `invited` | Whether the contractor created the company or was invited in. |
| `managedBy` | `contractor`, `company` | Which side runs the link. A created company starts `contractor`-managed. |
| `status` | `pending`, `active`, `ended` | A `pending` link is an invitation waiting to be accepted. Only `pending` and `active` links are live. |

There is at most one live link between two companies. Links never cross regions: a contractor can only be linked to companies homed in its own region.

### Creating a company for a client

`POST /api/admin/org/client-companies` takes the company's name, country, business type, time zone, optional numbering prefix and starting number (still named `jobNumberPrefix` and `startingJobNumber` on the wire; api-global applies them to the new company's quote numbering), and the contractor's people to place in it with their roles. The creator is always included as an admin. The new company is homed in the contractor's region and records `createdByOrganisationId`. Its link is created `active`, with `origin: created` and `managedBy: contractor`.

A created company's name only has to be unique among the same contractor's companies; a clash returns `409 client_company_name_taken`. After the transaction commits, admin sends api-billing a `cover` command, so the company is covered by the contractor's billing, and a `link_state` command. The response reports `billingSync: "pending"` if either command could not be sent; adding people later re-sends the cover.

### Inviting a contractor

`POST /api/admin/org/contractors/invites` takes an email address. The address must belong to a user who is not disabled and whose default company is an active contractor company in the same region; otherwise the route returns `422 contractor_not_found` or `422 contractor_region_mismatch`. A live link between the two companies returns `409 contractor_already_linked`, unless it is an expired invitation, which is ended and replaced. The new link is `pending`, `invited` and `company`-managed, and the invitation email is sent to that address. It expires after 14 days.

Anyone signed in can read an invitation with `GET /api/admin/org/contractor-invites/:token`, which reports whether they can accept it and, if not, why (`not_admin`, `not_contractor`, `wrong_company` or `expired`). `POST /api/admin/org/contractor-invites/:token/accept` requires an admin, in their own right, of the invited contractor company while it is on the contractor plan. Accepting activates the link, gives the accepting admin an admin membership in the client company through the link, and sends a `link_state` command.

### Contractor people and seats

Contractor people are memberships with `via_link_id` set to their link. A contractor admin adds one of their own active people to a client company with `POST /api/admin/org/client-companies/:linkId/people` and removes them with `POST /api/admin/org/client-companies/:linkId/people/:userId/remove`. People are added as active members without an invitation email; they receive an "added" notification instead. Contractor people never take one of the client company's seats.

`stralos-api-login` mints a contractor person's token only while their link is `active`, and stamps it with the `via` claim (`org`, `link`, `name`). Migration `0042` grants the login role column-scoped `SELECT` on the link columns it needs, and nothing else on that table.

### Ending, closing and taking over

Either side can end a live link: the contractor with `POST /api/admin/org/client-companies/:linkId/end`, the company with `POST /api/admin/org/contractors/:linkId/end`. Ending a link disables every membership that came through it and sends a `link_state` command. Project memberships are kept.

A free company the contractor created and still manages cannot be unlinked (`409 link_managed_by_contractor_close_instead`). The contractor closes it instead with `POST /api/admin/org/client-companies/:linkId/close`, which disables the company and ends the link. Closing any other link returns `409 link_not_closable`.

`POST /api/admin/org/client-companies/end-all` ends every live link the contractor has, one transaction per link, and is used when a contractor company changes back to standard. A link that cannot be ended, for example because removing a person would leave a project without a lead, is reported in the response and does not stop the others.

A company's own admin takes over a created company with `POST /api/admin/org/contractors/:linkId/take-over`. The company must already have its own billing (`409 company_needs_billing_first` otherwise). The link becomes `company`-managed and records `handedOverAt`; the contractor's people stay until the link is ended.

### Billing commands

| Operation | Sent when | Effect in api-billing |
| - | - | - |
| `cover` | A contractor creates a company, and again before adding people if needed. | Marks the company `covered` by the contractor. |
| `link_state` | A link is created, accepted, ended or closed. | Writes the `LINK#<linkId>` row that the [contractor billing guard](https://help.stralos.app/internal/org-security.md#contractor-billing-guard) reads in every regional API. |

Both are signed commands (`typ: stralos-billing-contractor-command+jwt`, audience `stralos-billing-admin-<env>`, 30-second expiry) sent to the company's home region through the `BILLING_ADMIN_URLS` endpoints.

### Organisation payload

`organisation.created` and `organisation.updated` carry `createdByOrganisationId`: the contractor company that created the company, or `null`. The field is optional on parse, because outbox rows written before migration `0041` do not carry it.

### Routes

All routes are under `/api/admin/org` and use the org self-service authentication (`auth/orgAdminToken.ts` and `auth/orgAdmin.ts`, or `auth/orgUser.ts` for the invitation routes).

| Method | Path | Caller |
| - | - | - |
| `GET` | `/client-companies` | Contractor admin |
| `POST` | `/client-companies` | Contractor admin |
| `GET` | `/client-companies/:linkId/people` | Contractor admin |
| `POST` | `/client-companies/:linkId/people` | Contractor admin |
| `POST` | `/client-companies/:linkId/people/:userId/remove` | Contractor admin |
| `POST` | `/client-companies/:linkId/end` | Contractor admin |
| `POST` | `/client-companies/:linkId/close` | Contractor admin |
| `POST` | `/client-companies/end-all` | Contractor company's own admin (no plan check) |
| `GET` | `/contractors` | Company admin |
| `POST` | `/contractors/invites` | Company admin |
| `POST` | `/contractors/invites/:linkId/cancel` | Company admin |
| `POST` | `/contractors/:linkId/end` | Company admin |
| `POST` | `/contractors/:linkId/take-over` | Company admin |
| `GET` | `/contractor-invites/:token` | Any signed-in user |
| `POST` | `/contractor-invites/:token/accept` | Admin of the invited contractor company |

Operators can read a company's links in both directions with `GET /api/admin/organisations/:id/contractor-links`; the admin console shows them in the billing triage panel.

## Reconciliation

Reconciliation re-arms outbox rows the runtime missed and detects state drift between
the admin store and the runtime replica. It runs as a scheduled pass (Lambda
`{ mode: "reconcile" }` or `pnpm flush -- --reconcile`) and only re-arms rows to
`pending`; the flush schedule delivers them.

M4 adds a conflicted-settle step: a `conflicted` row whose aggregate version is at or
below the replica's applied high-water mark is settled to `processed` rather than
re-armed, preventing it from blocking successors and from stranding the provisioning
saga in `failed` indefinitely.

## User identity provisioning

IDP-M2b adds a Cognito provisioning layer between the admin-store user write and the
invite email. Every user-mutating route enqueues an intent row in `cognito_operations`
on the same database transaction as the `users` row change. The sweeper Lambda
(`{ mode: "cognito" }`) executes those intents out of band — never on the request path.

### User provisioning states

| State | Meaning |
| - | - |
| `pending` | Create committed in the admin store; Cognito account not yet created. |
| `provisioned` | `AdminCreateUser` confirmed applied; invite delivery is now eligible. |
| `failed` | Create exhausted its retry cap (a definitive Cognito refusal). Operator action required. |

The `provisioning` column is set to `provisioned` only after `AdminCreateUser` succeeds.
Invite dispatch gates on `provisioning = 'provisioned'`, so no invite can point at a
login that does not yet exist.

### Cognito operation queue

| Operation | Terminalisable | Notes |
| - | - | - |
| `create` | Yes (after retry cap) | `UsernameExistsException` is success — the account already exists. |
| `resend_invite` | No | Calls `AdminCreateUser` with `MessageAction: RESEND`. Cognito sends the email; the sweeper records `invite_sent_at` in the same transaction. `inviteMessageId` is always null on this path (Cognito returns none); the audit row carries `channel: "cognito-resend"` to distinguish it from an unknown-outcome SES send. |
| `disable` | No | Retries indefinitely; a Cognito refusal requires operator resolution. |
| `enable` | No | Retries indefinitely. |
| `delete` | No | Retries indefinitely. Used as delete-wins compensation when the admin user row is gone. |
| `email_change` | No | Retries indefinitely. |
| `link_provider` | Yes | An identity collision is a definitive fault; operator must resolve. |

Each operation reads the live `users` row at execution time (except `link_provider`,
which has its payload fixed at enqueue). A crash between the Cognito call and the
post-commit write leaves the operation `pending` for the next tick; `UsernameExistsException`
on re-drive is treated as success.

Operations move from `pending` to `applied`. Only `create` and `link_provider` can enter a
terminal `failed` state; `disable`, `enable`, `delete`, and `email_change` retry until they
converge or an operator resolves the underlying problem.

### Cognito pool reconciliation

The daily `cognito-reconcile` pass (`{ mode: "cognito-reconcile" }`) diffs the Cognito
pool against the admin store and repairs discrepancies by enqueueing sweeper operations.
It owns the `cognito_operations` table and the `users.provisioning` column only — it
never touches `users.status`, `memberships.status`, or the admin outbox.

Pool accounts whose Username is not a UUID and is not a linked provider shadow are
reported as foreign accounts and never touched.

## Invite email dispatch

IDP-M2b delivers invite emails through Amazon SES v2 from a dedicated mail subdomain
(`id.<envdomain>`), keeping mail identity ownership separate from the application apex.

### IAM requirements

When invite mail is enabled, three roles need `ses:SendEmail`:

* `signup` sends verification codes during self-service signup;
* `event_flusher` sends the scheduled first invitation;
* the shared `lambda` role serves `POST /api/admin/users/:id/resend-invite`.

All three roles use the same enumerated identity set. `ses:FromAddress` fixes the sender mailbox, while the resource list contains the sender identity and configuration set.

SES sandbox accounts also authorise sends against the recipient identity. The Terraform source therefore includes both domain and address-pattern identity ARNs for `stralos.co.nz` in the `dev` and `test` environment allowlist. `staging` and `prod` receive the sender identity only. This is an environment allowlist in `ses.tf`, not a `.tfvars` setting.

SES production access is granted per account. After an environment leaves the sandbox, remove that environment from `ses_sandbox_recipient_environments`; do not remove another environment until its own account has production access.

The first-send path records its claim before calling SES. If delivery fails after that point, the dispatcher raises an alert and leaves the row claimed, and an operator must use the resend action. Keep the shared send policy attached to all three roles so verification, first-send, and recovery paths remain aligned.

### Dispatch behaviour

The scheduled dispatcher (`{ mode: "invites" }`) runs two passes in sequence on every tick.

**Account invite pass** — sends each eligible user's first invite exactly once (at-most-once guarantee). Eligibility requires:

* `provisioning = 'provisioned'` — the Cognito account exists;
* `status` is not `disabled`; and
* `users.invite_sent_at` is null — not yet attempted.

The claim commits **before** the SES call. A send that fails after the claim leaves
`invite_sent_at` set with a null `invite_message_id` (attempted, outcome unknown). The
dispatcher logs an error and raises an alert; an operator resolves it via the resend endpoint.

**Membership notification pass** — sends a separate notification email to users who are invited into an **additional** organisation. An existing user invited into a further organisation previously received no signal: the Invitations section appears only in the login chooser during a sign-in ceremony, and sessions live 30 days, so an invitation could sit invisible for weeks.

This pass is distinct from the account invite pass. Eligibility requires:

* `memberships.status = 'invited'`;
* `memberships.notified_at` is null — not yet attempted;
* the user is not `disabled`; and
* the organisation is `active` — mail only when acceptance can succeed.

The "additional" condition is enforced by requiring a strictly-older sibling membership row for the same user (any status, including historical). This keeps the two passes disjoint: a brand-new user's first membership is handled by the account invite pass, which already deep-links that invitation in the account email.

The same at-most-once discipline applies: `memberships.notified_at` is set before the SES call and never cleared automatically. A post-claim SES failure logs an error and leaves the row claimed (`notified_at` set, `notified_message_id` null) for an operator to resolve. It is never re-sent automatically. One exception: if eligibility fails after the claim but before sending (for example, the organisation was disabled mid-tick), the claim is released — the outcome is definitively known (nothing was sent), so releasing is safe.

Existing `invited` membership rows without a `notified_at` value become eligible for one notification each on the first post-deploy tick. This is the intended behaviour: they are invitations that nobody has been told about.

The email tells the recipient that a named organisation has invited them and directs them to sign in and accept via the organisation chooser.

### Provisioning and invite fields

**`users` table**

| Field | Notes |
| - | - |
| `provisioning` | `pending` until the sweeper confirms the Cognito account exists; `provisioned` thereafter. |
| `inviteSentAt` | Timestamp of the last invite dispatch attempt. Set before sending and not cleared automatically. |
| `inviteEmail` | Address used for the invite. |
| `inviteMessageId` | SES message ID returned after a successful SES send. Null on the `cognito-resend` path (Cognito returns none) and when a send was attempted but the outcome is unknown. Check the audit row's `channel` field to distinguish the two cases: `"cognito-resend"` means the identity provider sent the email; a null `inviteMessageId` with no matching audit `channel` means the SES outcome is unknown. |
| `inviteCount` | Total invites sent, including operator-triggered resends. |

**`memberships` table** (membership notification pass)

| Field | Notes |
| - | - |
| `notifiedAt` | Timestamp of the membership notification attempt. Set before the SES call and never cleared automatically, except when a post-claim eligibility check fails before any send was attempted. |
| `notifiedMessageId` | SES message ID returned after a successful send. Null when a send was attempted but the outcome is unknown. A row with `notifiedAt` set and `notifiedMessageId` null is a notification whose outcome is unknown, left for an operator to resolve. |

### Operator resend

`POST /api/admin/users/:id/resend-invite` allows an operator to re-send an invite via SES.
The route requires `provisioning = 'provisioned'`; it returns `409` with code
`user_not_provisioned` when the Cognito account has not yet been created. Returns
`503` when invite email is not configured (`mail_hosted_zone_id` not set).

This path runs on the api Lambda, which does not hold Cognito admin grants and cannot mint a
temporary password. The SES email therefore carries no credential and states this plainly. An
operator who needs the recipient to receive a working temporary password should use the
organisation-admin resend path instead.

### Organisation-admin resend

`POST /api/admin/org/users/:userId/resend-invite` is the organisation-admin resend path. It
enqueues a `resend_invite` Cognito operation in the same transaction as the request. The sweeper
executes that operation by calling `AdminCreateUser` with `MessageAction: RESEND`, which mints a
fresh temporary password and lets Cognito send the invitation email. The sweeper records
`invite_sent_at` inside the same transaction. `inviteMessageId` is always null on this path
because Cognito does not return a message ID; the audit row carries `channel: "cognito-resend"` to
distinguish it from an SES send with an unknown outcome.

Previously, the sweeper's `resend_invite` execution also triggered a second SES email from the
admin API, resulting in two invitation emails per resend — the first being Cognito's stock template
naming the internal UUID as the username. The Cognito pool now carries an `InviteMessageTemplate`
(configured in stralos-core) that names the email address and includes the temporary password
token; the admin API no longer sends a second email on this path.

### Sends with an unknown outcome

There is no operator worklist query. `findUnconfirmedInvites` and `findUnconfirmedMembershipNotifications` had no route, CLI or console view calling them, and were removed in stralos-admin-api PR 186. An operator finds these rows by their state instead:

* **Account invites:** `users` rows with `invite_sent_at` set and `invite_message_id` null, `provisioning = 'provisioned'`, a `status` other than `disabled`, and no `signup_intent_id` (self-service signups never get an invite). A Cognito resend also leaves the message id null, so check the audit row's `channel` as described under the `users` table above. Resolve each one with the operator resend endpoint.
* **Membership notifications:** `memberships` rows with `notified_at` set, `notified_message_id` null and `status = 'invited'`. There is no automated retry.

### Invite link structure

Invite links deep-link to the regional shell login URL for the user's organisation.
The `?invite=<membershipId>` parameter is a selector only — it names which invitation
the user clicked, but grants no authority on its own. The exchange re-validates the
membership against the authenticated user before accepting it.

## Federated identity links

For federated sign-in (Google, Microsoft Entra), the pre-signup Lambda reads a DynamoDB
lookup table to match an inbound provider identity to a native user UUID. IDP-M2b
transfers the writer responsibility from elsewhere to the `link_provider` Cognito
operation.

The admin-api writes the lookup row as part of the `link_provider` sweeper operation.
A queued `link_provider` with no `IDENTITY_LINKS_TABLE` configured fails loudly rather
than silently skipping, because a missing row means a federated user cannot sign in at all.

Identity keys are built from immutable provider identifiers only:

| Provider | Key format |
| - | - |
| Google | `google#<sub>` (lower-cased) |
| Microsoft | `microsoft#<tenantId>#<objectId>` (lower-cased) |

A `link_provider` operation is idempotent: if the same user already holds the key, the
write returns `already-linked`. An attempt to claim a key held by a different user is a
definitive fault that alerts an operator and does not retry.

## Invitation generation

The `memberships.invitation_generation` column (a `bigint`, default `1`) is a
stale-acceptance guard. When the runtime reports a membership acceptance, the report
envelope carries the `invitationGeneration` value the runtime projected. The report
channel rejects the acceptance with `stale_invitation` if the value does not match the
current admin store value.

The generation advances only when a revoked membership is re-invited (status transitions
back to `invited`). It does not advance on acceptance, revocation, or role change. The
migration `0007_idp_m2a_report_channel.sql` backfills `invitationGeneration = 1` into
legacy outbox rows so delivery can parse the field.

## Directory backfill

M3b introduces the directory backfill: a one-shot CLI mode that emits a
`directory.regions_replaced` event for every user who does not yet have one, giving
the runtime a directory anchor for each account. Run it before relying on directory
routing for accounts created before M3b.

Run it with:

```sh theme={null}
pnpm flush -- --directory-backfill
# Resume a previous run using its printed runId:
pnpm flush -- --directory-backfill --run-id <uuid>
```

The backfill is resumable. Each invocation processes up to the configured deadline
and prints its `runId`; pass that id on the next invocation to continue from the
cursor rather than restarting. A clean run requires `failed = 0` for the whole
`runId`, the cursor to have reached the end of the users table, and every emitted
replacement to be confirmed at the directory anchor via the runtime replica.

The `DIRECTORY_WRITER_REGION_KEY` environment variable must be set to the region
key of the runtime that holds the directory write path. It must name a region
present in both `RUNTIME_INGEST_URLS` and `RUNTIME_REPLICA_URLS`. There is no
default; omitting it causes the process to fail at startup.

## SSM parameter management

The seven admin SSM parameters are Terraform-managed resources defined in
`terraform/ssm-parameters.tf`. Terraform owns the parameter objects; the deployment
finaliser or an out-of-band operator step writes the application and Google values
after apply. The signup-code pepper keeps its Terraform-generated random value and
is readable only by the signup role.

| Parameter | Default SSM path | Type | Signup role |
| - | - | - | - |
| `database_url` | `/<project>/<env>/admin/database-url` | `SecureString` | Yes |
| `event_signing_key` | `/<project>/<env>/admin/event-signing-key` | `SecureString` | Yes |
| `operator_signing_key` | `/<project>/<env>/admin/operator-signing-key` | `SecureString` | Yes |
| `totp_encryption_key` | `/<project>/<env>/admin/totp-encryption-key` | `SecureString` | Yes |
| `signup_code_pepper` | `/<project>/<env>/admin/signup-code-pepper` | `SecureString` | Yes (signup role only) |
| `google_client_id` | `/<project>/<env>/admin/google-client-id` | `String` | No |
| `google_client_secret` | `/<project>/<env>/admin/google-client-secret` | `SecureString` | No |

The **Signup role** column indicates whether the signup Lambda's IAM grant includes the parameter. The Google credentials are excluded: the signup function has no operator routes and the `ADMIN_GOOGLE_*` environment variables are stripped from it entirely.

The `google_client_id` parameter is a public identifier and remains `type = "String"`.
Keeping the declared type aligned with adopted parameters also prevents Terraform
from rewriting an existing value during adoption.

The database path is fixed by the stack. The signing-key paths may be overridden
with `event_signing_key_ssm_parameter`, `operator_signing_key_ssm_parameter`, and
`totp_encryption_key_ssm_parameter`. The Google credential paths may be overridden
with `google_client_id_ssm_parameter` and `google_client_secret_ssm_parameter`.

The `totp_encryption_key` parameter is retained from the previous email + TOTP scheme. It is still provisioned by Terraform but is no longer used by the sign-in path.

### Cold-start preload

`src/lambda.ts` awaits `preloadApiSsmParameters()` (`src/config/ssm-preload.ts`) before it serves any request. The preload uses the shared `@stralos-co-nz/api-common` parameter cache: batched `GetParameters` calls of up to ten names, with jittered retry on throttling. After the preload, SSM-backed reads answer from memory, and an expired value is still served while the cache refreshes it in the background, so no request waits on Parameter Store.

Two functions run `lambda.ts`, `api` and `signup`, with different environment variables. The preload skips any variable a function is not given. A new `*_SSM_PARAMETER` read on the request path must be added to the preload list; `test/ssmPreload.unit.test.ts` fails until it is.

| Environment variable | Required at cold start | If it cannot be read |
| - | - | - |
| `DATABASE_URL_SSM_PARAMETER` | Yes | The Lambda INIT phase fails |
| `ADMIN_OPERATOR_SIGNING_KEY_SSM_PARAMETER` | Yes | The Lambda INIT phase fails |
| `ADMIN_EVENT_SIGNING_KEY_SSM_PARAMETER` | Yes | The Lambda INIT phase fails |
| `SIGNUP_CODE_PEPPER_SSM_PARAMETER` | Yes (signup function only) | The Lambda INIT phase fails |
| `ADMIN_GOOGLE_CLIENT_ID_SSM_PARAMETER` | No | A warning is logged; Google sign-in answers `503` |
| `ADMIN_GOOGLE_CLIENT_SECRET_SSM_PARAMETER` | No | A warning is logged; Google sign-in answers `503` |
| `INVITE_UNSUBSCRIBE_KEY_SSM_PARAMETER` | No | A warning is logged and the key is read lazily when needed. The signup role cannot read this key |

Both the shared Lambda role (`ReadAdminKeyMaterial`) and the signup role (`ReadAdminSignupMaterial`) grant `ssm:GetParameter` and `ssm:GetParameters` for the batched preload.

### Google sign-in Terraform variables

| Variable | Type | Purpose |
| - | - | - |
| `enable_google_sign_in` | `bool` | Wire Google operator sign-in into the application. The two managed SSM parameters must hold real values before sign-in can work. Defaults to `false`. While `false`, the sign-in routes answer `503`. |
| `google_hosted_domain` | `string` | The Google Workspace domain whose accounts may sign in (the `hd` claim gate, e.g. `stralos.co.nz`). Required when `enable_google_sign_in` is `true`. |
| `google_redirect_uri` | `string` | The exact redirect URI registered on the Google OAuth client. Defaults to `https://<admin domain>/auth/callback`. |
| `google_client_id_ssm_parameter` | `string` | Override the default SSM parameter name for the OAuth client ID. |
| `google_client_secret_ssm_parameter` | `string` | Override the default SSM parameter name for the OAuth client secret. |
| `google_auto_provision_operators` | `bool` | Seed an operator row on first sign-in for any verified account in the Workspace domain. Defaults to `false`. Enabled in dev, test, and prod: the Workspace domain gate (`google_hosted_domain` plus the verifier's `hd` claim check) is the bounding control. Each provisioned row is audited as `operator.provisioned`. Off by default in other environments because a verified Workspace identity proves who someone is; it does not grant them the admin console. |

Enabling Google sign-in requires four steps in order:

1. Create the OAuth client in Google Cloud.
2. Store the client ID and secret in SSM at the expected paths.
3. Set `enable_google_sign_in = true`, `google_hosted_domain`, and (if not using the default) `google_redirect_uri` in the environment's `.tfvars`.
4. Apply Terraform, then sign in.

While `enable_google_sign_in = false`, the sign-in routes answer `503`. With the password path removed, this means the admin console for that environment is unreachable until the flag is enabled.

### New environment provisioning

For a fresh environment, Terraform creates all seven parameter objects with a
cryptographically random `terraform-bootstrap-pending:<random>` placeholder using
a write-only (`value_wo`) block. The placeholder is ephemeral — it is never
persisted to plan state or Terraform state. `value_wo_version = 0` combined with
`ignore_changes = [value_wo_version]` ensures that subsequent applies do not
overwrite the values written by the finaliser.

For `signup_code_pepper`, that random initial value is the retained secret rather
than a placeholder awaiting the deployment finaliser.

The `prevent_destroy = true` lifecycle guard prevents accidental destruction of
parameters that hold live key material.

The four application SecureStrings (`database_url`, `event_signing_key`, `operator_signing_key`, `totp_encryption_key`) are populated by the deployment finaliser after apply. The two Google sign-in credentials (`google_client_id`, `google_client_secret`) have no finaliser; write their real values out of band:

```bash theme={null}
aws ssm put-parameter --name "/<project>/<env>/admin/google-client-id" \
  --value "<client-id>" --type String --overwrite

aws ssm put-parameter \
  --cli-input-json file:///secure/path/google-client-secret.json
```

Supply the secret command from a protected `0600` JSON input file containing the
parameter name, value, `SecureString` type, and `Overwrite: true`; remove that file
through the approved secrets-handling workflow afterwards. Do not put the client
secret directly in shell history or process arguments.

Until the real values are written, Google sign-in cannot complete successfully.

### Adopting pre-existing parameters

Where parameters were provisioned before Terraform ownership, set
`adopt_existing_ssm_parameters = true` in the environment's `.tfvars` file. This
activates `import` blocks for every parameter flagged `adopt = true` in the
managed-parameters map. Adoption brings those parameters under Terraform management
without replacing their values. The `ignore_changes` rule on `value_wo_version`
ensures that the import apply cannot re-seed a parameter that already holds live
key material.

The `adopt` flag is set only on parameters that genuinely pre-exist in a given
environment. Parameters created fresh by this apply do not carry `adopt = true`;
including them in an import block would fail the plan because Terraform cannot
import a resource that does not yet exist. Leave `adopt_existing_ssm_parameters`
at its default `false` when Terraform should create fresh parameters.

## IDP-M2b Terraform variables

The following optional variables enable Cognito provisioning, invite email, the federated
identity-link table, and the runtime→admin report channel. Every variable defaults to
`null` or `false`; an unset value omits the corresponding feature entirely rather than half-configuring it.

| Variable | Type | Purpose |
| - | - | - |
| `cognito_user_pool_id` | `string` | Cognito user pool the provisioning sweeper drives. Null disables the `cognito` and `cognito-reconcile` schedules. |
| `identity_links_table` | `string` | DynamoDB table name for federated identity-link rows. Null causes queued `link_provider` operations to fail loudly. |
| `region_login_urls` | `map(string)` | Region key → shell login URL for invite deep links. Null disables invite delivery. |
| `mail_hosted_zone_id` | `string` | Route53 zone id that holds the invite-mail domain's verification and DKIM records. Null disables invite email and the `invites` schedule: no SES identity, records, or configuration set are created. |
| `invite_mail_domain` | `string` | SES sending domain for invite and signup-verification mail. Null derives `id.<env domain>` from the admin hostname. Prod sets `mail.stralos.app` because the `stralos.app` apex is Cloudflare-hosted and that subzone is the prod-owned mail zone. |
| `adopt_existing_ses_identity` | `bool` | Import an SES domain identity and its Easy DKIM tokens that were created before this stack owned them, instead of failing on "already exists". Re-creating either would invalidate the published records and the identity's verified status. Set only where the identity pre-dates Terraform (prod). |
| `ses_feedback_notifications_enabled` | `bool` | Publish the invite identity's SES `Bounce` and `Complaint` notifications to the platform alerts topic (`admin_alarms_topic_arn` from the `stralos-core` environment-setup state, whose policy lets SES publish). Takes effect only when invite mail is enabled. |
| `report_flusher_role_arn` | `string` | ARN of api-global's report-flusher execution role. Null omits the report-channel API Gateway method entirely. |
| `report_source_map` | `map(object)` | Per-region `{ issuer, jwksUrl }` for report-envelope verification. Null leaves the report channel returning `503`. |

The `cognito` and `invites` schedules are each gated on their respective variables: an
unset `cognito_user_pool_id` means no `cognito` EventBridge rule is created, so there
is no periodic failure from a missing pool ID.

Invite mail sends from `no-reply@<invite mail domain>`. Terraform creates the identity on a dedicated mail subdomain rather than the environment apex, so each environment owns one identity and its DNS records in a single state. It also creates the `<prefix>-invites` configuration set (TLS required, reputation metrics on) and passes its name to the Lambdas as `SES_CONFIGURATION_SET`; every send names it. Prod enables `ses_feedback_notifications_enabled`, because its SES production access relies on bounces and complaints reaching the alerts topic. The account-level SES suppression list (bounces and complaints) is enabled out of band in the account; Terraform does not manage it.

Set `region_login_urls` whenever `mail_hosted_zone_id` is set. Enabling invite mail adds `INVITE_FROM_ADDRESS` to the API and events functions. The API resolves the regional URLs at startup for operator resend, and the events worker resolves them when it dispatches invitations. If neither `REGION_LOGIN_URLS` nor the single-region `INVITE_LOGIN_URL` fallback is present, resend configuration cannot initialise and invite dispatch fails. Keep `region_login_urls` and `mail_hosted_zone_id` together in each environment's configuration.

## Organisation self-service Terraform variables

The following variables enable the organisation self-service surface (`/api/admin/org/*`). Both must be set together. Setting only one half leaves the surface in an unsafe or non-functional state, so they are deployed in two separate Terraform applies in a fixed order.

| Variable | Type | Purpose |
| - | - | - |
| `org_admin_caller_role_arns` | `list(string)` | IAM role ARNs of the regional api-global execution roles allowed to reach `/api/admin/org/*`. Empty (the default) keeps the path on the open unauthenticated proxy method with no resource-policy guard. |
| `user_token_source_map` | `map(object({ issuer = optional(string), jwksUrl = string }))` | Region key → trusted api-global user-token source. Null (the default) leaves organisation self-service returning `503`. Set-but-empty is rejected at plan time because an empty map would 401 every organisation administrator. |

The map key in `user_token_source_map` is compared for exact equality against `organisations.home_region_key`. Spelling it differently from the stored region key (for example `aunz` instead of `au-nz`) causes every organisation to receive `403` with no application log entry that names the mismatch.

`ADMIN_USER_TOKEN_SOURCES` is set on the API Lambda only, not on the shared Lambda environment. Placing it in the shared environment causes an init failure and a `502` on every route across all five Lambda functions, including `/health`, whenever the value is absent or malformed.

The `issuer` attribute is optional. Omit it to use the default `stralos-api-global`. Terraform materialises an unset optional attribute as null, so the Lambda environment encoding explicitly strips the null key before calling `jsonencode`; the consumer's schema accepts an absent issuer but rejects null.

### Phased deployment

These two variables must be applied in two separate Terraform applies:

1. **Phase 1** — set `org_admin_caller_role_arns` only. This creates the `AWS_IAM` method chain on `/api/admin/org/*` and the matching resource-policy `Deny`. The path is now reachable only by the named roles and still returns `503` because the verifier is off.
2. **Phase 2** — set `user_token_source_map`. This enables `ADMIN_USER_TOKEN_SOURCES` on the API Lambda and the org self-service routes start answering.

Applying both variables in the same Terraform run is unsafe. Terraform may update the Lambda environment before the new API Gateway deployment and stage go live. In that window the verifier is on while the path is still served by the root `{proxy+}` catch-all (no IAM requirement), which places the org self-service surface on the open internet with only the user token as a gate. Phase 1 must be confirmed live in each environment before Phase 2 is applied to that environment.

## Release journey probe Terraform variable

`service_token_source_map` enables the `GET /api/admin/service/probe` endpoint used by the PRD 03 promotion pipeline to verify a promoted environment answers an authenticated admin request.

| Variable | Type | Purpose |
| - | - | - |
| `service_token_source_map` | `map(object({ issuer = optional(string), jwksUrl = string }))` | Region key → trusted api-global service-token source. Null (the default) leaves `/api/admin/service/*` returning `503`. Set-but-empty is rejected at plan time. |

The map shape mirrors `user_token_source_map`. The `issuer` attribute is optional; omit it to use the default `stralos-api-global`. Terraform strips null issuer values before encoding to avoid an empty-string init failure.

`ADMIN_SERVICE_TOKEN_SOURCES` is a separate environment variable from `ADMIN_USER_TOKEN_SOURCES`. They are two independent trust decisions. Setting one does not enable the other.

### Service probe endpoint

`GET /api/admin/service/probe?nonce=<hex>` requires a bearer service token with scope `admin:probe-read`. The response body contains only constants and the caller’s own nonce:

| Field | Value |
| - | - |
| `service` | `"stralos-admin-api"` (literal) |
| `env` | Deploy-time environment name |
| `regionKey` | Deploy-time region key |
| `nonce` | The caller’s own nonce, echoed back (1–64 lowercase hex characters) |
| `storeReachable` | `true` when the database is reachable; a store error returns `5xx` instead |

The nonce echo is load-bearing. Without it a `200` response could be satisfied by a cached response or an error page that returned the right shape. The endpoint reads no tenant row, no operator row, and no catalogue item.

The route returns `503` when `ADMIN_SERVICE_TOKEN_SOURCES` is not configured, `401` when no bearer token is presented or verification fails, `403` when the token does not carry `admin:probe-read`, and `503` when the api-global JWKS cannot be fetched (distinguished from a bad token to avoid misleading the promotion gate’s negative control).

## Deployment environments

The `Terraform deploy` workflow plans dev on pull requests. Dev applies run through
the separate manual **Deploy dev** workflow. Test and production apply through a
validated release promotion or through an explicit manual dispatch carrying the
frozen promotion digest.

| Workflow target | CORS origin | `enable_event_sync` | `signup_enabled` | `enable_google_sign_in` | Apply trigger |
| - | - | - | - | - | - |
| `dev` | `https://admin.dev.stralos.app` | `true` | `true` | `true` | Manual **Deploy dev** dispatch |
| `test` | `https://admin.test.stralos.app` | `true` | `true` | `true` | Prerelease promotion or manual dispatch |
| `prod` | `https://admin.stralos.app` | `false` | `true` | `true` | Production release promotion or manual dispatch |

Run the workflow with `target=test` to deploy the test stack. The repository also contains `staging.tfvars` with `https://admin.staging.stralos.app` and event sync disabled, but `staging` is not a selectable workflow target.

For a first production apply, do not create the Google SSM parameters by hand:
Terraform creates them with bootstrap placeholders. Register
`https://admin.stralos.app/auth/callback` on the shared Google OAuth client, then
replace both placeholders with the real client ID and secret as described in
[New environment provisioning](#new-environment-provisioning). Until they contain
real values, Google sign-in cannot complete successfully. The
production configuration auto-provisions a verified `stralos.co.nz` Workspace
account on first sign-in, so no operator bootstrap is required for this path.

When `enable_event_sync = false`, Terraform omits the event flush, reconciliation, provisioning saga, Cognito sweep, and invite EventBridge schedules and triggers. The API, database-migration Lambda, and events Lambda still deploy; the events Lambda remains present but unscheduled.

### Writer cutover step

The deploy workflow includes an optional writer-cutover step that transfers the directory-writer role to the current environment. The step is **opt-in**: it does not run on a standard push to `main` or a normal dispatch. Enable it explicitly when you intend to perform the cutover as part of a deploy.

When the step is enabled and the cutover fails for any reason — including an unreadable or absent marker — the workflow fails closed. The deploy does not continue past a failed cutover. Confirm the marker is readable and the expected writer state is in place before enabling the step.

## Infrastructure protection and backups

The production Terraform configuration associates the admin CloudFront distribution (`admin.stralos.app`) with the shared edge WAF Web ACL. The regional WAF association remains on the API Gateway stage behind it. Edge filtering runs before a request reaches that origin.

The admin RDS instance's automated backup retention is configured to **30 days in production** and **7 days in dev and test**. These database backups cover the admin store and its supporting tables.

## Monitoring

The admin plane carries CloudWatch alarms covering all admin Lambda functions and the API Gateway entrypoint. Alarms fire to an SNS topic (`${name_prefix}-alarms`). The topic requires a subscription to deliver notifications; creating that subscription is an operational follow-up and is not provisioned by Terraform.

### Lambda alarms

Every admin Lambda has two alarms:

* **Errors** — trips when the function throws (crash, timeout, OOM, or init failure). The threshold is 1 for rarely-invoked functions (`migrate`, `operator-ops`, `secret-sync`, and `signup` when enabled) and slightly higher for the traffic-bearing ones (`api`: 3, `events`: 5) to account for self-healing retries without masking sustained failures.
* **Throttles** — trips on the first throttle in a 5-minute window.

The two high-throughput functions (`api` and `events`) also carry an **error-rate** alarm. It evaluates `100 * errors / invocations` only when there are at least 10 invocations in the period, which prevents a low-denominator false alarm from a quiet window. The threshold is 20%. This alarm catches a proportional burst (such as a credential-desync outage) even when the absolute Errors threshold has not been reached.

A Terraform `check` block compares the alarm map with the Lambda functions already live in AWS and reports a non-blocking plan warning for any gap. A function introduced by the same apply is visible only on a later plan, so this is drift detection rather than a deployment gate.

### Secret-sync dead-man's switch

The `secret-sync` function carries an additional **sweep-stalled** alarm. It monitors `AWS/Events SuccessfulInvocationAttempts` on the sweep EventBridge rule (not the Lambda invocation count), so it fires for both a dead schedule and a schedule that fires but fails to deliver. The alarm uses a 1-hour period, `evaluation_periods = 3`, and `treat_missing_data = breaching`. If the default hourly schedule is changed, the period must be updated to match.

### API Gateway 5xx alarm

The `api` Lambda handles failures internally and returns HTTP 5xx responses rather than throwing. Those responses count as successful Lambda invocations, so the Lambda Errors alarm stays at zero during an application or dependency outage. The API Gateway `5XXError` metric captures them instead.

The `${name_prefix}-apigw-5xx` alarm trips on the first 5xx in a 5-minute window (`threshold = 1`, `treat_missing_data = notBreaching`). It complements the Lambda Errors alarm: together they cover both function-level failures and application-level HTTP failures for the API entrypoint.

### Safe error handler

Route handlers map known errors to typed responses. Any error that `handleRouteError` does not map is rethrown; previously Fastify's default handler answered with `error.message`, which let verbatim Postgres text reach the response body.

The safe error handler (`registerSafeErrorHandler` from `api-common`) intercepts every rethrown error and answers with fixed copy plus a correlation reference:

* A database-saturation error (Postgres code `53300`) returns `503` with `code: "service_unavailable"` and `Retry-After: 5`. The driver text never appears in the response; the full error is written to the log under the same reference.
* Any other unhandled error returns `500` with `code: "internal_error"` and a reference. The describer logs only `name`, `code`, `message`, and `stack` — not any additional properties the error carries. This prevents token-claim errors from leaking the decoded JWT payload into structured logs.

The reference in both responses is the same value the caller receives, so an operator can locate the log entry without the error text being visible to the caller.

### API Lambda Postgres pool

The API Lambda's Postgres pool is capped to prevent connection exhaustion on the shared admin database.

| Setting | Value | Reason |
| - | - | - |
| `max` | `2` | One connection for the request transaction, one spare so a helper that opens a connection while that transaction is open cannot deadlock. A Lambda container serves one request at a time, so a larger pool provides no benefit. |
| `connectionTimeoutMillis` | `5 000 ms` | A saturated database fails the request quickly. The safe error handler maps the resulting timeout to a retryable `503`, rather than the request hanging until API Gateway's 29-second limit. |

Local development, the CLI tools, and the one-shot Lambda functions (migration, operator-ops) keep the `pg` defaults (`max: 10`, no connection timeout).

## Related page

See [Admin console frontend](https://help.stralos.app/internal/admin-console-frontend.md) for the operator SPA.

## Lambda activation

The `api` and `retention` functions expose named `live` aliases. API Gateway targets the API alias, and the weekly retention schedule targets the retention alias. The dedicated signup integration targets its separate function.

Terraform lists the aliased functions in `release_functions` and ignores alias `function_version` changes. In a full deployment, the core post-apply step moves the listed aliases to the versions Terraform published. The API function ignores `source_code_hash`, so that initial version can hold the previous code: its finaliser subsequently promotes the migrated API artifact to the alias. Retention code is shipped by Terraform, so its post-apply alias move activates that version. Do not assume the post-apply API alias move alone activates the new API artifact.


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