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

# Rates service

> Internals of stralos-api-rates, including backend contract tests, the category projection and retention

`stralos-api-rates` owns the rate catalogue, pricing rules, day-rate schedules, project rate context, and import jobs. This page covers the receiving-side backend contract test suite introduced to pin the wire format that backend callers depend on.

For the import-specific internal contract, see [Rate import service](https://help.stralos.app/internal/rates-import-service.md). For the full public API reference, see [Rates API](https://help.stralos.app/api/rates.md).

## Safe error handler

All unhandled errors from route handlers in `stralos-api-rates` are intercepted before they reach the HTTP response. Fastify's default behaviour — echoing `error.message` verbatim — is replaced by fixed copy with a support reference.

Two response shapes apply:

| Condition | Status | Body |
| - | - | - |
| Database connection pool saturated (Postgres error code `53300`) | `503` + `Retry-After: 5` | `{ "code": "service_unavailable", "reference": "<x-request-id>" }` |
| Any other unclassified error | `500` | `{ "code": "internal_error", "reference": "<x-request-id>" }` |

The `reference` value equals the `x-request-id` response header. Provide it when raising a support incident so the originating error can be correlated in CloudWatch.

The `Retry-After: 5` on a saturated database signals that the caller should wait five seconds before retrying. A Lambda container creates three connection pools of at most three connections each (nine total), with a five-second connection timeout. A container that cannot acquire a connection within that window answers `503` rather than blocking until API Gateway's 29-second limit.

These responses are distinct from the `ErrorEnvelope` (`{ message, issues?, code? }`) used by application-level validation and business-rule errors.

## Response compression

The REST API Gateway sets `minimum_compression_size` to 1024 bytes in `terraform/main.tf`. When the caller sends `Accept-Encoding` with `gzip` or `deflate`, responses of 1 KB and over are compressed; smaller responses are not. CloudFront's `/api/*` behaviour forwards `Accept-Encoding` and passes `Content-Encoding` back, so browser callers receive the compressed body. Binary responses such as XLSX exports are compressed too and decode to the same bytes. API Gateway leaves alone any response that already carries `Content-Encoding`, so the Lambda does not compress its own output.

Changing the setting needs a redeploy, so the value is part of the deployment's `redeployment` trigger hash. `terraform/CONTRACT_OK` carries a standing entry for `aws_api_gateway_deployment.api`, which is replaced whenever that hash changes.

## Library history and transactional restoration

`stralos-api-rates` records every write to rate items and rate options as an audit event. Two new endpoints expose library-wide history and allow previously saved states to be restored atomically.

### `GET /history`

Returns all recorded changes across every library rate item and rate option for the organisation, newest first. The list includes events for soft-deleted items. Project-only rate items are excluded — they have their own write surface and are never mixed into catalogue history.

**Query parameters:** standard `start` and `length` pagination; optional `search` (matches item name); optional `actor` (see [Actor filter](#actor-filter)).

**Response shape:** `{ items: RateAuditEvent[], totalCount, start, length }`. Each event carries:

| Field | Description |
| - | - |
| `id` | UUID of the audit event. |
| `entityType` | `"rate_item"` or `"rate_option"`. |
| `entityId` | The id of the changed item or option. |
| `rateItemId` | The parent rate item. |
| `action` | `"created"`, `"updated"`, or `"deleted"`. |
| `changedBy` | User id of the actor, or `null` for system writes. |
| `changedAt` | ISO 8601 timestamp. |
| `changes` | `{ field: { old, new } }` — only the fields that changed on that write. |
| `actorOrgId` | The contractor company the change came through, or `null` for the company's own people and for events recorded before contractor companies (migration 0062). |
| `actorOrgName` | The contractor company's name, stored at write time so it still reads after the link ends. |
| `source` | `"manual"`, `"copy"` (the first copy from a contractor company's library) or `"refresh"` (a later refresh from it). Defaults to `"manual"`. |
| `itemName` | Denormalised name of the parent rate item for display. |
| `restoreWarning` | Present on older deleted-option events that predate pricing-rule retention. Explains that only the recorded fields can be restored. |
| `retiredOptionCount` | Number of rate options that were retired with an item deletion, when applicable. |
| `optionContext` | Condition, scope, supplierId, and locationId snapshot for option events. |

### `GET /rate-items/:id/history`

Returns all recorded changes for a single rate item and all of its rate options, newest first. A project-only item is a 404 unless `?projectId` names its project.

**Query parameters:** standard `start` and `length` pagination; optional `projectId`; optional `actor`.

**Response shape:** same `{ items, totalCount, start, length }` envelope as the library-wide endpoint.

### Actor filter

`historyActorFilter` in `src/lib/rate-audit.ts` backs the rate history's **Changed by** filter on both history endpoints:

| `actor` | Events returned |
| - | - |
| Absent or empty | Everyone's changes. |
| `own` | Changes by the company's own people (`actorOrgId` is null). |
| A contractor company id | That contractor company's changes. |

Any other value returns `400`. The actor on a normal write comes from the token's `via` claim (`auditActor`), so nothing is looked up at read time.

### `POST /history/:id/restore`

Restores a deleted rate item or rate option, or rolls back the previous values of an update event, atomically. The restoration is transactional: if any part of it fails (for example, because a slot is already occupied by a live rate), the entire operation is rolled back and the rate library is unchanged.

**Permissions:**

* Every restore requires a signed-in member or admin (`requireOrgMember`).
* Bringing back a deleted rate item also requires an admin, because only admins delete items.
* `"created"` events cannot be restored (there is no previous value to revert to) — returns `422`.

**Restoration behaviour by event type:**

| Scenario | What happens |
| - | - |
| Deleted rate item | The item is set back to its previous status. Options retired with the item are reactivated, Standard first so multiplier options regain their anchor. Previously inactive options are not revived. |
| Deleted rate option | The option is recreated with its recorded field values. Saved pricing rules are restored if the event retained them. Legacy deletions that predate pricing-rule capture note this in `restoreWarning`. |
| Updated rate item or option | Only the fields that appear in `changes` are reverted; any other fields that changed after the recorded event are preserved. |

**Conflict responses:**

* `409` — the item or rate is already active (was restored elsewhere), or a live rate already occupies the slot the deleted option would restore into.
* `422` — the event is a creation, the event has no restorable values, a deleted item has a retired supplier rate that is no longer valid, or the `status` field would be set to `"deleted"` by the restore.

**Audit trail:** A successful restore records a new audit event on the item. The original history is never overwritten.

### Tracked fields

The audit log captures only the fields in the tracked field lists, not every column:

**Rate items:** `name`, `categoryId`, `category`, `rateGroup`, `status`, `bulkingPercent`, `compactionPercent`, `maxTonnes`, `maxCubicMetres`.

**Rate options:** `scope`, `supplierId`, `locationId`, `sourceItemCode`, `sourceItemName`, `notes`, `currency`, `condition`, `basis`, `basisMultiplier`, `unit`, `conversionFactor`, `costRate`, `provenance`, `validFrom`, `validTo`, `status`, `needsReview`.

Options created before migration 0045 may carry `rateSourceId` and `rateSourceLocationId` keys in their `changes` object. These reference tables from the previous global service that no longer exist. The API serves those keys unchanged; restoration refuses to recreate a deleted option that carries only these legacy keys (no `scope` value) because the supplier identity cannot be resolved.

## Contractor copy

A contractor company can copy its Rates Library into a company it works in, and later refresh those rows one way, from the contractor company to the company. api-global's orchestrator drives the job; `src/lib/contractor-copy.ts` implements the api-rates side.

```text theme={null}
POST /api/rates/contractor-copy/export
POST /api/rates/contractor-copy/import
```

Only the `contractor-copy` service login may call them: scope `contractor-copy:export` with the contractor company as the token's organisation, or `contractor-copy:import` with the receiving company. An export token can never write and an import token can never read. Any other route answers that login with `403 contractor_copy_only`. Each call runs under one organisation's RLS; rows travel through the orchestrator.

What travels: Rates Library items (active and inactive, nothing deleted), their current options (active, open `validTo`) and the pricing rules those options own. Retired options, project rates, day-rate schedules and history never travel.

### Provenance (migration 0062)

Copied items, options and pricing rules carry `copiedFromOrgId`, `copiedFromId` and `copiedAt`. Rows the company created itself have none. An item is found again by provenance, and an option by its slot (item, scope, supplier, location, condition), because a price change at home usually supersedes the option with a new id. A retried copy therefore never inserts twice, and a refresh updates in place.

### Export

The body takes optional `supplierIds` (keep only those suppliers' prices), `rateCategoryIds` (keep only items in those categories), `locationIds` (keep only the contractor company's own prices at those locations, plus its all-locations prices), `cursor` and `limit` (default 500, maximum 1,000 items). The response is `{ items, options, pricingRules, nextCursor }`, paged by item id. Each page carries every option and rule for its items.

### Import

The body carries `jobId`, `step` (`rates.items_options` or `rates.pricing_rules`), `mode` (`copy` or `refresh`), optional `dryRun`, `sourceOrgId`, optional `sourceOrgName` and `requestedByUserId`, `idMaps` (`rateCategory`, `party`, `location`, `rateItem`, `rateOption`), optional `locationMap` (copy only: which home location's prices each copied location takes) and `rows`. A `sourceOrgId` equal to the receiving company returns `422 contractor_copy_same_company`.

* An item is matched by provenance, then adopted by name (the company's own item is kept and the contractor's prices attach to it), then created.
* Options land by slot, with suppliers and locations remapped through `idMaps`. A slot the company priced itself is never overwritten.
* `mode: refresh` updates copied rows whose tracked fields differ. `dryRun: true` runs the same code in a transaction that is rolled back, so the preview matches what applying it would do.

The response is `{ idMap, counts, skipped, diff, dryRun }`. `idMap` holds `rateItem`, `rateOption` and `pricingRule`. `counts` reports `created`, `matched`, `updated`, `unchanged` and `skipped`. `diff` lists `changed` and `new` rows and is always filled.

| Skip reason | Cause |
| - | - |
| `category_not_copied` | The item's category is not in `idMaps.rateCategory`. |
| `item_not_copied` | The option's item was not mapped. |
| `supplier_not_copied` | The option's supplier was not mapped. |
| `not_chosen_location` | An own price at a location no copied location takes rates from. |
| `slot_taken` | A live option the company priced itself holds the slot. |
| `owner_not_copied` | A pricing rule's owner option was not copied. |

A must-pick option (priced by steps or by a rate table) has no cost, and its rules travel with it on copy. When a refresh finds that the home option has stopped being must-pick and has a cost again, it deletes the copied option's `stepped` or `matrix` base rule in the same transaction, because a costed option that kept one would fail `rates_rate_options_stepped_cost_check` at commit. A refresh that brings steps over an option that had a minimum or a loading clears those rules first, because a stepped base cannot carry them.

Every write records rate history with `source` `copy` or `refresh`, the contractor company as `actorOrgId` and `actorOrgName`, and `requestedByUserId` as `changedBy`.

## Backend contract tests

Backend callers — `api-assistant`, `api-estimate`, and `api-global` — read rate items by ID and by search filter. The contract test suite pins the exact request validation and response JSON those callers rely on.

Tests live in `test/requests.backendContract.unit.test.ts` and use the `*.backendContract.unit.test.ts` suffix. Run them with:

```bash theme={null}
pnpm test:contract
```

CI runs the named **Backend API contracts (receiving requests and responses)** step. A failure fails the repository's CI job. The tests also match the normal unit-test discovery pattern so they run with the standard test suite.

The dedicated configuration runs one worker and no database setup. It does not skip when a database is unavailable. Locally:

```bash theme={null}
NODE_OPTIONS=--max-old-space-size=768 nice -n 19 pnpm test:contract
```

Run one repository at a time.

## Test boundaries

Route tests register production handlers and execute actual validation, error mapping, and JSON serialisation. Only the persistence and external service ports are simulated. Fixtures are not prebuilt HTTP responses.

Request-schema tests pin validation rules only and do not claim to exercise successful writes.

SQL filtering, RLS, JWT signature verification, and distributed workflows remain the responsibility of the existing integration and security suites. The suite does not validate a sibling repository's current client implementation or automatically trigger its CI.

## Covered contracts

| Endpoint | Pinned assertions |
| - | - |
| `GET /api/rates/rate-items/:id` | UUID path validation; item JSON shape; `assumptions` array; numeric and null physics fields; ISO timestamps; `organisationId` omitted from the response; `400` for a non-UUID path; `404` with `{ message: "Rate item not found: <id>" }` |
| `GET /api/rates/rate-items` | `search`, `categoryId`, `includeDeleted`, `start`, `length` query parameters; `{ items, totalCount, start, length }` envelope shape; `400` for a malformed `categoryId` |
| `POST /api/rates/rate-items` | Request schema only: `name` and `categoryId` required; `name` trimmed; `status` defaults to `active`; invalid types, missing required fields, blank name, wrong `status` enum, and non-numeric `maxTonnes` all rejected |

## Rate item response shape

The `GET /api/rates/rate-items/:id` response omits `organisationId`. This is a storage-only field that backend callers must not read from the wire. The pinned response shape is:

```json theme={null}
{
  "id": "<uuid>",
  "name": "<string>",
  "categoryId": "<uuid>",
  "category": "<string>",
  "rateGroup": "<string>",
  "status": "active | inactive | deleted",
  "bulkingPercent": "<number | null>",
  "compactionPercent": "<number | null>",
  "maxTonnes": "<number | null>",
  "maxCubicMetres": "<number | null>",
  "createdAt": "<ISO 8601 datetime>",
  "updatedAt": "<ISO 8601 datetime>",
  "assumptions": []
}
```

`assumptions` is always an array. An empty array means the item has no assumptions.

## Create request schema

`POST /api/rates/rate-items` validates the request body through the exported `rateItemCreateBodySchema`. Key rules:

* `name` is required and must contain non-whitespace characters. It is trimmed before storage.
* `categoryId` is required and must be a UUID.
* `status` defaults to `active` when omitted. Accepted values are `active`, `inactive`, and `deleted`.
* `maxTonnes`, `maxCubicMetres`, `bulkingPercent`, and `compactionPercent` must be numbers when present; string representations are rejected.

## Receiving-side contract pairs

The named contracts below link the receiving tests in this service to the sending tests in each caller. The existence of a sending test does not imply that every endpoint in the pair is covered by the receiving suite.

### BC-01: stralos-api-assistant → stralos-api-rates

**Requests:** `GET /api/rates/rate-items/:id`, `GET /api/rates/rate-items/:id/options`, `GET /api/rates/rate-summaries`, `POST /api/rates/rate-items`.

**Sender tests:** `stralos-api-assistant/test/readTools.test.ts`, `stralos-api-assistant/test/drafts.test.ts`.

Dedicated mocked-fetch unit tests check rate-option paging, nullable locations, and rate-summary mapping. Draft integration tests assert the POST URL, body, and forwarded bearer token.

**Coverage limits:** The receiver suite covers the item GET and create-body schema only. Option and summary responses and successful POST responses are outside that suite. Sender `rateItemBody` fixtures still use non-UUID IDs, so those mocks do not prove that the receiver will accept the request.

### BC-08: stralos-api-estimate → stralos-api-rates

**Requests:** `GET /api/rates/rate-items`, `GET /api/rates/rate-items/:id`, option and companion reads, project override and settings reads.

**Sender tests:** `stralos-api-estimate/test/reResolvePrices.test.ts`, `stralos-api-estimate/test/helpers/reResolveStub.ts`.

DB-backed sender tests use a simulated HTTP peer and check bearer forwarding, numeric and metadata validation, pagination failure, and 502 handling.

**Coverage limits:** These tests require Postgres and a test fixture listener. Receiver contracts cover rate-item reads only, not every option, companion, override, or settings response this fan-out consumes.

### BC-10: stralos-api-global → stralos-api-rates

**Requests:** `GET /api/rates/rate-items?categoryId&includeDeleted=true&start&length`.

**Sender tests:** `stralos-api-global/test/rateItemUsageClient.unit.test.ts`.

Dedicated mocked-fetch unit tests assert exhaustive pagination, query parameters, bearer forwarding, live and deleted classification, and failure and response validation.

**Coverage limits:** Receiver tests pin the list envelope and accepted parameters. SQL filtering is not proved by the receiver unit fixture; retain database integration coverage. Both suites remain independently maintained.

## Sending-side contract pairs

The named contracts below link the outbound HTTP calls this service makes to their corresponding receiver-side documentation.

### BC-05: stralos-api-rates → stralos-api-global

**Requests:** `GET /api/global/projects/:id`, `GET /api/global/projects/:id/location-routes?locationIds`, `POST /api/global/projects/:id/location-routes/resolve`, delegated category, location, party, and company reads, and batch resolution via `POST /api/global/rate-references/resolve-batch`.

**Sending implementation:** `src/lib/project-rate-context-client.ts`.

**Sender tests:** `test/projectRateContext.test.ts`.

DB-backed sender route tests exercise real peer clients through URL-dispatched fetch stubs, including `locationIds` query and body handling and malformed and unavailable peer responses.

**Coverage limits:** These tests can skip locally without Postgres and are not a standalone client unit suite. Receiver contracts cover project detail and cached routes; `resolve` has invalid-input tests only. Catalogue and source-pair contracts need separate matching receiver coverage.

### BC-06: stralos-api-rates → stralos-api-activities

**Requests:** `GET /api/activities/activities/:id`.

**Sending implementation:** `src/lib/day-rate-activity-client.ts`.

**URL resolution:** `resolveActivitiesApiUrl` reads `ACTIVITIES_API_URL` first. If that is unset, it reads the SSM parameter named by `ACTIVITIES_API_URL_SSM_PARAMETER`. If neither is set, it throws a configuration error at call time:

```text theme={null}
Activities API URL is not configured: set ACTIVITIES_API_URL or ACTIVITIES_API_URL_SSM_PARAMETER
```

There is no localhost fallback. stralos-local sets `ACTIVITIES_API_URL` directly. Every deployed Lambda has `ACTIVITIES_API_URL_SSM_PARAMETER` set by Terraform. A missing URL surfaces as a configuration error, not as "Activities service is unreachable".

**Sender tests:** `test/dayRateScheduleLines.test.ts`, `test/helpers/harness.ts`.

DB-backed mapping tests exercise the sender against activity fixtures, including unavailable and foreign activities, malformed success responses, unit mismatch, and excluding `optionalCost` from included costing. The test harness sets `ACTIVITIES_API_URL` explicitly.

**Coverage limits:** The receiving suite checks real activity DTO construction and separate included and optional numeric costs. Sender tests are integration tests with peer fixtures, not dedicated no-DB client contract tests.

### BC-07: stralos-api-rates → stralos-api-rate-extract

**Requests:** `POST /rate-extract/uploads`, `POST /rate-extract/ingest`.

**Sending implementation:** `src/lib/rate-extract-client.ts`.

**URL resolution:** see the api-rate-extract URL resolver under [Peer URL configuration](#peer-url-configuration).

**Sender tests:** `test/rateImportParses.test.ts`, `test/rateImportAnalyses.test.ts`, `test/selectionReuse.test.ts`.

DB-backed import tests mock ingest responses and exercise downstream success, refusal, and retry handling through the real ingestion client.

**Coverage limits:** No dedicated `requestUpload` or `ingestUpload` unit suite exists. The ingest stub routes by URL but does not comprehensively assert the outgoing body, shared-secret header, or timeout budgets. Receiver tests cover upload and ingest validation and JSON responses in both HTTP and Lambda adapters.

## Service login confinement

`stralos-api-rates` gates every incoming service token with an explicit confinement layer before route handlers run (security review 2026-10-03). Three kinds of confinement apply.

### Read-only service logins (B1)

Three service principals hold a `rates:read` scope and were previously able to call any route, including mutating ones. Each is now confined to the specific reads it is known to make:

| Subject | Allowed route | Error code on refusal |
| - | - | - |
| `service:documents-worker` | `GET /api/rates/rate-items` | `service_read_only` |
| `service:pricing-materialise` | `GET /api/rates/rate-items/:id/options` | `service_read_only` |
| `service:ci-journey` | None (its only api-rates read has been retired) | `service_read_only` |

Any request from one of these subjects outside its allowed route, or with the wrong method, or without a valid org, returns `403 service_read_only` before the handler runs.

Before this confinement, a mutating request from one of these principals would fail only by accident — the service subjects are not UUIDs, so the `changed_by` column stamp would throw. The confinement makes the boundary structural and explicit.

### Service write gate (B1)

No service principal may call a mutating method on a route unless that route has explicitly opted it in. Today only the `estimate-rates` login's writes are opted in, plus the `contractor-copy` login on its two routes (see [Contractor copy](#contractor-copy)). Any other service principal attempting a non-`GET`/`HEAD`/`OPTIONS` request is refused with `403 service_write_forbidden`.

### JWKS resolver hardening (B2)

The JWKS resolver now enforces a 30-second global cooldown on all refetch attempts, including failed ones. Before this change the per-`kid` negative cache was keyed by the token's own `kid`, so requests carrying many distinct fabricated `kid` values could each trigger a separate unauthenticated fetch through the shared NAT gateway. The new resolver gates all kids on a single clock, coalesces in-flight refreshes, and keeps serving the last successfully fetched key set when a refresh fails rather than returning `401` to every request until a refresh succeeds. See [JWKS caching in consumers](https://help.stralos.app/internal/auth-token-architecture.md#jwks-caching-in-consumers).

### Per-organisation import queue fair share (B3)

The rates import queue is shared across all organisations and drained by a worker capped at a small number of concurrent invocations. A new import request (parse, survey, analysis, or extraction job start) is refused with `429 rate_import_busy` when the requesting organisation already has two or more jobs in the `queued` or `running` state. Rows that have been in one of those states for more than one hour stop counting, so a crashed worker cannot lock an organisation out of importing indefinitely.

The limit is two concurrent in-flight imports, which allows two people in the same company to import at the same time without any one organisation filling the whole queue.

See [Bounded input](https://help.stralos.app/internal/rates-import-service.md#bounded-input) for the existing per-route rate limits and the cap table.

## Required startup configuration

`buildApp` refuses to start without `AUTH_JWKS_URL`, in every environment; see [Authentication deployment requirements](https://help.stralos.app/internal/deployment.md#authentication-deployment-requirements). Terraform passes `var.auth_jwks_url` to both the API Lambda and the rate-import worker through `local.lambda_environment`.

## Peer URL configuration

`stralos-api-rates` resolves its peer base URLs from environment variables or SSM parameters. No resolver has a localhost default: a missing configuration throws a configuration error rather than calling a hard-coded port. The api-activities resolver is described under BC-06 above; the others follow.

### api-global URL (`src/config/global-api-url.ts`)

`resolveGlobalApiUrl` resolves in order:

1. `STRALOS_GLOBAL_URL` — set by stralos-local for local development.
2. `STRALOS_GLOBAL_URL_SSM_PARAMETER` — the SSM parameter name this repo's terraform sets on every deployed function that calls api-global.

If neither is set, it throws: `"api-global URL is not configured: set STRALOS_GLOBAL_URL or STRALOS_GLOBAL_URL_SSM_PARAMETER"`. The five global clients (project authorisation, day-rate identity, project-rate context, rate references, suppliers) resolve the URL before their first fetch, so a missing configuration surfaces as a configuration error rather than `"Global service is unreachable"`.

### api-estimate URL (`src/config/estimate-api-url.ts`)

`resolveEstimateApiUrl` resolves in order:

1. `ESTIMATE_API_URL` — set by stralos-local for local development.
2. `ESTIMATE_API_URL_SSM_PARAMETER` — the SSM parameter name this repo's terraform sets on every deployed function that calls api-estimate.

If neither is set, it throws: `"estimate API URL is not configured: set ESTIMATE_API_URL or ESTIMATE_API_URL_SSM_PARAMETER"`.

The delivery variant `resolveEstimateApiUrlForDelivery` returns a typed result rather than throwing, so the re-price outbox can record a `not_configured` skip. It returns `kind: "not_configured"` in all environments when neither variable is set, when the SSM parameter does not exist, or when the parameter holds no usable value. It returns `kind: "unavailable"` when the SSM read fails or times out. See [Re-price outbox](https://help.stralos.app/internal/project-rates-service.md#re-price-outbox) for how callers handle each outcome.

### api-rate-extract URL (`src/lib/rate-extract-client.ts`)

The rate-extract client resolves in order:

1. `RATE_EXTRACT_URL` — set by stralos-local for local development and by the test harness.
2. The SSM parameter named by `RATE_EXTRACT_URL_SSM_PARAMETER` — Terraform sets this on the API Lambda and the rate-import worker, the two functions that reach this client.

If neither is set, it throws `"rate-extract URL is not configured: set RATE_EXTRACT_URL or RATE_EXTRACT_URL_SSM_PARAMETER"`. The URL is resolved before the fetch's `try` block, so a missing configuration surfaces as a configuration error rather than `"the file service is unreachable"`.

## Rate category projection

`stralos-api-rates` keeps a local projection of api-global's rate categories in `rates_rate_category_projection` (migration 0050). Rate reads and writes resolve categories from this table only. `GET /api/rates/rate-items`, `GET /api/rates/rate-items/:id` and `GET /api/rates/rate-summaries` make no peer call for category data, and item and option writes validate `categoryId`, allowed units and physics licensing against the projection. The former per-request classification reconcile has been removed.

### Event consumer

api-global publishes a `RateCategoryChanged` v1 event on every category create, update and soft delete, carrying the category's full state after the write. The consumer in `src/events/rateCategoryChanged.ts` reads the `rates-rate-category` SQS subscription queue and applies each state through `applyCategoryStates`. For an active category it also copies `name` and `parentGroup` onto the organisation's rate items whose copy has drifted. A deleted category leaves its items unchanged.

* **Newer versions only.** Delivery is at-least-once and unordered, so an event applies only when its `categoryVersion` is greater than the stored one. An older or equal event is acknowledged without change.
* **Dedupe and effect in one transaction.** `handleEvents` inserts the `(consumer, eventId)` row into `processed_events` in the handler's transaction. A duplicate is acknowledged without running the handler; a handler error rolls both back so the event is retried.
* **Owner pool, tenant-scoped.** The consumer uses the owner database pool under `withTenant` for the event's organisation. The runtime role's restrictive `project_membership` policy would hide project-only items, and a category rename must reach them too. The owner is still subject to every tenant-isolation policy, and the handler refuses an event whose organisation differs from its transaction's.

### Backfill and read-through

The projection starts empty and api-global publishes only on writes. The first request that needs an organisation's catalogue therefore reads it once from api-global with the caller's bearer, seeds the projection, and records the organisation in `rates_rate_category_projection_seeds`. The same read-through runs when a write names a `categoryId` the projection does not hold as active, so a category created moments earlier is not refused while its event is in flight.

A service caller, such as the [`estimate-reprice` login](https://help.stralos.app/internal/project-rates-service#estimate-reprice-service-login), never seeds. Its reads need no category data, and a missing category label reads "Unavailable category".

## Retention and offboarding

A dedicated retention Lambda (`retention-lambda.handler`, deployed as `stralos-<env>-au-nz-api-rates-retention`) runs weekly on `cron(0 15 ? * SUN *)`. It hard-deletes every api-rates row for an organisation that api-global has reported as `disabled` for `ORG_OFFBOARDING_PURGE_AFTER_DAYS` (default 90) days. The clock is kept in `rates_org_offboarding` (migration 0052), which is not purged.

The purge runs as the platform role, re-checks the organisation's status immediately before deleting, and deletes in one transaction with an explicit organisation predicate on every statement. Day-rate tables stay closed to the platform role and are deleted through the owner function `rates_org_purge_day_rates(org)` in the same transaction. A run with any failed organisation throws after trying the rest, which raises the Lambda's Errors alarm.

The same Lambda answers the support fan-out's `{ "kind": "erase-user", "userId", "requestId" }` event. api-rates stores only opaque actor ids, so it always reports empty counts.

See [Data retention](https://help.stralos.app/internal/data-retention.md) for the shared [offboarding clock](https://help.stralos.app/internal/data-retention.md#the-offboarding-clock), [status guard](https://help.stralos.app/internal/data-retention.md#organisation-status-guard), [per-user erasure](https://help.stralos.app/internal/data-retention.md#per-user-erasure) and [operations](https://help.stralos.app/internal/data-retention.md#operations).

## Adding new contracts

Add a new `[backend-contract]`-tagged test for each endpoint that gains a backend caller. For each endpoint, cover:

* A valid request with a representative response.
* Required and optional parameters.
* Wrong types and out-of-bounds values.
* Status codes.
* Non-empty response data.
* Null and omitted fields.
* Error response shapes.

Keep expected values explicit. Do not derive expectations from the serialiser being tested. A contract change should require deliberate review of callers — not a blanket snapshot update.

For a change to a listed method, path, parameter, required header, status code, or response field: use the same contract ID in the sender and receiver notes; update receiver tests to pin accepted and rejected inputs and actual response JSON; update sender tests to assert the real outgoing request and its handling of valid, missing, null, malformed, and error responses. Review and run both repositories' relevant suites.


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