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

# Activities service

> Internal architecture and operational notes for stralos-api-activities.

Source repository: `stralos-co-nz/stralos-api-activities`.

## Overview

The activities service owns the activity library: activities, nested activity structure, and activity components. Every read and write is tenant-scoped: the library is organisation-owned.

Base prefix: `/api/activities`

## Database roles

The service uses three Postgres connection roles:

| Role | Purpose |
| - | - |
| `stralos_owner_activities` | Migrations and schema changes (full owner rights). |
| `stralos_runtime_activities` | Request-path queries (RLS-subject; cannot bypass row-level security). |
| `stralos_platform_activities` | BYPASSRLS platform operations. |

In local development and tests a single owner connection is used for all three.

## Authentication

All business routes require a valid JWT. `GET /health`, `GET /api/activities/version`, and
`GET /api/activities/openapi.json` are auth-exempt infrastructure routes. The auth
plugin accepts only EdDSA tokens verified against the Global API JWKS. A missing
or invalid token receives `401`.

Reads are available to any authenticated organisation member. Writes use two gates in `src/db/tenant.ts`:

* `requireOrgMember` guards adding and editing: activities (including `POST /activities/from-components`), components, production profiles (including their deletes) and library calculators. It accepts a user token whose role is `member` or `admin`. An org-bearing service token renders role `""` and receives `403`.
* `requireOrgAdmin` guards `DELETE /activities/:id` and every rate-sheet mutation. A member receives `403`.

The `contractor-copy` service login has its own gate on its two routes; see [Contractor copy](#contractor-copy).

### Billing access guard

After JWT verification, the auth plugin parses the optional `billing` claim into the request context and registers `registerBillingAccessGuard` from `@stralos-co-nz/api-common/billing`. When `BILLING_ENTITLEMENTS_TABLE` is configured, the guard checks DynamoDB for current entitlement state before authenticated user writes and refreshes stale claim state for that request. A subscription beyond its grace period is rejected before business logic runs; service-token behavior and the exact response follow the shared guard contract.

When the table name is empty, the guard makes no DynamoDB call and passes authenticated requests through. Terraform's `enable_billing_enforcement` flag controls whether the environment-qualified table name and read permission are supplied.

The read permission (`terraform/billing-entitlements.tf`) grants `dynamodb:GetItem` on two partition key prefixes:

| Prefix | Row |
| - | - |
| `ORG#*` | The company's billing entitlement. |
| `LINK#*` | Contractor-company link state, read by api-common's `createLinkStateReader` on every request by a contractor person. |

Without `LINK#*`, that read is `AccessDenied` and the guard answers `503 billing_unavailable` for contractor persons.

## Version endpoint

`GET /api/activities/version` is unauthenticated and always returns `Cache-Control: no-store`. The response carries two fields:

* `version` — reads `SERVICE_VERSION` and falls back to `package.json` when that variable is absent.
* `commit` — reads `SERVICE_COMMIT` and falls back to `"unknown"` when that variable is absent.

`SERVICE_COMMIT` is stamped into the Lambda environment on every Terraform apply via `TF_VAR_service_commit` in the core deployment workflow. This lets operators and the release pipeline tie the running build to an exact commit through the edge, even on dev environments that carry no semver.

This endpoint is used by the frontend coordinator's minimum-version gate to verify that a compatible build is deployed before any promotion operations are attempted.

## OpenAPI document

`GET /api/activities/openapi.json` serves the service's OpenAPI 3.1.0 document. This endpoint is unauthenticated and always returns `Cache-Control: no-store`.

The document is generated at startup from the route-descriptor registry in
`src/openapi/registry.ts` via `buildOpenApi.ts` and serialised once; the same bytes are returned for
every request (`src/openapi/routes.ts`). Route coverage and the committed
`src/openapi/openapi.snapshot.json` bytes are enforced by separate tests.

### Route-descriptor registry

Each route is described by a `RouteDescriptor` in `activityRouteDescriptors` (PRD 01 §5.1). The descriptor's `body`, `params`, and `querystring` fields reference the **same** live Zod schema objects the request handlers parse with — the documented contract cannot drift from the validated one.

`GET /activities` documents its query as the union of the shared `listQuerySchema` (`start`, `length`, `search`) and the route-specific `activitiesListQuerySchema` (`parentActivityId`), because the handler parses both.

### Search filter

The search filter is built by `makeLiteralSearchFilter` in `src/lib/search.ts`. It escapes `%`, `_`, and `\` before interpolating the term into a `%…%` LIKE pattern, so the caller's text is always treated as literal characters. Both the `items` rows and the `totalCount` are computed from the same org-scoped predicate; a previous unescaped builder caused stray metacharacters to silently expand both.

The term is not always typed by a human. The estimate service's promote path sends a work item's name here as `search` (via `findLibraryActivitiesByName`, capped at `length=25`) when checking for library duplicates. Literal escaping is therefore required for cross-service correctness as well as for interactive use.

### Drift gate and block integrity

`test/openapiDrift.test.ts` verifies that the registered Fastify route table and the descriptor array cover exactly the same set of routes in both directions. `test/openapiSnapshot.test.ts` asserts the committed snapshot matches the document the current code would emit.

The four shared emitter files (`buildOpenApi.ts`, `descriptor.ts`, `routes.ts`, `stableStringify.ts`) are byte-identical across all adopting services. Their combined SHA-256 is stored in `src/openapi/blockSha.ts` (`OPENAPI_BLOCK_SHA`) and verified by `test/openapiBlockParity.test.ts`. The descriptor-contract version (`OPENAPI_CONTRACT_VERSION`, currently `1.0.0`) is set in the same file and is independent of the service or deploy version.

## Idempotency

`POST /activities/from-components` accepts an optional `idempotencyKey`. The service enforces one activity per `(organisation_id, idempotency_key)` using a partial unique index. A replayed request returns the original activity with `HTTP 200` and `idempotentReplay: true`; a new key returns `HTTP 201` and `idempotentReplay: false`. Concurrent identical promotes are resolved by re-reading the row that won the insert race.

## Activity name uniqueness (C1, 2026-09-18)

`POST /activities`, `POST /activities/from-components`, and `PUT /activities/:id` all call `assertActivityNameAvailable` inside the same write transaction. The predicate is:

```sql theme={null}
lower(btrim(name)) = lower(btrim($submitted))
```

This matches the `lower(btrim(name))` convention used by disposal substances in `stralos-api-global` (PRD 02 §6 / ruling R5), not the exact-match index pattern used by `activities_rate_sheets_active_name_unique`. The disposal-substances rule is the more deliberate and recently reasoned precedent for per-organisation name uniqueness.

A conflict returns `409 Conflict` with `code: "activity_name_taken"` and the stored spelling of the conflicting row in the message. The response includes `field: "name"` and `existingId` so callers can surface or navigate to the duplicate. The `PUT` path passes the current activity's id as an exclusion so saving a row with its own unchanged name is not refused.

**Why this is an application pre-check, not a unique index**

The `activities_activities` table is live and populated. Retrofitting a `lower(btrim(name))` unique index risks a migration that fails outright if any organisation already holds a same-folded-name pair. (The disposal-substances index was added at table creation, not onto populated data.) The pre-check runs inside the write transaction so it is authoritative for sequential callers. A genuinely concurrent double-submit that both pass the `SELECT` and then collide is still caught by the existing `activities_code_unique`-style 23505 handler as a generic `409`. A follow-up that audits existing names and adds a partial unique index is tracked separately.

**Scope**

The check is scoped to `organisation_id` only, with no status exclusion. `DELETE /activities/:id` is a soft delete (see [Soft delete](#soft-delete)), so a deleted activity's name is released for reuse: `findActivityNameHolder` skips rows where `deleted_at IS NOT NULL`. If the soft-delete concept is later extended with a status field, this check should be revisited against the same-status-only precedent.

## Package pricing schema (migration 0010)

Migration `0010` adds the package pricing mode and optional-item columns required for PRD 03 M1.

### Activity pricing mode

Two new column groups are added to `activities_activities`:

**Pricing mode:**

| Column | Type | Default | Purpose |
| - | - | - | - |
| `pricing_mode` | `varchar(20)` | `project_margins` | Pricing strategy for the activity. |

Accepted values for `pricing_mode`:

* `project_margins` — the activity prices through the project's margin settings. This is the legacy default; all pre-migration rows carry it.
* `fixed_price` — the activity is a publishable package with its own sell amount and default margin.

**Package pricing fields** (only valid when `pricing_mode = 'fixed_price'`):

| Column | Type | Purpose |
| - | - | - |
| `default_sell_margin` | `numeric(6,3)` | Default sell margin as a percentage (0–95, inclusive). |
| `published_sell_amount` | `numeric(14,2)` | Snapshot sell amount at the time of publication. |
| `published_cost_amount` | `numeric(14,2)` | Snapshot cost build-up at the time of publication. |
| `published_at` | `timestamp with timezone` | When the price was published. |

**CHECK constraints on activities:**

| Constraint | Rule |
| - | - |
| `activities_pricing_mode_check` | `pricing_mode` must be `project_margins` or `fixed_price`. |
| `activities_pricing_fields_mode_check` | The four package fields must all be `NULL` when `pricing_mode = 'project_margins'`. |
| `activities_default_sell_margin_check` | `default_sell_margin` must be between 0 and 95 inclusive when present. |
| `activities_published_amounts_nonnegative_check` | `published_sell_amount` and `published_cost_amount` must be non-negative and not `NaN` when present. |
| `activities_publication_complete_check` | `published_sell_amount`, `published_cost_amount`, and `published_at` are atomic — all three must be set or all three must be `NULL`. |

### Component optionality

Two new columns are added to `activities_activity_components`:

| Column | Type | Default | Purpose |
| - | - | - | - |
| `optionality` | `varchar(20)` | `included` | Whether the component is mandatory or optional within the package. |
| `option_group` | `varchar(120)` | — | Label grouping mutually exclusive optional components. Only valid when `optionality = 'optional'`. |

Accepted values for `optionality`:

* `included` — the component is always part of the package (default; all pre-migration rows carry this).
* `optional` — the component can be selected or deselected when the package is used in an estimate.

**CHECK constraints on components:**

| Constraint | Rule |
| - | - |
| `activity_components_optionality_check` | `optionality` must be `included` or `optional`. |
| `activity_components_option_group_check` | `option_group` must be `NULL` when `optionality = 'included'`. |
| `activity_components_optional_referent_check` | An optional component must reference either a `rate_item_id` or a `child_activity_id`. |

### Migration hardening

All new constraints are created with `NOT VALID` in the migration SQL so that the constraint check applies to new writes immediately without a full table scan at deploy time. The `scripts/migrate.mjs` runner follows up with `VALIDATE CONSTRAINT` calls for each constraint once the migration is applied, completing backfill validation in the same deploy step.

### Write and publication behaviour

The activity and component write schemas accept the package fields. Optional components must retain a rate-item or child-activity referent.

**Inline price save (2026-09-20)**

`sellAmount` on `POST /activities`, `POST /activities/from-components`, and `PUT /activities/:id` records the sell, the current included build-up cost snapshot, and the timestamp in the same transaction as the activity save, under the parent-row lock that component writes already take. There is no separate publish step. The `priceSnapshotColumns` helper computes the four-column write from the supplied amount:

* A value equal to the already-recorded sell to the cent writes nothing.
* Explicit `null` clears all three publication columns.
* Omitted leaves the publication columns unchanged.

`sellAmount` is validated against the activity's `pricingMode` before the write: supplying it on a `project_margins` activity returns `422 Unprocessable Entity`. Switching `pricingMode` to `project_margins` in the same request clears all package pricing fields regardless of `sellAmount`.

A priced margin is locked against a `PUT` that changes only `defaultSellMargin` without also changing the price; that write returns `409 Conflict`.

### Build-up cost precision

`buildUpCostCents` in `src/routes/activities.ts` accumulates each component's `quantity × resourceCount × unitCost` product with scaled bigint arithmetic. It uses the domain's `multiplyScaled` helper and a local `addScaledExact` accumulator. Products stay at their natural scale with no intermediate rounding; only the final included and optional totals are rounded half-up to `SCALE.money` (two decimal places) with `rescaleHalfUp`.

The `estimatedCost` and `optionalCost` DTO fields therefore remain exact cent values even for decimal inputs such as `0.1 + 0.2`. The inline price save records the same included-cost result as `published_cost_amount`. If that total cannot fit the `numeric(14,2)` money boundary, the endpoint returns `422` before DTO conversion or persistence.

`buildUpCosts` accepts an optional `LivePrices` argument. When supplied (all three read endpoints), `unitCostRangeFor` is called for each rate-item component to compute its range ends. The low end of each line feeds the `min` accumulator; the high end feeds the `max` accumulator. A non-rate-item line or one with no resolvable range uses its stored `unitCost` for both ends. A rate-item line whose `unitCost` is `null` (multi-price lines) and whose range cannot be read contributes nothing (zero) to both accumulators. When the `LivePrices` argument is absent (the inline price save), every component prices at its stored `unitCost` — no range is computed.

`estimatedCost` and `optionalCost` are the low ends of their respective ranges (`estimatedCostRange.min` and `optionalCostRange.min`). The inline price save does not read live prices: it records the included cost at each component's stored `unitCost`.

## Indicative pricing for library lines

`src/rates/itemPriceRange.ts` resolves the indicative unit-cost range for each rate-item component at read time. Each component is priced as the range across all live options for its rate item.

### Rate item lookup

The options come from stralos-api-rates' batch read `POST /rate-items/lookup`: each referenced item with its live library options, and each option carrying the `pricing` metadata api-rates computed — its loaded `fromUnitPrice` (base cost plus loadings) and its pricing `rules`. The range is computed the same way `/rate-summaries` computes `costRate`, but on the line's unit rather than the item's mode unit.

Only the items a page names are read, never the whole catalogue. One lookup answers up to 100 ids, so a page costs one request per 100 distinct items, sent together. Each item is cached per bearer token for 30 seconds, with a maximum of 200 cache entries. The lookup is never called inside a database transaction.

### Range computation

A tiered or rate-table option contributes every band or cell price, each with the option's standard loadings; any other option contributes its loaded price (`pricing.fromUnitPrice`). Each price is normalised to the component's unit through the domain's `normalisedCostRate`. Options whose conversion path is not available are excluded from the range rather than priced as zero. Options priced by step are excluded from the per-unit range entirely.

### Stepped items

`stepAmountRangeFor` in `src/rates/itemPriceRange.ts` reads each summary option's `rules` for a base rule with `ruleKind: "stepped"`. It collects the `amount` of every step not marked `retired` across the item's stepped options and returns `{ min, max, basis: "step", stepCount }`, or `null` when no option is priced by step.

`buildUpCosts` in `src/routes/activities.ts` prices each line from `unitCostRangeFor` first. A line with no per-unit range but a step range contributes `0` to both ends, and its stored `unitCost` is not used. The component DTO carries the step range as `stepAmountRange`.

### Failure behaviour

When the sweep fails (rates unreachable, timeout, non-2xx response, or incomplete pagination), `resolveItemSummaries` returns `null`. A `null` map causes `unitCostRangeFor` and `stepAmountRangeFor` to return `null` for every component. An unranged line that has a stored `unitCost` falls back to that stored cost for both ends of the range. A rate-item line whose `unitCost` is `null` (a multi-price line) contributes zero to both accumulators. The activities read still succeeds; `estimatedCostRange.min` equals `estimatedCostRange.max`. Failed sweeps are never cached.

## Component rate group snapshot

Activity components carry a nullable `rateGroup` field that records which cost category the component belongs to. The six accepted tokens are `labour`, `plant_vehicles`, `quarry_materials`, `other_materials`, `subcontractors`, and `disposal`. These match the `RATE_GROUPS` tuple exported by `@stralos-co-nz/domain` and the `rate_group` column on `estimate_work_item_components`.

A Zod `z.enum(RATE_GROUPS)` guard fronts a database `CHECK` constraint:

* A valid token is stored and echoed on every DTO surface (activity detail, components list, activities list, create/update echo).
* On create, explicit `null` or omission leaves the column `NULL`.
* On update, explicit `null` clears the stored group and omission preserves it.
* An invalid token returns `400 Bad Request`; a direct-SQL out-of-range write is rejected by the `CHECK` as a `23514`.

Migration `0007` adds the column and initial `CHECK` to `activities_activity_components`. Migration `0009` extends the `CHECK` to include `disposal` as the sixth token; the change is additive and no existing rows are rewritten.

## Commercial-rule library defaults on production profiles (REMOVED)

Migration `0008` added three nullable columns to `activities_production_profiles` —
`default_min_charge_duration`, `default_rounding_increment` and `default_mobilisation_amount` — as
reusable library defaults that pre-filled the estimate service's materialise dialog. They were inert
within this service: stored on the profile, echoed on every DTO, no pricing logic.

**Migration `0015` drops all three.** The minimum charged duration, the rounding increment and the
mobilisation amount were withdrawn as a feature on 2026-09-20. The estimate side went first — its
materialise pre-fill was retired, then `estimate_work_item_components.min_charge_duration`,
`.rounding_increment` and `.mobilisation_amount` were dropped by that service's migration `0060` — so
these defaults fed nothing by the time they were dropped.

`0015` is a destructive migration and arms the writer outage (`drizzle/0015_CUTOVER_REQUIRED`). It
counts each column separately, `RAISE NOTICE`s the counts into the deploy log, and REFUSES to drop
anything if a value is still set: a stored charging rule is priced money.

A request body that still carries the three field names is accepted and ignored — the request schemas
strip unknown keys — and no response carries them back.

## Retired profile and calculator fields

Migration `0020` drops the production profile condition columns (`depth_min_m`, `depth_max_m`, `diameter_min_mm`, `diameter_max_mm`, and `ground_type`) and their constraints. These fields are absent from request and response contracts; unknown request keys are stripped.

Migration `0019` clears activity-line rate-option pins. Migration `0022` drops `activities_activities.calculator`, `activities_activity_components.rate_option_id` and `rate_option_label`, and `activities_calculators.legacy_activity_id`, together with their retired constraints and indexes. Activity calculators use the library-level `activities_calculators` entity; activity lines take their indicative price from the item's available options rather than a pinned option.

Migration `0021` removes the redundant `organisation_id` indexes on the calculator counters and links tables. The primary and unique indexes continue to cover those keys.

### Batch component `code` and `category` nullability

The `batchComponentSchema` used by `POST /activities/from-components` accepts explicit `null` for both `code` and `category`. This is a three-valued contract — string, `null`, or absent — matching what the estimate service sends: it forwards these fields verbatim from its own nullable columns, so an absent value on the estimate side arrives here as explicit `null`, not as an omitted key.

`code` is commonly `null` because the rates service dropped `rate_items.code` in migration `0036`. The batch still accepts and stores a non-null code when the Estimate API has stamped one onto the component.

The single-component create route (`componentBodySchema`) keeps the two-valued `optionalLimitedText` contract — it is a UI surface, not a service-to-service one, and does not forward the estimate's nullable columns. `name: null` is still rejected on the batch schema; only `code` and `category` are nullable.

## Calculator physics validation

Activity library calculators store geometry and physical snapshots without prices. The shared engine validates configurations and exposes named layer outputs. The assignment register selects an output and destination for each linked layer; the Activities service then reconciles the calculator's complete generated-row namespace transactionally.

`DisposalSubstanceSnapshot` supplies excavation bulking and density. `FillMaterialSnapshot` supplies bought-fill compaction and minimum and maximum density. Missing physics leaves the corresponding loose-volume or tonnage output absent rather than inventing a zero or fallback value. Pricing belongs to each destination activity component.

### Calculator response and engine warnings

`POST /activity-calculators`, `PUT /activity-calculators/:id`, and `GET /activity-calculators/:id` return:

```json theme={null}
{
  "calculator": { ... },
  "warnings": [...],
  "engineWarnings": [...]
}
```

`warnings` are this service's own write-gate advisories (for example, under-allocated excavation strata). `engineWarnings` are the engine's own advisories, passed through verbatim. Both are reported; they answer overlapping but not identical questions.

Engine warning kinds:

| `kind` | Payload fields | Meaning |
| - | - | - |
| `excavation-partition-under` | `unallocatedInSituM3` | In-situ volume that no destination claims. |
| `reuse-exceeds-excavation` | `shortfallInSituM3` | More material requested from the dig as backfill than the dig contains. Disposal is clamped at zero. |
| `trench-width-below-pipe-clearance` | `pipeDiameterMm`, `clearancePerSideMm`, `requiredWidthMm`, `enteredWidthMm` | Trench width is narrower than the clearance the pipe requires. Quantities are unchanged. |
| `fill-layer-stratum-unspecified` | `layerId` | A layer filled from the dig names no stratum in a calculator with two or more existing strata. The engine cannot auto-select a source stratum, so it generates a generic row. Quantities are unchanged. Introduced in calculators 1.6.0. |

`kind` is an open string, not an enum. A pin bump that introduces a new advisory reaches the client without a service change.

`fill-layer-stratum-unspecified` fires when a `backfillFrom` is absent on a from-the-dig layer in a pit, trench, or volume calculator that has two or more `existingLayers`. Adding a `backfillFrom` snapshot to the layer resolves the advisory. The warning carries the `layerId` of the affected layer so the client can point the estimator to the right row.

### `backfillFrom` config validation

`POST /activity-calculators` and `PUT /activity-calculators/:id` validate every `backfillFrom` reference in the submitted config before storing it. The config is stored as sent; the engine's normaliser runs at compute time and is never written back. Two write-gate rules guard the reference.

**Well-formedness**

`src/calculators/backfillFrom.ts` walks the whole config graph and refuses any `backfillFrom` that is malformed or misplaced. Two distinct error codes, both naming the dotted path:

| Code | HTTP | Meaning |
| - | - | - |
| `calculator_config_backfill_from_malformed` | `422` | The value is not a `{ stratumId, name }` object with two non-blank strings and nothing else (an extra member, a blank id, a string in place of an object). |
| `calculator_config_backfill_from_misplaced` | `422` | The reference sits on a shape that carries no `isBackfill` — a stratum, a pipe, a structure, or the calculator arm itself — where it can mean nothing. |

Valid parents (the only locations where `backfillFrom` is accepted): `layers[*]` for trench and volume arms; `layers[*]`, `bedding`, `concreteBase`, and `backfill` for the pit arm. A reference beside `isBackfill: false` is stored exactly as sent; the engine strips it at compute time and this service does not pre-empt that.

**Reinstatement eligibility**

After the well-formedness check, `assertConfigBackfillFromEligible` in `src/calculators/backfillFrom.ts` refuses any from-the-dig layer (`isBackfill: true`) whose `backfillFrom.stratumId` names a stratum whose frozen substance snapshot carries `canUseForReinstatement: false` explicitly. The response is `400` with code `calculator_config_backfill_from_ineligible`, naming the path and the stratum.

The gate is narrower than the UI's advisory rule on purpose. The engine's `isStratumReinstatementEligible` also derives legacy snapshots (no `canUseForReinstatement` flag) as ineligible based on `compactionPct`. The save gate does not apply that derivation: stored configs already reference legacy strata with null compaction, and blocking their next autosave would be incorrect. Only an explicit `canUseForReinstatement: false` triggers the `400`. A legacy snapshot without the flag is always accepted here; the UI advises on those cases rather than blocking.

**PUT exemption for already-stored selections**

On `PUT`, a stratum the stored config already draws from is exempt from the eligibility check. A stratum is considered already drawn from if any from-the-dig layer in the stored config names it via `backfillFrom.stratumId`, or — for a calculator with exactly one stratum — if any from-the-dig layer has no explicit `backfillFrom` (the engine auto-names it to the sole stratum). This exemption means that accepting a substance staleness refresh that unticks a stratum does not block the next autosave of a calculator whose reinstatement assignment nobody changed (ruling 3: keep the stored selection plus an advisory).

**Lockstep with api-estimate**

This rule is a copy of the same gate in `stralos-api-estimate`'s `src/calculators/backfillFrom.ts`. The two copies are kept identical and must be updated together.

## Backend API contracts

The activities service maintains a receiving-side backend contract suite. These tests pin the input validation and response JSON that other backend services rely on when calling the activities API.

### Running contract tests

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

The dedicated configuration (`vitest.contract.config.ts`) runs one worker with no database setup. It does not skip when a database is unavailable.

CI runs this as the named **Backend API contracts (receiving requests and responses)** step. A failure fails the CI job.

For a local single-repo run with memory constraints:

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

### Test boundaries

Contract tests register production Fastify route handlers and exercise actual parameter validation, error mapping, and JSON serialisation. Only the persistence boundary is simulated — fixtures are representative database rows, not pre-built HTTP responses.

What these tests cover:

* Valid request paths with representative inputs
* Required and optional parameters
* Wrong types and out-of-range values
* Expected status codes (`200`, `201`, `400`, `404`, `422`)
* Named response fields and their types
* Null and omitted field behaviour
* Error response shapes

What these tests do not cover:

* SQL filtering and RLS enforcement (existing integration and security suites own these)
* JWT signature verification
* Distributed workflow behaviour
* Sibling repository client implementations

### Covered contracts

| Endpoint | Assertions |
| - | - |
| `GET /api/activities/activities/:id` | UUID path; numeric `estimatedCost` and `optionalCost` remain separate, each with its `{min,max}` range (`estimatedCostRange`/`optionalCostRange`, sweep D10); a stepped line's `stepAmountRange` `{min,max,basis:"step",stepCount}`, which adds nothing to either total; component counts, provenance, child arrays and timestamps; populated/empty activities; `400`/`404` |

### Test file conventions

Contract tests use the suffix `*.backendContract.unit.test.ts` and carry the `[backend-contract]` marker in the `describe` label. The helper in `test/helpers/backendContract.ts` provides `fixtureDatabase()`, which simulates the persistence boundary using a queue of expected results. The queue must be empty at the end of each test — a non-empty queue fails with `"unused storage fixtures"`.

### Sending counterparts and coverage notes

Contract IDs are shared between this receiving service and the sending service. The existence of a sender test does not mean every endpoint in the pair is covered by this receiving suite.

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

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

**Sender tests:** `stralos-api-rates/test/dayRateScheduleLines.test.ts`, `stralos-api-rates/test/helpers/harness.ts`.

DB-backed mapping tests exercise the sender against activity fixtures. Covered cases include unavailable and foreign activities, malformed success responses, unit mismatch, and excluding `optionalCost` from included costing.

**Coverage limits:** This 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.

**Receiver unit suite:** `test/requests.backendContract.unit.test.ts` (only the endpoints explicitly listed under Covered contracts above).

### Maintaining both sides

For a change to a listed method, path, path or query or body parameter, required header, status code, or response field:

1. Use the same contract ID in the sender and receiver notes and associated test descriptions.
2. Update the 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.
3. Keep fixtures compatible with the receiving API, including UUID formats, envelopes, and optional fields. Do not only update a mock to make a caller test pass.
4. Review and run both repositories' relevant suites and update both notes. Preserve the existing integration and security checks.

Existing sender tests are not consistently tagged `[backend-contract]` and are generally run by the normal CI suite, not by `pnpm test:contract`. The contract runner currently selects receiver-owned tests only. A failure fails the sender's own test job when that test runs; a receiver change does not automatically run sender CI. Independent green suites do not prove compatibility against each other's current implementation. These notes add discoverability and maintenance guidance, not a cross-repository CI trigger or a new test gate.

### Adding new contracts

Add a new contract file whenever an endpoint gains a backend caller. Each contract should cover:

* A valid request with a representative payload
* Required and optional parameters
* Wrong types and out-of-range values (verify the handler rejects before touching storage where applicable)
* Status codes for success and expected error cases
* Non-empty response data and null or omitted fields

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

## Soft delete

Migration `0016` (additive) adds nullable `deleted_at` and `deleted_by` columns to `activities_activities`, `activities_activity_components`, and `activities_production_profiles`. `DELETE` endpoints stamp these columns instead of removing the row.

### Affected endpoints

| Endpoint | Behaviour |
| - | - |
| `DELETE /activities/:id` | Stamps the activity. Components and profiles are left untouched. A deleted activity still answers `GET /activities/:id` with `200` and carries `deletedAt`; it is hidden from all list and search endpoints. |
| `DELETE /activity-components/:id` | Stamps the component. When the component is a library calculator row, the route also removes the layer's register link and records the layer in `decided_layer_ids` so the reconcile cannot re-insert it. A repeat DELETE is a `204` that changes nothing. |
| `DELETE /activity-production-profiles/:id` | Stamps the profile and clears `is_default`. A repeat DELETE is a `204` that changes nothing. |

### Idempotency

A repeat DELETE on an already-deleted row returns `204` and changes nothing. The stamp is not re-applied.

### Writes to deleted rows

Any write to a deleted activity, component, or profile returns `409 Conflict` with `{ code: "deleted" }`. This applies to all `POST`, `PUT`, and sub-resource create routes that target the deleted row.

### List visibility

`GET /activities` and the components and profiles embedded in an activity hide deleted rows by filtering on `deleted_at IS NULL`. The `GET /activities/:id` detail endpoint is explicitly unfiltered so that the estimate service's materialisation path can still resolve an activity an estimate already holds.

### Name uniqueness after delete

A soft-deleted activity releases its name. The `findActivityNameHolder` uniqueness check skips rows where `deleted_at IS NOT NULL`, so a new activity can be created with the same name after the original is deleted.

### Unique index changes

The code, idempotency-key, generated-key, and default-profile unique indexes are dropped and recreated with a `deleted_at IS NULL` predicate under new `*_live_unique` names. Uniqueness now holds among live rows only, so a deleted row never blocks a new live row from taking the same code, idempotency key, generated key or default slot.

### Activity delete side-effects

Deleting an activity releases its library-calculator links (the links that pointed at it are deleted from the register) and unsets any calculator's `defaultActivityId` that pointed at it. These are the operations the `ON DELETE` foreign key rules performed while the delete was hard. The reconcile no longer reaches component rows on a deleted activity.

A deleted activity is refused as a calculator destination: `PUT /activity-calculators/:id` and `PUT /activity-calculators/:id/links` reject a `defaultActivityId` or link target that resolves to a deleted row, returning `422 calculator_destination_out_of_library`.

### Calculator row soft-delete

Component rows owned by a library calculator are soft-deleted by the reconcile rather than hard-deleted. The `generated_key` column is retained on the stamped row so that a subsequent reconcile can re-insert a row with the same key. The `activity_components_generated_key_live_unique` partial index enforces uniqueness only among live rows, allowing a new row with the same generated key to be inserted after the previous one is stamped.

When an estimator deletes a calculator row via `DELETE /activity-components/:id`, the route takes the calculator's reconcile lock, removes the layer's entry from the assignment register, and adds the layer to `decided_layer_ids`. This prevents the default seed and the reconcile from re-inserting the row on the next save. The layer's link goes with the row; the layer itself is preserved in `decided_layer_ids` as the record that the estimator chose to leave it unassigned.

## Soft references and validation

`parentActivityId` (on an activity) and `childActivityId` (on a component) are bare UUIDs, not database foreign keys. The service validates them on write: a missing or foreign-organisation reference returns `422 Unprocessable Entity` and writes nothing.

`activityId` on a component is a real composite foreign key (`activity_id, organisation_id`) with `ON DELETE CASCADE`. This cascade applies to hard deletes only (for example, from migration scripts). The application-level `DELETE /activities/:id` is a soft delete and does not trigger the cascade — the activity's components are left untouched. (There is no read-by-id route for a single component.)

`rateItemId` on a component is a soft reference to the Rates service; the activities service stores the ID but does not validate it.

`rate_group` on a component is an optional rate-group snapshot. The service does not validate it against the Rates service. For manually-managed components, the ordinary free-text component editor leaves the column untouched on `PUT` and defaults it to `NULL` on `POST`.

## Library calculator reconciliation

Library-level calculator rows are reconciled by `projectReconcile.ts` (`reconcileProjectCalculator`). The scope is the calculator, not the activity: every component in the organisation whose `generated_key` starts with `calc:<calculatorId>:`, across every activity it has ever linked, is owned by that calculator.

`generated_key IS NOT NULL` identifies a library-calculator row. Rows without a `generated_key` are ordinary components unrelated to the reconcile.

### Rate fields after insert

Library-calculator rows are inserted with `type: "rate_item"` and `resourceCount: 1`, and no rate fields — the estimator fills those in the work item. Once a rate is set, a re-run of the calculator (triggered by a config or assignment-register save) updates only `quantity`, `unit`, and `name`. All other columns — `rateItemId`, `unitCost`, `rateGroup`, `code`, `category`, `optionality`, `resourceCount` — survive a re-run unchanged.

### Error handling on config read

The engine (`createCalculatorEngine` in `src/calculators/projectEngine.ts`) calls the current `@stralos-co-nz/calculators` builders after normalising the config. If the engine cannot read the config, it throws `422 calculator_config_unreadable` rather than returning `{ ok: true, rows: [] }`. An empty row set is a legitimate output — the reconcile acts on it by pruning every linked row — so a swallowed error would silently delete an estimator's assignments with a `200`.

### Assignment register

`PUT /activity-calculators/:id/links` replaces the complete assignment register atomically. After the write, the engine re-runs and rows are reconciled across all affected activities in one transaction. Rows stranded at old destinations by a link move are deleted in the same transaction — this is why the reconcile is scoped to the calculator rather than to a single activity.

### Spent measurement link pruning

A measurement link is a link whose output key refers to a measurement row (prefixed `m:`). When the engine emits that row but the quantity reads nothing or a non-positive value, the link is considered spent.

On a server-derived save — a calculator create, config save, or default change — a spent measurement link is pruned. The link is deleted from the register and its component row is removed, the same outcome a deleted layer receives. Nothing returns when the geometry later produces a value again: seeding skips measurements by design, so the estimator re-adds the measurement if they still want it.

Physical layer links are not pruned under this rule. A layer whose measure falls to zero keeps its link because the material is still described by the config, and §2e.4 requires the assignment to survive clearing and restoring the geometry. Only a measurement, which is a read-out rather than a described thing, is removed for reading zero.

The prune does not apply to a client-supplied register (`PUT /activity-calculators/:id/links`). A measurement link the estimator has just added in their own request body stands unchanged through the 200 response. The next server-derived save then applies the ruling to it if it is still spent.

The `prunedLinkIds` field on the reconcile outcome reports every link removed by this rule, alongside links whose layer the engine no longer emits.

### Bulk-dig exclusion

The pit and volume calculators always emit a `pit-excavation` ("Bulk excavation") or `vol-excavation` ("Bulk excavation — volume") row. The engine still computes the volume — the strata, fill, and disposal rows are derived from it — but the bulk-dig row itself must never become a priced component row.

`isBulkDigLayer` in `src/calculators/projectEngine.ts` identifies the excluded layer ids and also identifies every layer-scoped measurement reading of them (`m:l:pit-excavation:<key>` and `m:l:vol-excavation:<key>`, for example `m:l:pit-excavation:inSituM3` or `m:l:vol-excavation:areaM2`). The scope is parsed with the engine's own `parseMeasurementLayerId` rather than by string-prefix test. The predicate is applied on three independent paths:

* **Seed** — `seedLinksForDefault` skips any layer `isBulkDigLayer` returns `true` for, so the bulk-dig row is never auto-linked to the default activity.
* **PUT /activity-calculators/:id/links** — the incoming register is filtered before processing. A link naming the bulk-dig layer or one of its measurement readings is dropped silently rather than refused, because the assignment screen round-trips the register it read and refusing it would 422 every subsequent save of an old calculator that already carried such a link.
* **Reconcile** — `reconcileProjectCalculator` never inserts a row for a bulk-dig link, and removes any such row left by an older save.

The overall measurement readings of the calculator (`m:o:inSituM3`, `m:o:areaM2`, etc.) are not affected. Those are read-outs of the whole calculator, not of the bulk-dig layer, and can be linked normally.

## Library calculator deletion (`DELETE /activity-calculators/:id`) (REMOVED)

`DELETE /activity-calculators/:id` was removed as part of the soft-delete programme (2026-09-24). No screen or service called it, and a route with no callers cannot be exercised or tested. The route descriptor, its schemas, and the `detach`/`delete` helpers were all deleted with it.

## Library calculator copy (`POST /activity-calculators/:id/copy`) (REMOVED)

`POST /activity-calculators/:id/copy` was removed in code health round 2. Its only screen caller had already been deleted from stralos-feature-activities. A request to it now returns `404`. The helpers it used (`allocateCalculatorName`, `requireCalculator`, `lockCalculatorReconcile`, `applyCalculatorState`) are shared with create and update, so they stay. stralos-api-estimate's own `POST /calculators/:id/copy` is a different route and is unchanged.

## Activity calculator routes (D57–D69, D78, D80)

The `/activity-calculators` routes are the library-level counterpart of the estimate calculator routes. Five endpoints manage calculator records and links; a sixth reports substance usage.

```
GET    /activity-calculators
GET    /activity-calculators/:id
POST   /activity-calculators
PUT    /activity-calculators/:id
PUT    /activity-calculators/:id/links
GET    /activity-calculators/substance-usage
```

Write routes call `requireOrgMember`: any signed-in admin or member may create, update or relink a calculator. A service token receives `403`.

### Rename short-circuit (D80)

`PUT /activity-calculators/:id` is the save path for a calculator's geometry, default destination, and name. The route accepts a partial body: every field is optional and omitted fields are preserved.

A body that carries **only** `name` — with neither `config` nor `defaultActivityId` present as keys — is a rename, not a save. The route updates the name column, reads the updated wire, and returns immediately. It does not run the engine on the stored config, does not call `writeRegister` (which deletes and reinserts every link row), and does not call the row reconcile.

A body that also carries `config` or `defaultActivityId` goes through the full mutation tail: the engine runs on the config, the register is rewritten (inheriting links are re-resolved if the default changed), and all linked activity component rows are reconciled.

**Why the short-circuit is necessary**

Without it, a name change triggers the full mutation tail on the stored geometry. Three consequences follow:

* After an engine release the reconcile can silently re-price component rows the estimator only meant to relabel.
* A stored geometry the current engine version can no longer read makes the calculator un-renameable (returns `422`).
* Every link row is re-minted with new IDs on a relabel.

The distinction is expressed in the request body: a key's presence (even `config: undefined`) is not the same as the key being absent. The check is `body.config === undefined && !("defaultActivityId" in body)`. Only when both conditions hold is the body treated as a name-only rename.

## Rate sheets (PRD 03 M4a)

Rate sheets are tenant-owned, durable snapshots of published fixed-price package sell prices. Migration `0011` adds the `activities_rate_sheets` table.

### Data model

The `activities_rate_sheets` table stores one row per rate sheet. The `lines` column is `JSONB` and carries an ordered array of snapshot lines. Each line records the `activityId`, `name`, `unit`, `sellAmount`, and `sortOrder` at the time the sheet was created or last refreshed.

The lines column carries no foreign key back to the activities table. This is intentional: once a line is snapshotted, its sell amount is fixed until the sheet is explicitly refreshed. Price changes on the source activity do not propagate automatically.

A partial unique index enforces that no two active sheets within the same organisation share the same name. Archived sheets are excluded from this constraint, so a name can be reused after the original sheet is archived.

`adjustment_percent` is stored as `numeric(7,3)` with a `CHECK` constraint requiring the value to be non-NaN, ≥ −95, and ≤ 1000.

### Access control

All seven rate-sheet routes go through the shared `runScoped` + `orgScoped` path. Read routes (`GET /rate-sheets`, `GET /rate-sheets/:id`) accept any authenticated organisation member. All five mutation routes (`POST`, `PUT`, `DELETE`, `POST .../refresh`, `POST .../duplicate`) call `requireOrgAdmin` and return `403 Forbidden` for non-admins.

Archived sheets are excluded from the list endpoint's `WHERE` clause but remain accessible via the get-by-id endpoint. This is the same non-enumerating 404 pattern used across the service: a sheet belonging to another organisation is indistinguishable from a missing sheet.

### Adjusted sell computation

The `adjustedSellAmount` field on every line is computed by the server on every response — it is never stored. The calculation scales both the `sellAmount` (money scale, two decimal places) and the `adjustmentPercent` (percent scale, three decimal places) to integers using `toScaledBigInt`, then applies:

```text theme={null}
adjustedCents = roundedDivideHalfUp(sellCents × (100_000 + adjustmentThousandths), 100_000)
```

The result is converted back to a decimal amount with `centsToAmount`. An adjusted amount that exceeds `Number.MAX_SAFE_INTEGER` returns `422 Unprocessable Entity`.

### Activity eligibility

The `assertEligibleActivities` helper is called whenever the service builds or replaces a snapshot from live activities (create, line-replacing update, and refresh). It rejects any activity that does not exist within the organisation, is inactive, has `pricingMode !== 'fixed_price'`, or has a null `publishedSellAmount`. All conditions are checked together; a single ineligible id causes the entire mutation to return `422` and write nothing. Duplicate deliberately copies a frozen snapshot without a live-library check.

Uniqueness is also enforced at this layer: `assertUniqueActivityIds` rejects a request body that lists the same activity id twice.

### Snapshot and refresh

On create, `snapshotActivities` resolves each `activityId` in order, reads the activity's current `name`, `unit`, and `publishedSellAmount`, and writes them as the initial lines. Row order matches the order of `activityIds` in the request body.

On refresh, the same eligibility check is re-run against all existing line activity ids. If any source activity is no longer eligible, the refresh returns `422` and the sheet is not modified. When all activities pass, the server re-reads their current `name`, `unit`, and `publishedSellAmount` and overwrites the stored lines. Row order from the existing sheet is preserved. The response includes a `changes` array with per-line deltas, including a `changed: boolean` that is `true` when the sell amount differs from the previously stored value.

### Duplicate

The duplicate endpoint copies `lines`, `includeLogo`, and `termsText` verbatim from the source sheet. `clientName` and `adjustmentPercent` can be overridden in the request body; omitted fields inherit from the source. A duplicate of an archived sheet produces a new active sheet. No eligibility check is re-run on duplicate — the snapshot is carried as-is.

### Conflict and status guard

`requireActiveSheet` is called before every write that modifies content. An archived sheet returns `409 Conflict`. The archive endpoint itself does not call this guard — archiving an already-archived sheet is a no-op that still returns `204`.

## Library-level calculators (migration 0012)

Migration `0012` introduces organisation-scoped standalone calculators and their assignment register. These are the activities-side counterpart of the project-level calculators in `stralos-api-estimate`. The key structural difference is that this service has no project or estimate scope — the calculator entity is owned at the same altitude as the activities it feeds (organisation scope only).

### Database schema

Three new tables are added. No existing table is dropped, renamed, or made `NOT NULL`; the migration is `additive-online` and requires no cutover marker.

**`activities_calculators`** — one row per calculator in the organisation's library:

| Column | Type | Purpose |
| - | - | - |
| `id` | `uuid` | Primary key. |
| `organisation_id` | `uuid` | Tenant scope. |
| `name` | `varchar(200)` | Display name, unique by convention (not constrained in the table). |
| `type` | `varchar(20)` | Calculator arm: `trench`, `pit`, or `volume`. CHECK-constrained. |
| `config` | `jsonb` | Geometry-only configuration. Pricing fields are refused on write. |
| `default_activity_id` | `uuid` | Optional default destination. `ON DELETE SET NULL` enforces §2e.4's "default target deleted ⇒ default becomes unset". |
| `decided_layer_ids` | `jsonb` | Layers the estimator has seen; seeds from the default no longer apply to these. |

`default_activity_id` is a real single-column foreign key with `ON DELETE SET NULL`. The estimate-side counterpart uses a bare UUID because its default is a PAIR (`project_id` + `activity_id`) and a composite FK cannot partially null; this service has no second altitude so a single-column FK is valid.

**`activities_calculator_links`** — the assignment register, one row per (calculator, layer, destination) triple:

| Column | Type | Purpose |
| - | - | - |
| `calculator_id` | `uuid` | FK → `activities_calculators`. Cascades on delete. |
| `layer_id` | `varchar(64)` | Layer identifier from the calculator config. |
| `activity_id` | `uuid` | Destination activity. FK → `activities_activities`. Cascades on delete. |
| `output_key` | `varchar(16)` | Which physical output the component row uses: `inSituM3`, `looseM3`, `tonnes`, `areaM2`, `lengthM`, `count`. `count` is used by non-bulk layers whose rate item has a non-geometric unit (e.g. `each`, `hour`, `lot`). |
| `share` | `numeric(6,5)` | Fraction of the stratum (0–1] for a partitioned excavation stratum, or `NULL` for a whole-layer assignment. |
| `key_ordinal` | `integer` | Monotonic per-(calculator, layer) counter. Together with `calculator_id` and `layer_id` it forms the generated key. |
| `inherits_default` | `boolean` | `true` when the assignment was seeded from the calculator's default destination. Inherited assignments move when the default changes. |
| `component_row_id` | `uuid` | The `activities_activity_components` row this link currently projects, or `NULL` while it emits none. |

A unique index on `(organisation_id, calculator_id, layer_id, activity_id)` enforces D67's "a (calculator, layer, destination) triple is unique". Because `activity_id` is `NOT NULL`, one index covers all cases; the estimate side required a partial pair due to its nullable component column.

A unique index on `(organisation_id, calculator_id, layer_id, key_ordinal)` ensures ordinals are stable — the generated key `calc:<calculatorId>:<layerId>:<ordinal>` does not change when a link's destination is reassigned.

**`activities_calculator_counters`** — one row per organisation, monotonic name allocator:

| Column | Type | Purpose |
| - | - | - |
| `organisation_id` | `uuid` | PK. One row per tenant. |
| `next_seq` | `integer` | Current counter value. Each `POST /activity-calculators` without a name increments this and returns "Calculator {next_seq}". |

All three tables carry `ENABLE ROW LEVEL SECURITY` and `FORCE ROW LEVEL SECURITY` with a tenant-isolation policy matching `app.current_org`. `FORCE` binds the owner role; the only bypasses are `SUPERUSER` and the `BYPASSRLS` platform role.

**New column on `activities_activity_components`**:

| Column | Type | Purpose |
| - | - | - |
| `generated_key` | `varchar(120)` | Identity for rows projected by a library calculator. Format: `calc:<calculatorId>:<layerId>:<ordinal>`. |

`generated_key` is a column on `activities_activity_components`. The library reconcile reads only rows where `generated_key IS NOT NULL`.

A partial unique index on `(organisation_id, activity_id, generated_key) WHERE generated_key IS NOT NULL` prevents duplicate projected rows without consuming index space for the large number of `NULL` rows.

### Routes

Five routes under `/api/activities`:

| Method | Path | Description |
| - | - | - |
| `GET` | `/activity-calculators` | List all organisation calculators. Accepts `?activityId=` to filter to calculators that have at least one register entry pointing at that activity. |
| `GET` | `/activity-calculators/:id` | One calculator with its register and "used by" summary. |
| `POST` | `/activity-calculators` | Create. Name is allocated from the counter when absent. Seeds inherited links for the default activity if supplied. |
| `PUT` | `/activity-calculators/:id` | Patch-in-shape update. All three fields (`name`, `config`, `defaultActivityId`) are independently optional. Absent means preserve; `defaultActivityId: null` clears it. A config change re-runs the register against the new geometry. A default change moves inherited links. |
| `PUT` | `/activity-calculators/:id/links` | Replace the entire register atomically. D67 exclusivity is validated on the post-write set inside the same transaction. Returns `warnings[]` for under-allocated strata. |

All write routes (`POST`, `PUT`) require a signed-in admin or member (`requireOrgMember`); read routes accept any authenticated organisation member.

The `GET /activity-calculators?activityId=` filter works through the register, not the default: a calculator whose default points at the requested activity but whose layers were all explicitly re-pointed elsewhere is correctly excluded.

### Calculator config carries no pricing

The `config` field stores geometry only. Supplier, location, rate item, unit cost, and rate group are the consuming component row's own fields. `POST` and `PUT` validate on write that the incoming config contains none of these fields, and return `422` if any are present. A stored price that nothing reads would be a price somebody eventually trusts.

### Reconcile by calculator

The library reconcile (`src/calculators/projectReconcile.ts`) owns every `activities_activity_components` row in the organisation where `generated_key` starts with `calc:<calculatorId>:`. This is ownership by calculator, not by activity.

Reconciling by calculator is required by D64's "a layer reassigned to a new activity must not strand a row at the old one". A per-activity reconcile reads only the current activity's rows and cannot see or clean up rows at the previous destination.

On each register write or config update, the reconcile:

1. Reads the current register (after writing it, within the same transaction).
2. Calls the calculator engine twice — once for layer shape (which outputs each layer exposes), once with allocations projected in for quantities.
3. Computes the desired set of `(generated_key, quantity, unit, name)` triples across all destination activities.
4. Updates matching rows, inserts new ones, and deletes rows that are absent from the desired set. On a server-derived save it also removes spent measurement links and their component rows — see [Spent measurement link pruning](#spent-measurement-link-pruning).

The rate item, unit cost, rate group, code, category, optionality, and resource count on each row are never written after initial insert (D65). An estimator's pricing choices survive a re-run that changes only dimensions.

New rows start with no rate set. The activity's cost roll-up is computed on read in `buildUpCosts`, so there is no stored total to invalidate.

A transaction-scoped advisory lock (`pg_advisory_xact_lock` keyed on the calculator id) serialises concurrent reconciles for the same calculator. Two calculators reconcile in parallel without contention. The per-activity embedded lock is a different key and does not contend with the library lock.

### Register invariants (D67)

The register write replaces the whole set atomically, then validates D67 on the post-write result:

* Each link may only name an output key the layer actually exposes.
* A fill layer (not an excavated stratum) may have at most one assignment.
* Excavated strata may be partitioned: each assignment takes a `share` (fraction 0–1], and the total shares across all assignments for a layer must not exceed 1.
* Unknown layer IDs supplied by the client are rejected `422`. Layer IDs that disappear due to a config edit are silently dropped (server-derived register re-run; dropping is §2e.4 "layer deleted ⇒ assignment goes with it").

### Ordinal stability and row identity

Each link carries a `key_ordinal` that is preserved when a (layer, destination) pair survives a register replace. This makes the `generated_key` stable: reassigning a layer to a different activity moves the row by updating `activity_id` on the link rather than deleting and re-inserting it. Under D65, a delete-and-reinsert would discard the estimator's chosen pricing; the stable ordinal prevents this.

When a destination is new to a layer, the smallest freed ordinal for that layer is reused before extending the ordinal space, keeping generated keys compact.

### Default seeding

When a calculator has a `defaultActivityId`, any physical output layer not already assigned (and not already decided about) receives an inherited link pointing at the default. Changing the default rewrites all inherited links to point at the new destination. Clearing the default drops all inherited links; it does not freeze them as explicit assignments.

Measurement rows (engine 1.4.0+, `layerKind === "measurement"`) are never seeded. A measurement is a geometry read-out — total length, saw cut length, footprint perimeter, surface area, excavation volume, pipe sticks per standard lengths, and each layer's own area, length, and volume — which an estimator may choose to charge a rate item against. Its link is created by the estimator only.

The bulk-dig layer (`pit-excavation`, `vol-excavation`) and its layer-scoped measurement readings are also never seeded. See [Bulk-dig exclusion](#bulk-dig-exclusion).

The seeded output key depends on the layer kind:

| Layer kind | Seed order |
| - | - |
| `reinstatement` | `looseM3`, `inSituM3`, `tonnes`, `areaM2`, `lengthM`, `count` |
| All other kinds | `inSituM3`, `looseM3`, `tonnes`, `areaM2`, `lengthM`, `count` |

Bought fill starts with `looseM3` because the estimator orders loose material delivered to site. Excavation keeps `inSituM3` first because it is paid for by the hole. A from-the-dig backfill layer exposes neither `looseM3` nor `tonnes`, so it falls through to `inSituM3`. `count` remains last and is selected only when a non-bulk layer exposes no earlier output.

The `decided_layer_ids` column tracks layers the estimator has made an explicit decision about. A layer in `decided_layer_ids` is not re-seeded from the default even if it has no assignment. Layers enter `decided_layer_ids` in two ways: the assignment UI records every layer the estimator has been shown, and `DELETE /activity-components/:id` on a calculator row records the deleted layer as decided so that the reconcile and the default seed cannot re-insert it.

## Migration 0017: unit cost nullable on multi-price lines

Migration `0017` (`drizzle/0017_component_unit_cost_nullable.sql`) relaxes the `NOT NULL` constraint on `unit_cost` in `activities_activity_components`. The change is additive: no existing row is rejected by the relaxed column and no cutover marker is required.

**Why null is now valid**

Library pricing is indicative. A rate-item component with multiple priced options previously stored the cheapest option's price as a default `unit_cost`. That figure was never chosen by an estimator and could mislead callers — stralos-api-rates BC-06 — into treating it as a confirmed price rather than the low end of a range.

After this migration, a rate-item line whose item has more than one priced option stores `unit_cost = NULL` on write. The read price for such a line is its item's option range supplied by `itemPriceRange.ts`, not a stored scalar.

**Validation rules**

| Line type | `unitCost: null` accepted? |
| - | - |
| Rate-item line (`rateItemId` set) | Yes |
| Line with no rate item (`rateItemId` absent) | No — `422` |

The `assertComponentWrite` guard checks these conditions on both `POST /activity-components` and `PUT /activity-components/:id`. Clearing the rate item from a previously null-cost component requires supplying a non-null `unitCost` in the same request body.

**Read behaviour when the range cannot be resolved**

When stralos-api-rates is unreachable at read time, `unitCostRangeFor` returns `null` for null-cost lines. `effectiveUnitCost` then falls through to `component.unitCost ?? 0`, which resolves to `0`. The line contributes nothing to `estimatedCost` or `estimatedCostRange`. Existing rows that still carry a stored `unitCost` fall back to that figure, unchanged.

**Existing rows**

Rows written before this migration retain their stored `unit_cost`. They continue to price at the stored figure until the next write, which — for an unpinned multi-price component — stores `null` according to the new rule.

## Migration 0013: `count` output key

Migration `0013` (`drizzle/0013_calculator_links_count_output.sql`) widens the `CHECK` constraint on `activities_calculator_links.output_key` from five tokens to six by adding `count`.

`count` is the sixth named output emitted by `@stralos-co-nz/calculators` 1.3.0. A Non Bulk Material layer — a fill layer whose rate item is priced by a unit other than metre, square metre, or cubic metre — produces `{ count: 1 }` and no volume outputs. The constraint in this service must accept every token the engine considers valid, or a link the engine produces would be refused at save time.

The migration replaces the existing `CHECK` with a new one that is a superset of the old: every value accepted before is still accepted. No row is rewritten. An alias rollback to the previous release still serves against this schema (`additive-online`; no cutover marker required).

`CALCULATOR_OUTPUT_KEYS`, the Drizzle `check()`, and the OpenAPI enum are all updated to include `count`. `OUTPUT_UNITS` maps `count` to `"each"` — this service carries no rate-item snapshot and therefore cannot resolve a truer unit.

`count` is appended as the last entry of both `OUTPUT_SEED_ORDER` and `BOUGHT_FILL_SEED_ORDER`. A Non Bulk Material layer states only `count`, so it falls through every earlier key in the seed order to reach this tail entry. A layer that states any volume output above the tail seeds that volume output and never reaches `count`.

`test/calculatorOutputKeys.unit.test.ts` holds all five copies of the vocabulary (the engine's `LAYER_OUTPUT_KEYS`, `CALCULATOR_OUTPUT_KEYS`, the Drizzle `check()` literal, `OUTPUT_UNITS`, and the OpenAPI enum) equal. A later engine pin that widens the vocabulary without a matching migration fails this test before an insert fails in production.

## Migrations

The Activities deploy runs `db:migrate` before the new code goes live. `scripts/migrate.mjs` applies the journalled Drizzle batch in one transaction, then runs post-commit `VALIDATE CONSTRAINT` work so the preceding access-exclusive operation does not keep writers blocked. It also runs `CREATE INDEX CONCURRENTLY`, which PostgreSQL does not permit inside a transaction block.

Activities uses the shared `additive-online` manifest, cutover markers, standing writer-outage wiring, and pull-request guard described in [Migration execution](https://help.stralos.app/internal/deployment.md#migration-execution). Follow [API writer cutover](https://help.stralos.app/internal/runbooks.md#api-writer-cutover) when a migration cannot remain additive.

When you author a migration:

* use expand, migrate, contract rather than combining an incompatible schema change with its cleanup;
* place `CREATE INDEX CONCURRENTLY` work in `scripts/migrate.mjs`, not in a journalled SQL file;
* allocate the migration number and journal timestamp at commit time so parallel branches cannot create an order that skips one migration on an existing database; and
* validate against PostgreSQL 16 syntax because deployed environments and CI use PostgreSQL 16, while local development may use PostgreSQL 17.

## Supply-chain policy

`pnpm-workspace.yaml` sets `minimumReleaseAgeStrict: true`, so a dependency that has not aged past the configured release cutoff fails installation instead of being silently added to the exclusion list.

`minimumReleaseAgeExclude` lists each version-pinned first-party `@stralos-co-nz/` package. Update the list with every first-party dependency bump. `test/supplyChainPolicy.test.ts` verifies both the strict gate and parity between the current pins and their exclusions.

## Safe error handler

`registerSafeErrorHandler` from `@stralos-co-nz/api-common/http` is registered on the Fastify app before any routes. It intercepts whatever any route handler rethrows and answers with fixed copy — never `error.message` — so internal database text or other runtime detail is never included in a response body.

Two response shapes are emitted:

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

The `reference` value matches the `x-request-id` response header so the event can be found in CloudWatch.

This handler is the last line — `handleRouteError` in `src/lib/http.ts` maps all recognised application errors first (validation, not-found, conflict, and so on) and rethrows only genuinely unexpected errors for the safe handler to catch.

## Deployment

The service runs as an AWS Lambda behind API Gateway, deployed with Terraform. The Lambda runtime is `nodejs24.x` (Node.js 24).

### Database connection pool bounds

Each Lambda container creates up to three Postgres connection pools (owner, runtime, and platform). In a Lambda environment every container handles one request at a time, so a large pool buys nothing and a burst of concurrent containers can exhaust the database's connection slots.

`LAMBDA_POOL_BOUNDS` in `src/db/client.ts` caps each pool at:

| Setting | Value | Reason |
| - | - | - |
| `max` | `2` | One connection for the request transaction, one spare for a helper that checks out while that transaction is open. |
| `connectionTimeoutMillis` | `5000` | A saturated database fails the request in 5 s. The safe error handler maps the resulting `53300` to a retryable `503` instead of hanging until API Gateway's 29 s limit. |

The local server and scripts use pg's defaults (unbounded `max`, no connection timeout) because they handle concurrent requests from a single process.

Because Lambda freezes a container the instant its handler returns, pg's idle-timeout timer never fires while the container is idle. Sockets stay open and count against Postgres's connection limit until the container is reaped. The handler therefore closes all idle connections at the end of every invocation. Keep `DB_KEEP_IDLE_CONNECTIONS` unset or `false` in Lambda, including behind RDS Proxy. Tenant `set_config` calls pin a client session, so retaining idle clients would retain pinned database connections and recreate connection exhaustion behind the proxy.

### Telemetry after the response

`src/lambda.ts` calls `startTelemetryExtension()` from `@stralos-co-nz/api-common` first at INIT, so its SQS warm-up overlaps the SSM preload. It registers an in-process Lambda extension that sends PostHog batches through SQS after the response has gone, so requests do not wait on telemetry. The handler's `finally` block calls `finishTelemetry()` to hand the invocation's events to the extension.

The extension stays off, and `finishTelemetry()` flushes inside the request as before, when `TELEMETRY_AFTER_RESPONSE=false`, when `TELEMETRY_QUEUE_URL` is empty, outside Lambda, or when Lambda refuses the registration.

Terraform grants `sqs:SendMessage` and `sqs:GetQueueAttributes` (for the warm-up) on the telemetry queue only. When core's `environment-setup` stack has not published the queue, the statement is left out entirely, because an IAM statement with `Resource = []` is malformed.

### CloudWatch alarms

`terraform/alarms.tf` provides alarm coverage for the request-handling Lambda and its API Gateway stage. Two independent alarm families are required because neither alone covers the full failure surface:

* **Lambda Errors** — trips on the first error (crash, timeout, OOM, init failure) in a five-minute window. `treat_missing_data = notBreaching` so a quiet period does not alarm.
* **Lambda Throttles** — trips on the first throttle in a five-minute window.
* **API Gateway 5XXError** — trips on the first HTTP 5xx returned by API Gateway in a five-minute window. This catches the primary failure mode the Lambda Errors metric cannot see: the Fastify error handler catches application and dependency failures and returns them as HTTP 5xx responses, which are successful Lambda invocations that the Lambda Errors metric never records.

All alarms publish to the per-environment alerts SNS topic on both alarm and OK transitions. The effective topic ARN comes from the core `environment-setup` remote-state output `admin_alarms_topic_arn`. The optional `alarm_sns_topic_arn` Terraform variable can override it with another topic in the same account; Terraform rejects a cross-account effective ARN.

A Terraform `check` block reads the live Lambda function list at plan time and flags any function belonging to this service that is missing from `local.alarmed_lambda_functions`. A coverage gap surfaces as a named plan-time finding rather than running silently.

GitHub Actions is the canonical deployment path. A push to `main` runs the guarded `deploy-dev.yml` publish-and-apply path. A manual dispatch remains available for reviewed cutovers and recovery. Test and prod promote a pre-built artifact through a frozen digest. See [Relational API immutable release path](https://help.stralos.app/internal/deployment.md#relational-api-immutable-release-path) for the trigger and validation contract.

### Release model

API Gateway integrates with the `live` Lambda alias rather than the bare function ARN. The `live` alias always points at a published numbered version; `$LATEST` is never served.

A release is a pointer flip: `release-alias.yml` moves the `live` alias to a version that is already published without requiring a new Terraform apply or a Lambda build. A rollback is the same operation in reverse, at the same cost.

The `release-alias.yml` workflow accepts two inputs. The `version` input supports a published version number or either of the named values below:

| Input | Values | Behaviour |
| - | - | - |
| `target` | `dev`, `test` | Environment to target |
| `version` | A published version number (e.g. `77`) | Moves `live` to that version |
| `version` | `previous` | Resolves the highest published version below the one currently served and moves `live` there (rollback) |
| `version` | `current` | Reports the version `live` currently serves and makes no change |

`$LATEST` is refused. The alias exists so that `$LATEST` is never what API Gateway serves.

The move step is conditional on the revision ID read before the update. Two concurrent moves cannot both win — the one whose `RevisionId` no longer matches loses. After a successful alias update the workflow probes the edge (`GET /api/activities/version`) up to three times to confirm the new version is reachable through API Gateway.

A Terraform apply re-points `live` to the version it publishes. The alias move is intended for the window between applies.

For a reviewed manual or recovery apply, use `./scripts/deploy.sh` rather than invoking `terraform apply` directly. The Terraform configuration ships the on-disk `deployment-package.zip` verbatim, so the helper rebuilds before applying and avoids deploying stale code.

```bash theme={null}
./scripts/deploy.sh
```

Committed backend configurations are `backend-dev.hcl`, `backend-staging.hcl`, `backend-test.hcl`, and `backend-prod.hcl`. Staging has no committed tfvars file and is not a target of the current deployment workflow.

### Service-discovery parameter

Terraform publishes the regional API Gateway stage URL as a standard SSM String
parameter. Unless `api_url_ssm_parameter` overrides it, the parameter path is:

```text theme={null}
/<project_name>/<environment>/<region_key>/services/activities/api-url
```

The `api_url_ssm_parameter` output exposes the resolved parameter name for peer
stacks. The separate `api_url` output is the raw API Gateway stage invoke URL;
CloudFront uses the `api_hostname` output plus the stage path instead.

## Key environment variables

Each connection string can be supplied either as a plain value or as an AWS SSM
Parameter Store path. All SSM-backed reads go through the shared
`@stralos-co-nz/api-common/aws/ssm` cache.

`src/lambda.ts` calls `preloadApiSsmParameters()` at INIT, before serving, so no
request waits on SSM. It loads every parameter in `API_SSM_PARAMETERS`
(`src/config/ssm-preload.ts`) in batched `GetParameters` calls, retrying throttles
with jittered backoff. A required parameter that cannot be loaded fails the INIT.
`POSTHOG_PROJECT_TOKEN_SSM_PARAMETER` is optional: if it cannot be read, telemetry
is off and the API still starts. An expired value is served from memory while one
background refresh runs. The Lambda role therefore needs both `ssm:GetParameter`
and `ssm:GetParameters`.

A new `*_SSM_PARAMETER` read belongs in `API_SSM_PARAMETERS`.
`test/ssmPreload.unit.test.ts` fails until it is listed, and checks that every
SSM-backed reader answers from memory after a preload with Parameter Store
unreachable.

| Variable | SSM alternative | Purpose |
| - | - | - |
| `DATABASE_URL` | `DATABASE_URL_SSM_PARAMETER` | Owner/migration connection string. Required. |
| `RUNTIME_DATABASE_URL` | `RUNTIME_DATABASE_URL_SSM_PARAMETER` | Runtime (RLS-subject) connection string. Optional; falls back to owner pool when unset. |
| `PLATFORM_DATABASE_URL` | `PLATFORM_DATABASE_URL_SSM_PARAMETER` | Platform (BYPASSRLS) connection string. Optional; falls back to owner pool when unset. |
| `SERVICE_VERSION` | Package version | Semver reported by `GET /api/activities/version` as `version`. |
| `SERVICE_COMMIT` | — | Commit SHA reported by `GET /api/activities/version` as `commit`. Stamped on every Terraform apply via `TF_VAR_service_commit`. Falls back to `"unknown"` when unset. |
| `AUTH_JWKS_URL` | — | JWKS endpoint for JWK-signed tokens from api-global. Required in every environment: the service refuses to start without it. |
| `BILLING_ENTITLEMENTS_TABLE` | — | DynamoDB table name for billing entitlements. Empty string (the default) disables billing enforcement. Set by Terraform when `enable_billing_enforcement = true`. |
| `RATES_API_URL` | `RATES_API_URL_SSM_PARAMETER` | URL of stralos-api-rates for indicative pricing range resolution. There is no localhost fallback. Without either, no line is ranged, totals use stored costs, and a warning is logged once. |
| `CORS_ORIGIN` | — | Comma-separated allowed origins, or `*`. Unset falls back to localhost-only. |
| `POSTHOG_PROJECT_TOKEN` | `POSTHOG_PROJECT_TOKEN_SSM_PARAMETER` | Public PostHog ingestion token. Empty disables PostHog. |
| `POSTHOG_HOST` | — | PostHog ingestion host. Defaults to `https://helpme.stralos.app`. |
| `POSTHOG_REQUESTS_ENABLED` | — | Capture bounded API request completion events. Defaults to `false`. |
| `POSTHOG_ERROR_TRACKING_ENABLED` | — | Capture sanitised HTTP 5xx exceptions in PostHog Error Tracking. Defaults to `false`. |
| `POSTHOG_PAYLOADS_ENABLED` | — | Capture redacted, bounded request and response payloads. The SDK refuses this flag outside dev and test. Defaults to `false`. |
| `TELEMETRY_QUEUE_URL` | — | SQS queue that carries PostHog batches. Set by Terraform from core's `environment-setup` output `telemetry_queue_url`. Empty means telemetry posts directly to PostHog inside the request. |
| `TELEMETRY_AFTER_RESPONSE` | — | Set to `false` to turn off the after-response telemetry extension. Unset leaves it on wherever it can run. |
| `DB_KEEP_IDLE_CONNECTIONS` | — | Leave unset or `false` in Lambda, including behind RDS Proxy, so idle tenant-pinned clients are released after every invocation. Defaults to `false`. |

## Contractor copy

A contractor company can copy its activity 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 and calls two service-only routes in `src/routes/contractorCopy.ts`. The logic is in `src/lib/contractorCopy.ts`.

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

Only the `contractor-copy` service login may call them, with scope `contractor-copy:export` (token organisation is the contractor company) or `contractor-copy:import` (token organisation is the company receiving the rows). Any other caller receives `403 contractor_copy_only`. Each call runs under one organisation's RLS, so rows travel through the orchestrator and no transaction reads one company and writes another.

### Provenance (migration 0023)

Migration `0023_contractor_copy_provenance` adds nullable `copied_from_org_id`, `copied_from_id` and `copied_at` columns to `activities_activities`, `activities_activity_components`, `activities_production_profiles`, `activities_calculators` and `activities_calculator_links`, with a partial index on `(organisation_id, copied_from_org_id, copied_from_id)` per table. The migration is additive and needs no cutover marker. There is no foreign key, because the source row lives under another organisation's RLS.

Provenance lets a retried copy find the rows it already made, and lets a refresh update them in place. A row the company created itself has no provenance.

### Export

The body takes optional `activityIds` (limit the export to these activities), `cursor` and `limit` (default 500, maximum 1,000 activities per page). The response is `{ activities, components, productionProfiles, calculators, calculatorLinks, nextCursor }`: live activities with their live components and production profiles, paged by activity id. Calculators and their links travel on the first page only. Rate sheets, idempotency keys, calculator counters and deleted rows are never exported.

### Import

The body carries `jobId`, `step` (always `"activities.library"`), `mode` (`copy` or `refresh`), optional `dryRun`, `sourceOrgId`, optional `sourceOrgName` and `requestedByUserId`, `idMaps` and `rows` (an export page may be passed verbatim). The body limit is 6 MB. A `sourceOrgId` equal to the caller's own company returns `422 contractor_copy_same_company`.

`dryRun: true` computes the same result and diff without writing. `mode: refresh` updates copied rows whose fields differ.

The response is `{ idMap, counts, skipped, diff, dryRun }`. `idMap` holds this step's `activity`, `activityComponent`, `productionProfile`, `calculator` and `calculatorLink` maps; pass them forward in `idMaps` on later pages. `counts` reports `created`, `matched`, `updated`, `unchanged` and `skipped`.

### Matching and remapping

An activity is matched by provenance first. If none matches and the company already has a live activity with the same `code` that it created itself, the import adopts that activity: it stamps provenance on it, leaves its fields as they are, and counts it as `matched`. If the code belongs to an activity copied from somewhere else, the row is skipped as `code_taken`.

None of the cross-row references is a foreign key, so the import remaps each one:

* `rateItemId` through `idMaps.rateItem` (from the rates step). A line whose rate item was not copied is skipped as `rate_not_copied`.
* `childActivityId`, `parentActivityId` and `defaultActivityId` through this step's activity map.
* `generated_key` and `metadata.calcKey` (`calc:<calculatorId>:<layer>`) through the calculator map.
* `componentRowId` on links through the component map.
* Disposal substance ids in calculator configs through `idMaps.disposalSubstance` (from api-global's step).

An activity referenced from a page but not on it (a refresh sends only the selected activities; a later page names earlier ones) is resolved by provenance, so a parent, child or calculator default is never cleared because its row did not travel this time.

Skip reasons are `activity_not_copied`, `rate_not_copied`, `child_not_copied`, `calculator_not_copied`, `code_taken` and `slot_taken` (a calculator link slot already occupied).

## Retention and offboarding

The retention Lambda (`src/retention-lambda.ts`, handler `retention-lambda.handler`) ships in the same deployment package as the API Lambda. EventBridge invokes it weekly on `cron(0 15 ? * SUN *)`, and it runs with a reserved concurrency of 1. For the cross-service picture, see [Data retention](https://help.stralos.app/internal/data-retention.md).

| Event | Action |
| - | - |
| No `kind` (the weekly EventBridge invocation) | Organisation purge sweep |
| `{ "kind": "erase-user", "userId", "requestId" }` | Per-user erasure report |
| Any other `kind` | Refused |

### Organisation purge sweep

For every organisation that still has api-activities rows, `runOffboardingSweep` asks api-global for the organisation's status:

* **`disabled`:** records the first sighting in `activities_org_offboarding` and purges once that sighting is at least `ORG_OFFBOARDING_PURGE_AFTER_DAYS` old (default `90`).
* **Any other answer** (`active`, `provisioning`, or `missing`): clears any clock, so a reactivated organisation starts again from zero. Nothing is deleted.
* **Lookup failed:** leaves the clock unchanged and records a failure. After every organisation has been attempted, the Lambda throws and the `retention` Errors alarm pages the alerts topic.

`purgeOrganisation` asks api-global again immediately before deleting and refuses unless the answer is `disabled`. Every delete is scoped by `WHERE organisation_id = $1`, which is the only isolation between tenants because the platform role bypasses RLS. It hard-deletes, in one transaction and children before parents: `activities_calculator_links`, `activities_calculators`, `activities_calculator_counters`, `activities_production_profiles`, `activities_activity_components`, `activities_activities`, and `activities_rate_sheets`. Soft-deleted rows are included. `test/orgPurge.test.ts` reads the live schema and fails if any table carrying `organisation_id` is missing from the purge list.

`activities_org_offboarding` (migration `0018_org_offboarding.sql`, additive) holds `organisation_id`, `first_disabled_at` and `purged_at`. RLS is enabled with no policy, so only the platform role can read or write it. The purge does not delete it: `purged_at` is the durable record that the rows were removed.

### Organisation status lookup

The lookup invokes api-global's `mint-for-activities-offboarding` Lambda alias to mint an org-scoped service token, then calls `GET /api/global/service/organisation-status`. A `404` is reported as `missing`. Any other non-2xx response, a mint refusal, or a malformed body is a lookup failure.

### Per-user erasure

api-activities stores no personal data about users. Its only user-linked columns are opaque actor IDs (`deleted_by` on activities, components and production profiles; `created_by` on rate sheets). Names are resolved from api-global at read time, so erasure returns empty counts. The event exists so the support fan-out is uniform.

### Retention environment variables

Set by `terraform/retention.tf` on the retention Lambda only:

| Variable | Purpose |
| - | - |
| `PLATFORM_DATABASE_URL` or `PLATFORM_DATABASE_URL_SSM_PARAMETER` | Required. The `BYPASSRLS` platform role, the only role granted `DELETE` on every org table for this job. |
| `SERVICE_MINT_FUNCTION_ARN` | Required. ARN of api-global's `mint-for-activities-offboarding` alias. |
| `STRALOS_GLOBAL_URL` or `STRALOS_GLOBAL_URL_SSM_PARAMETER` | api-global's base URL for the status read. |
| `ORG_OFFBOARDING_PURGE_AFTER_DAYS` | Days after the first `disabled` sighting before purging. Defaults to `90`; must be a positive integer. |


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