Skip to main content
Each API service owns one product domain and references other domains by ID.

Ownership matrix

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.

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

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

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.