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:
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:
requireOrgMemberguards adding and editing: activities (includingPOST /activities/from-components), components, production profiles (including their deletes) and library calculators. It accepts a user token whose role ismemberoradmin. An org-bearing service token renders role""and receives403.requireOrgAdminguardsDELETE /activities/:idand every rate-sheet mutation. A member receives403.
contractor-copy service login has its own gate on its two routes; see Contractor copy.
Billing access guard
After JWT verification, the auth plugin parses the optionalbilling 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:
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— readsSERVICE_VERSIONand falls back topackage.jsonwhen that variable is absent.commit— readsSERVICE_COMMITand 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 aRouteDescriptor 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 bymakeLiteralSearchFilter 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:
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), 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)
Migration0010 adds the package pricing mode and optional-item columns required for PRD 03 M1.
Activity pricing mode
Two new column groups are added toactivities_activities:
Pricing mode:
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.
pricing_mode = 'fixed_price'):
CHECK constraints on activities:
Component optionality
Two new columns are added toactivities_activity_components:
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.
Migration hardening
All new constraints are created withNOT 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
nullclears 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 readPOST /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 nullablerateGroup 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
nullor omission leaves the columnNULL. - On update, explicit
nullclears the stored group and omission preserves it. - An invalid token returns
400 Bad Request; a direct-SQL out-of-range write is rejected by theCHECKas a23514.
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)
Migration0008 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 NOTICEs 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
Migration0020 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:
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 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:
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
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:
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
- SQL filtering and RLS enforcement (existing integration and security suites own these)
- JWT signature verification
- Distributed workflow behaviour
- Sibling repository client implementations
Covered contracts
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:- Use the same contract ID in the sender and receiver notes and associated test descriptions.
- 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.
- 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.
- Review and run both repositories’ relevant suites and update both notes. Preserve the existing integration and security checks.
[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
Soft delete
Migration0016 (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
Idempotency
A repeat DELETE on an already-deleted row returns204 and changes nothing. The stamp is not re-applied.
Writes to deleted rows
Any write to a deleted activity, component, or profile returns409 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. ThefindActivityNameHolder 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 adeleted_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’sdefaultActivityId 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. Thegenerated_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 byprojectReconcile.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 withtype: "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 (prefixedm:). 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 apit-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 —
seedLinksForDefaultskips any layerisBulkDigLayerreturnstruefor, 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 —
reconcileProjectCalculatornever inserts a row for a bulk-dig link, and removes any such row left by an older save.
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.
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.
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. Migration0011 adds the activities_rate_sheets table.
Data model
Theactivities_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 sharedrunScoped + 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
TheadjustedSellAmount 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:
centsToAmount. An adjusted amount that exceeds Number.MAX_SAFE_INTEGER returns 422 Unprocessable Entity.
Activity eligibility
TheassertEligibleActivities 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 copieslines, 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)
Migration0012 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 madeNOT NULL; the migration is additive-online and requires no cutover marker.
activities_calculators — one row per calculator in the organisation’s library:
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:
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:
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:
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:
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
Theconfig 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:
- Reads the current register (after writing it, within the same transaction).
- Calls the calculator engine twice — once for layer shape (which outputs each layer exposes), once with allocations projected in for quantities.
- Computes the desired set of
(generated_key, quantity, unit, name)triples across all destination activities. - 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.
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 akey_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 adefaultActivityId, 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.
The seeded output key depends on the layer kind:
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
Migration0017 (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
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 runsdb: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. Follow 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 CONCURRENTLYwork inscripts/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:
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 isnodejs24.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:
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 = notBreachingso 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.
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 for the trigger and validation contract.
Release model
API Gateway integrates with thelive 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:
$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.
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. Unlessapi_url_ssm_parameter overrides it, the parameter path is:
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.
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 insrc/routes/contractorCopy.ts. The logic is in src/lib/contractorCopy.ts.
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)
Migration0023_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 optionalactivityIds (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 carriesjobId, 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 samecode 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:
rateItemIdthroughidMaps.rateItem(from the rates step). A line whose rate item was not copied is skipped asrate_not_copied.childActivityId,parentActivityIdanddefaultActivityIdthrough this step’s activity map.generated_keyandmetadata.calcKey(calc:<calculatorId>:<layer>) through the calculator map.componentRowIdon links through the component map.- Disposal substance ids in calculator configs through
idMaps.disposalSubstance(from api-global’s step).
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.
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 inactivities_org_offboardingand purges once that sighting is at leastORG_OFFBOARDING_PURGE_AFTER_DAYSold (default90).- Any other answer (
active,provisioning, ormissing): 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
retentionErrors 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’smint-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 byterraform/retention.tf on the retention Lambda only:

