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

# Service boundaries

> Data ownership across Stralos service APIs

Each API service owns one product domain and references other domains by ID.

## Ownership matrix

| Service | Owns | References |
| - | - | - |
| Global | Auth, organisations, users, parties (companies register — clients, suppliers, and subcontractors), projects, contractors, rate categories, setting list items, app config, project contract profiles | Documents service (S2S status lookup on contract profile save); Estimate service (S2S lifecycle orchestration on coupled project transitions) |
| Rates | Rate items, rate options, summaries, project rate overrides, pricing rules, day-rate schedules, and import jobs | Project IDs, supplier and location IDs, supplier IDs in project rules, category IDs/settings |
| Activities | Activities and activity components | Rate item IDs |
| Estimate | Estimates, sections, takeoff, work items, components, subcontractor packages, bid rates, programme, TAGs, import jobs | Project, contractor, rate item, rate option, activity, subcontract bid-rate IDs; supplier context is indirect through Rates; `PartyChanged` and `LocationChanged` events and Global's organisation project-id read (name sync) |
| Documents | Upload records, scan verdicts, extracted text, summaries, area runs, suggestions, outbox/events | Project IDs, estimate IDs, rate/work-item/package context fetched from other services |
| Scheduler | Persons, licences, endorsements, competencies, plant, crews, crew members, assignments, unavailability, project requirements, project resources (rosters) | Project IDs (bare UUID, no cross-DB FK; validated via the project-status client for ownership checks, the assignment confirm gate, and overallocation filtering — S2S outbound only, no inbound S2S surface); organisation IDs |

## Boundary rules

* Keep shared source records in Global.
* Keep project-specific estimating data in Estimate.
* Keep reusable pricing in Rates.
* Keep reusable construction build-ups in Activities.
* Keep file bytes and AI processing state out of Estimate; Documents owns that lifecycle.
* Keep the people/plant/crew registry and scheduling data in Scheduler; reference projects by bare UUID.
* Snapshot external values only when a historical estimate needs stable evidence.
* Keep resource scheduling (people, crews, plant, assignments) in Scheduler; reference project IDs by value only.

## Service-to-service authentication

Internal callers obtain short-lived EdDSA service tokens from `stralos-api-global`. Deployed callers invoke a caller-specific `mint-for-<caller>` Lambda alias. Local callers use `POST /api/global/internal/service-tokens` with their own mint secret. The local route is not registered by the deployed API Lambda.

The static allowlist bounds each caller's target-service audiences, scopes, and tenant or platform organisation mode. The mint prevents a caller from exceeding that allowance. Receiving services validate their audience and expose the token scopes to route handlers; the token does not grant access to every route automatically.

Tenant callers carry an organisation UUID. Platform callers carry a null organisation and the `platform` scope. Only handlers that explicitly require platform authority use a BYPASSRLS platform connection. See [Service token authentication](https://help.stralos.app/internal/service-token-auth.md).

## Cross-service reads

### Contract corpus validation (Global → Documents)

When a project contract profile is saved via `PUT /api/global/projects/:id/contract-profile` with a non-empty `contractDocumentIds` list, Global calls `GET /api/documents/documents/status?ids=…` on the Documents service. The call forwards the caller's `Authorization` header. Global rejects the save with `422` if any document ID is missing or belongs to another project, or if a `custom` form has no `processed` document. Global returns `502` if Documents is unreachable or returns a non-`2xx` response. Global never stores unvalidated IDs.

### Rate reference resolution (Rates → Global)

When the Rates API needs the current company and location records referenced by rate options, it calls `POST /api/global/rate-references/resolve-batch`. The caller's `Authorization` header is forwarded so Global applies the same organisation scope.

The request carries independent `supplierIds` and `locationIds` arrays. Global returns `{ suppliers, locations }`; missing, foreign, or deleted IDs are omitted, while inactive rows are retained for historic display. Each supplier entry includes a `version` integer (from `parties.version`, migration 0085) and each location entry includes a `version` integer (from `locations.version`). These counters match the `partyVersion` and `locationVersion` fields on `PartyChanged` and `LocationChanged` events, so a consumer can compare them to determine whether a rename event is newer than the value it already holds. Rates compares the response with the requested IDs and verifies a location's ownership from its `partyId`: organisation-scoped prices require `partyId: null`, while supplier prices require the location to belong to that supplier. Peer failures and incomplete or contradictory ownership data fail closed rather than publishing an invalid rate context.

An unreachable Global API or an unexpected non-`2xx` response fails closed. The available-pairs read path reports `502`. Validation paths treat both an actual `404` response and an otherwise successful `items` array that omits a requested pair as an unavailable-pair `422`.

### Supplier ownership validation (Rates → Global)

When a supplier-scoped project rate rule supplies a supplier ID, Rates calls `GET /api/global/parties/:id` before saving it. The caller's `Authorization` header is forwarded so Global applies the same organisation scope. An unknown or foreign-organisation supplier returns `404`; an unreachable Global API or another unsuccessful response fails closed with `502`.

The endpoint moved from `/api/global/suppliers/:id` to `/api/global/parties/:id` in the companies-register M2 wave. Stored supplier IDs are unchanged — a party keeps the originating supplier row's UUID — so all existing `supplier_id` references continue to resolve.

The legacy `GET /api/global/suppliers/:id` route has been removed. Any caller still pointing at it receives a `404` immediately.

Rates resolves the Global API base URL from `STRALOS_GLOBAL_URL`, then the SSM
parameter named by `STRALOS_GLOBAL_URL_SSM_PARAMETER`. There is no localhost
fallback: with neither set, Rates refuses with a configuration error (see
[Deployment — Peer API URL resolution](https://help.stralos.app/internal/deployment.md#peer-api-url-resolution)).

### Organisation project IDs (Estimate → Global)

`stralos-api-estimate`'s `estimate-name-sync` caller (the `PartyChanged`/`LocationChanged` consumer and the daily name reconcile) fetches all project IDs for its organisation using `GET /api/global/service/organisations/:orgId/project-ids`. This read is needed because a party or location rename is org-wide and does not name any specific project; the estimate service's runtime role can only see rows whose project is in its own `app.project_ids` GUC. Without the full list, a guarded `UPDATE` would match zero rows in any deployed environment.

Only a service token with the `global:name-sync-read` scope (caller `estimate-name-sync`) may call this endpoint. The token's organisation must match the `:orgId` path parameter; a mismatch returns `403 organisation_mismatch`. The token is also confined to this endpoint and `POST /api/global/rate-references/resolve-batch` and is refused with `403 name_sync_read_only` on all other routes.

The response is `{ organisationId, projectIds: string[] }`. Projects are never hard-deleted (archive is a status), so every project the organisation has ever created is included.

### Pricing materialise backfill (Estimate scripts → Rates, Global)

The two pricing materialisation backfill scripts in `stralos-api-estimate` read the estimate database directly but make cross-service HTTP requests to other services:

* `materialise-pricing-inputs.mjs` — pages `GET /api/rates/rate-items/:id/options` on the Rates service and materialises a selected or lowest-effective-cost option into each eligible component.
* `materialise-pricing-rows.mjs` — reads app-config from the Global service.

The scripts mint a `service:pricing-materialise` token through the local mint
endpoint. Its audiences and scopes are clamped to the caller's allowlist. The
helper warns and attempts an unauthenticated request if minting fails, but the
current Rates and Global services reject that request. Successful minting is
therefore required for a useful run. See the
[estimate totals recompute runbook](https://help.stralos.app/internal/runbooks.md#estimate-totals-recompute)
for configuration.

## Estate events

### ProjectCreated (Global → estate EventBridge bus)

When `POST /projects` commits a new project, `stralos-api-global` enqueues a `ProjectCreated` v1 event inside the create transaction and publishes it to the estate EventBridge bus (`stralos-<env>-au-nz-events`) immediately after the commit. The event carries the new project's ID, name, organisation ID, creation timestamp, the organisation's working-day length (`hoursPerDay`), and a frozen snapshot of the organisation's margin ladder as of that commit (pricing basis, default markup percentage, and per-rate-group and per-category margins).

The margin ladder snapshot uses the same normalisation as `stralos-api-estimate` applies when it reads `GET /api/global/organisation-settings` and `GET /api/global/rate-group-settings` at estimate-create time. Values outside the valid band for the configured pricing basis are dropped, not clamped. The first 1,000 active rate categories are included; organisations with more than 1,000 categories emit a warning but the event still publishes with the capped set.

Publishing is best-effort and never turns a successful project-create into an error response. If the inline after-commit publish fails, the event stays in the `event_outbox` table and a `rate(1 minute)` Lambda flusher attempts to publish it each minute. The `event_outbox_backlog` alarm fires if any row is still unsent after 15 minutes (see [Infrastructure](https://help.stralos.app/internal/infrastructure.md)).

Consumers of `ProjectCreated` receive enough context to initialise project-level state without calling back to Global: the working-day length and margin ladder are frozen at the moment of creation and are authoritative for that project's initial estimate.

### ProjectStatusChanged (Global → estate EventBridge bus)

`stralos-api-global` enqueues a `ProjectStatusChanged` v1 event inside the transaction that commits a project lifecycle transition, and publishes it after the commit. The two places that write a project's status both enqueue it:

* `runSingleTxTransition` (`src/routes/projectTransitionEngine.ts`) for standalone transitions and inbound S2S actions.
* `commitOrchestratedTx2` (`src/lifecycle/orchestration.ts`) for orchestrated actions, reached from the route and from the pending-transition sweeper.

The event carries `projectId`, `action`, `fromStatus`, `toStatus`, `statusVersion`, and `changedAt`; the envelope adds `orgId` and optional `actorId` and `correlationId`. `statusVersion` is the count of the project's `committed` rows in `project_status_history`, read in the same transaction. Status writers hold the per-project advisory lock, so no two transitions share a version. Consumers compare versions and must not expect each to be one higher than the last: an `admin_set_status` onto the project's current status commits a history row but publishes nothing, which leaves a gap.

Replayed correlation IDs, stale CAS outcomes, converged "already finalised" rows, and aborts commit no transition and publish nothing. The crew scheduler consumes this event into a local status projection.

### RateCategoryChanged (Global → estate EventBridge bus)

`stralos-api-global` enqueues a `RateCategoryChanged` v1 event inside every transaction that creates, updates, or soft-deletes a rate category, and publishes it after the commit. It is a full-state event: it carries `categoryId`, `name`, `parentGroup`, `isCartageVehicle`, `allowedUnitCodes`, `marginPercent`, `status` (`deleted` for a soft delete), `categoryVersion`, and `updatedAt`, with the organisation in the envelope's `orgId`.

`categoryVersion` is `rate_categories.version` (migration 0071), which every write increments. `stralos-api-rates` applies an event only when its version is newer than the one it holds, and keeps its category projection from these events. It reads the catalogue from api-global only once per organisation to seed the projection, or when a write names a category the projection does not yet hold.

### PartyChanged and LocationChanged (Global → estate EventBridge bus)

`stralos-api-global` enqueues a `PartyChanged` v1 event inside every transaction that creates, updates, renames, or retires a party (company), and publishes it after the commit. A `LocationChanged` v1 event is enqueued and published in the same way for every transaction that creates, updates, renames, or retires a location. Both are full-state events.

`PartyChanged` carries: `partyId`, `name`, `status` (`active` or `inactive`), `partyVersion`, and `updatedAt`, with the organisation in the envelope's `orgId`.

`LocationChanged` carries: `locationId`, `partyId` (the owning company, or `null` for an organisation-scoped location), `name`, `status`, `locationVersion`, and `updatedAt`, with `orgId` in the envelope.

`partyVersion` and `locationVersion` are the respective row's `version` counter (migration 0085), which every write increments. A consumer applies a rename only when the event's version is higher than the version it already holds. Both values come from the same `.returning()` row as the name, so they always agree.

Seven write paths enqueue these events: `POST`, `PUT`, and `DELETE /parties`; `POST /parties/:id/locations`; and `POST`, `PUT`, and `DELETE /locations`. A company retire publishes `PartyChanged` only. Its locations are retired by association and receive no separate `LocationChanged` event; a consumer that sees the `PartyChanged` knows those locations are also inactive.

An `orgPurge` hard-delete is the one exemption: it removes the rows without publishing events, because the organisation is already disabled.

`stralos-api-estimate` consumes these events to keep supplier and location names current on editable estimates (see [Estimate service](https://help.stralos.app/internal/estimate-service.md)). It applies a rename only when the event's version exceeds the one stored on the line, then calls `GET /service/organisations/:orgId/project-ids` (see **Organisation project IDs** above) to scope its runtime role to all of the organisation's projects before the guarded update.

### DocumentUsagesChanged (Estimate → estate EventBridge bus → Documents)

`stralos-api-estimate` publishes `DocumentUsagesChanged` v1 with the full set of documents one estimate uses. `stralos-api-documents` applies it to its usage registry only when `usageVersion` is higher than the version it holds, then replaces that module's rows for the entity, so a document missing from `usages` is no longer registered. There is no shared event type: `@stralos-co-nz/domain` dropped `DocumentUsagesChangedV1` and `DocumentUsagesChangedV1Detail` in 4.0.0, so the publisher declares its own (`src/events/documentUsages.ts`) and `stralos-api-documents` validates with `usageSetSchema` (`src/usage/registry.ts`).

| Field | Notes |
| - | - |
| `module`, `entityType` | Both `estimate` for the estimate publisher. |
| `entityId` | The estimate id. |
| `label` | The entity's display label, or `null`. |
| `projectId` | Required and never `null`. The consumer scopes its RLS write to this project and rejects an event without a valid UUID. |
| `frozen` | Whether the entity's document set is frozen. |
| `usageVersion` | Monotonic per entity. |
| `usages` | Array of `{ documentId, purpose, count }`, at most 5,000 entries. |

The envelope adds `version: 1`, `eventId`, `occurredAt`, `orgId`, and optional `actorId` and `correlationId`. Modules with no bus publisher use `PUT /api/documents/usages/:module/:entityType/:entityId` instead, where `label` and `projectId` are optional and nullable; a set without a project is applied under the organisation scope alone. For the publisher's purposes and write sites, see [Estimate service](https://help.stralos.app/internal/estimate-service.md#documentusageschanged-event-publisher).

## Cross-service writes

### Project lifecycle orchestration (Global → Estimate)

Orchestrated project transitions (`mark_won`, `mark_lost`, `reopen`, `archive`, `unarchive`, and `admin_set_status`) drive a matching S2S peer call from Global to Estimate under the same `correlationId`. The protocol is intent-first (tx1 → peer call → tx2):

1. **Tx1 (Global)** — validates the project's current status, inserts a `pending` row in `project_status_history`, and records the actor and organisation. The partial unique index on `(project_id) WHERE state = 'pending'` blocks any concurrent transition with `409 transition_in_flight`.
2. **Peer call (Global → Estimate)** — `POST /api/estimate/projects/:projectId/estimate/transition` carrying the acting user's JWT as `x-forwarded-user` and a short-lived service token (`lifecycle:transition` scope, `estimate` audience) minted in-process by Global. No `expectedStatus` is sent; the estimate service enforces its own from-state.
3. **Tx2 (Global)** — takes the advisory lock, re-reads the history row, flips it to `committed`, and applies the guarded project CAS (history flip first, CAS second, one transaction). A CAS failure rolls the flip back and leaves the row `pending`.

Peer failure handling:

* **2xx (ok)** — tx2 commits and returns the updated project.
* **404 `estimate_not_found` (none)** — no estimate exists; `mark_lost`, `archive`, `unarchive`, and non-award `admin_set_status` skip the peer and commit with `peer: 'none'`. `mark_won` and an `admin_set_status` targeting `awarded` require a tendered baseline and abort with `409 baseline_required`. `reopen` aborts with `404 estimate_not_found`.
* **Other 4xx (rejected)** — aborts the pending row and returns the peer's status and body verbatim.
* **5xx / timeout / transport (ambiguous)** — leaves the row `pending`; returns `502 transition_pending` to the caller. The background sweeper re-drives the row.

The sweeper (EventBridge `rate(10 minutes)` → sweep Lambda) picks up rows older than 60 seconds, replays the peer call with `x-replay-of: <correlationId>` instead of a forwarded user JWT, and runs the same tx2/abort helpers. A claim stamp on the row prevents concurrent sweeper runs from replaying the same row twice. Rows that cannot be repaired (missing recorded data) are left pending with a diagnostic and counted in the sweep error report.

S2S-only inbound actions (`submit_tender`, `revise_tender`) travel in the opposite direction: the estimate-side orchestrator drives them to Global using a `lifecycle:transition`-scoped service token with the acting user forwarded via `x-forwarded-user`.

### Suggestion acceptance (Estimate → Documents)

1. The estimate service calls `GET /api/documents/suggestions/:id` to fetch the full suggestion payload and verify `projectId` matches the target estimate's project.
2. The estimate service creates the target record using its own API.
3. The frontend (or estimate service) marks the suggestion accepted in Documents with the created record ID via `POST /api/documents/suggestions/:id/accept`.

This ensures the estimate service — not the client — is the authority over what payload is applied, and that cross-service ownership is verified before any record is created.


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