stralos-co-nz/stralos-api-estimate.
Overview
The estimate service owns all project-level estimating data: estimates, sections, takeoff measurements and map sheets, work items, components, subcontractor packages, bid rates, programme, and import jobs. Every read and write is tenant-scoped. Base prefix:/api/estimate
Multiple estimates (PRD 210)
A project can hold more than one tender estimate. One live tender is always the primary — the estimate that opens on project load and thatGET /projects/:projectId/estimate returns. The others are alternative pricing scenarios. Saved versions and the post-award Delivery estimate are also rows of estimate_estimates, told apart by kind. The work shipped in phases:
Schema (migrations 0089, 0090, 0093)
estimate_estimates gains kind (tender, delivery, saved_version), is_primary, outcome (won, not_awarded), hidden_at, hidden_by, source_estimate_id, source_revision_id, source_version_id, signed_off_revision_id, deleted_at and deleted_by. Every existing row became a primary tender through the column defaults (is_primary defaults to true); no UPDATE ran. CHECK constraints enforce the shape of each kind — for example, a Delivery or saved-version row is never primary and has no outcome, and a primary is never hidden.
Partial unique indexes enforce one primary per project (estimate_one_primary_per_project), one won estimate per project (estimate_one_won_per_project), one live Delivery per project (estimate_one_delivery_per_project), and case-insensitive name uniqueness among live, listed estimates (estimate_name_per_project on (organisation_id, project_id, lower(name)), excluding saved versions and deleted rows).
0089 replaced estimate_estimates_project_unique with estimate_estimates_project_tender_unique, which excluded saved versions and soft-deleted rows and kept one live tender per project during phase 1. 0091 re-created it under the same name narrowed to kind = 'tender', so a Delivery estimate could sit beside the won tender. Phase 2’s contract migration 0093 dropped it; that drop is what lets a project hold a second tender. The phase-1 gate that read the index from pg_indexes (409 multi_estimate_contract_pending) went with it.
estimate_work_items and estimate_work_item_components gain nullable source_work_item_id and source_component_id columns: bare UUIDs naming the row a copy came from (the comparison link and match key). No foreign key is declared, because the source row lives in a different estimate.
Suggestion idempotency. 0089 added per-estimate and per-package uniques beside the org-wide ones:
estimate_work_items_source_suggestion_estimate_live_unique—(organisation_id, estimate_id, source_suggestion_id).estimate_bid_rates_source_suggestion_package_live_unique—(organisation_id, package_id, source_suggestion_id).
source_suggestion_id IS NOT NULL AND deleted_at IS NULL. POST /estimates/:id/work-items/accept-suggestion and POST /work-items/:id/accept-bid-suggestion name them as their ON CONFLICT arbiters. 0093 dropped the org-wide pair (estimate_work_items_source_suggestion_live_unique and estimate_subcontract_bid_rates_source_suggestion_live_unique): a duplicate keeps source_suggestion_id, so with them in place every duplicate of an estimate holding an accepted suggestion would fail. The same suggestion can now be accepted into each estimate of a project, and a copied package can take the same bid.
Migration 0090 fixes five identifiers whose names exceeded Postgres’s 63-byte limit, so the catalogue held truncated names that Drizzle never matched. The per-package bid-rate suggestion index (68 characters as declared by 0089) is rebuilt as estimate_bid_rates_source_suggestion_package_live_unique with CREATE INDEX then DROP INDEX — not RENAME TO, which the destructive-migration guard treats as cutover-armed. Five org-composite foreign keys on estimate_work_item_rate_attachments, estimate_programme_dependencies and estimate_variation_preassessments are renamed with RENAME CONSTRAINT to shorter *_org_fk names.
Project estimate routes and the Multiple estimates setting
src/estimates/estimateRoutes.ts registers the routes documented in the Estimate API:
GET /projects/:projectId/estimates— the project’s live tender and Delivery estimates, primary first; hidden estimates only withincludeHidden=true; saved versions never. The response also carriesmultipleEstimatesEnabled.POST /projects/:projectId/estimates— a new, non-primary tender: blank, or adraftcopy of a live tender of this project (from.estimateId) or of a saved version (from.versionId).from.revisionIdreturns422 copy_from_revision_unsupported.PATCH /estimates/:id— rename, unique per project, case-insensitive.POST /estimates/:id/make-primary— refused for hidden, Delivery and deleted estimates. After the commit, it pushes the new primary to api-rates as the project’s default estimate (PUT /estimate-rates/defaults).DELETE /estimates/:id— a soft delete (deleted_at,deleted_by). Refused for the primary (estimate_is_primary), a Delivery estimate, the won estimate, and an estimate with a committed tender revision (estimate_has_revision). A repeatDELETEis a204no-op.
estimate_project_settings (migration 0096), keyed by (organisation_id, project_id) with multiple_estimates_enabled defaulting to false; no row reads as off. The 0096 backfill turned it on for every project that already held more than one live tender, so nothing visible disappeared. api-estimate owns the setting (rather than api-global’s project record) because the backfill reads this database’s estimates. GET/PUT /projects/:projectId/estimate-settings read and write it. POST /projects/:projectId/estimates returns 409 multiple_estimates_disabled while it is off. Turning it off hides the switcher and opens the primary; no estimate is deleted.
POST /projects/:projectId/estimates also returns 409 project_awarded when any live estimate of the project has outcome = 'won': an awarded project’s tender is settled (PRD 210 §8.1), so the route refuses rather than create a tender that could never be edited. A from.estimateId source is read with orgScoped and must be a live tender of the route’s project; anything else returns 404 estimate_not_found.
Project lifecycle lock
Every write that changes the set or primacy of a project’s estimates — create, rename, make primary, delete — takes the project lifecycle advisory lock (lockProject in src/estimates/copyJobs.ts, which calls withProjectLifecycleLock in src/lifecycle/projectLifecycle.ts) before any estimate-level lock. Submit, revise and award take the same lock, so a create or make-primary cannot race a concurrent submit or award. The lock order is always the project lifecycle lock first, then estimate-totals locks in estimate-id order.
Two-service copy job (§9.2)
An estimate’s project rates live instralos-api-rates and its graph lives here; no transaction spans the two. Every copy — duplicate, copy from a saved version, saving a version, and the Delivery copy (estimate_copy_jobs.kind: duplicate, from_version, save_version, delivery) — therefore runs as a job (src/estimates/copyJobs.ts) with its target estimate id fixed before anything is written:
- An
estimate_copy_jobsrow is inserted aspending, with the target id chosen and the job id as the correlation id. - The project is backfilled into api-rates (idempotent). The backfill must finish before the graph copy: api-rates reads an estimate-less project row as belonging to any of the project’s estimates, so a copy made first would let the new estimate read the source’s rate rows. The backfill records the project’s primary as api-rates’ default estimate if none is recorded. What matters is that api-rates records some default, not which: since api-rates’ contract migration (0059) every project row carries its estimate and the copy names the source explicitly, so a default that lags the primary after make-primary or an award is fine. Only a project with no recorded default is refused, with
409 rates_default_estimate_mismatch(the code name predates this rule). The project rates are then copied onto the target id and api-rates’ id maps are stored on the job (rates_copied). api-rates replays the same maps for the same(target, correlation)pair. - One estimate transaction at
repeatable read: the header, the full graph (copyEstimateGraph), the parity assertion, the caller’s own rows (for example theestimate_versionsrow), and the job markeddone. Parity asserts that the copied totals match the source to the cent and that work-item, calculator, acceptance and override counts match; a mismatch throws500 copy_parity_failedand nothing is half-copied. - After the commit, carried rate-exception acceptances are re-fingerprinted through the copy’s id maps (
src/estimates/acceptanceRefingerprint.ts). This is best-effort: a failure leaves the acceptances as copied and the copy stands.
rates_copied skips step 2’s copy. A job nobody finishes — a Lambda that died between steps 2 and 3 — is closed by sweepAbandonedCopyJobs after 24 hours: it is marked failed and its target estimate never appears.
The graph copy is driven by the registry in src/estimates/estimateGraph.ts. Every estimate-owned table and every column it declares must have exactly one copy rule; the completeness gate (test/estimateGraphRegistry.test.ts) fails CI if a table or column is missing or undeclared, so a copy cannot silently drop an entity class.
Orphan discard (phase 2). A failed or abandoned job leaves api-rates rows keyed to a target estimate that will never exist. No read reaches them, but discardFailedCopyRates (src/estimates/copyJobOrphans.ts) removes them. It runs on the platform role inside the 10-minute sweep (src/sweep-lambda.ts) and calls POST /api/rates/estimate-rates/discard as the estimate-rates service login for each failed job whose target estimate does not exist here, up to 25 jobs per run (DISCARD_BATCH). api-rates answers a replay with zero rows and an unknown target with 404 copy_not_found; both stamp estimate_copy_jobs.rates_discarded_at (added by 0093) so the sweep stops revisiting the job. A failed call leaves the job for the next sweep. A target that does exist is never discarded; the job is only marked (outcome target_exists) and logged. The partial index estimate_copy_jobs_discard_pending_idx (state = 'failed' AND rates_discarded_at IS NULL) serves the sweep query.
Saved versions as estimate graphs
From migration 0089, a saved version is a frozensaved_version-kind estimate built by copyEstimateGraph. Its estimate_versions row points at it through version_estimate_id and holds only a money summary in snapshot (schema 3); the full rows live in the version’s own estimate.
Restoring a version (restoreSavedVersion in src/versions/versionRestore.ts) copies the version’s graph back onto the working estimate, then saves the result as the new current version. Two price modes are supported:
keep— rows are copied exactly, with no recompute. Prices match what the version captured.reprice— rows are copied andrecomputeEstimateTotalsruns against today’s rates.
GET /estimates/:id/versions/:version/restore-preview) runs the full restore in a transaction and rolls it back, returning the count of lines whose price would change and the total price delta. Nothing is written. The project rates follow through api-rates’ POST /estimate-rates/restore.
A version saved before 0089 holds only a JSON snapshot (schema 1 or 2) and has version_estimate_id = NULL. GET /estimates/:id/versions returns restorable: false for it; it can be viewed, but a restore or a copy from it returns 409 version_read_only. The JSON build, restore and upcaster code was deleted (api-estimate PR 393), so there is no JSON restore path.
Reading rows from the graph fixed three bugs the JSON restore had: a missing defaultMarginPct, respondedAt serialised as a string, and a tender clause that could take another row’s id. Each is pinned in test/estimateVersionGraph.test.ts.
Quote numbers (migration 0110)
Migration 0110 adds a nullablequote_number varchar(60) to estimate_estimates, estimate_versions and estimate_revisions. A quote number belongs to the individual estimate or version, not the project. api-global allocates every number through POST /api/global/projects/:id/quote-numbers; src/estimates/quoteNumbers.ts holds the client (createQuoteNumberClient, allocateEstimateQuote) and the lazy backfill (ensureEstimateQuote).
The identityId sent to api-global decides which number a row gets. api-global returns the existing number for an identity it has already seen, so every allocation is safe to retry.
A submit records the estimate’s current number on the new
estimate_revisions row. Delivery estimates are never numbered (kind = 'delivery').
Rows created before 0110 are numbered lazily: ensureEstimateQuote runs on GET /estimates/:id, GET /projects/:projectId/estimate, each unnumbered entry of the estimate list, and before submit and accept. GET /estimates/:id/versions numbers unnumbered versions and their version estimates. Each write uses WHERE quote_number IS NULL, so a concurrent backfill keeps the first number.
A user request forwards the caller’s token. A service-token request (for example a lifecycle fan-out) mints a quote-number token bound to the organisation, project and identity (mintQuoteNumberToken) and sends x-replay-of. Any failure, including a 10-second timeout, becomes 502 with “A quote number could not be allocated. Please retry; an existing allocation will be reused.”
Tender revision versions (migration 0109)
Revise tender (user action) and Reopen after Lost (api-global’s service-to-servicereopen) first save the still-frozen estimate as an ordinary saved version, then run the transition. The save lives in src/versions/tenderRevisionVersion.ts and runs before the transition’s transaction, because a version save is its own copy job.
- The version is named
Tender revision N (submitted D MMM YYYY)from the latest committedestimate_revisionsrow. The date uses the project’s effective timezone when the caller is a user, otherwisePacific/Auckland.recomputeis off, so the version carries the submitted totals. - The transition body’s optional
versionName(trimmed, max 200 characters) replaces that default name; blank falls back to it. feature-estimate’s Revise tender side sheet (ReviseTenderSheet.tsx) prefills the default and sends what the estimator leaves. Reopen arrives from api-global, which does not forward a name, so it always takes the default. A version already saved for the same tender revision keeps its name. estimate_versions.tender_revision_idwith the partial unique indexestimate_versions_tender_revision_uniquekeeps one version per tender revision. A replay, a retry after a refused transition, or a concurrent revise reuses the existing version.- An estimate with no committed revision saves nothing and transitions as before.
- If the save fails, nothing is transitioned:
503for revise and422for reopen (so api-global refuses the reopen and shows the message as is), both withcode: "tender_revision_not_saved". The refusal gate insrc/lifecycle/transitionEngine.tsreturns arefusedboolean rather than the reply: a Fastify reply is thenable, soawait reply.send(...)resolves toundefinedand would read as “not refused”, letting the handler carry on into the transition. GET /estimates/:id/versionsitems carrytenderRevisionandtenderRevisionId.GET /estimates/:id/revisionsitems carrytenderPrice(the snapshot header’s contract total, excluding GST),totalCost(the sum of top-level items’ frozen cost) andversion(the linked saved version, or null).
Tender copies and download log
Tender download keeps a sent tender as sent. Migration 0113 adds three tables insrc/db/tenderCopySchema.ts (0115 adds the layout column and 0116 widens the format CHECKs):
estimate_tender_download_options: one row per estimate holding the Tender download tab’s saved choices (sections,documentIds,includeSubItems,includeCostBreakdown,leavePricesOut). A row that no longer parses reads as never saved.estimate_tender_copies: the as-submitted files, one row per revision and format (pdf,xlsx,docx; 0116 addeddocx), stored asbyteawith a SHA-256, the options the file was drawn with and, since 0115, the tender layout. The unique indexestimate_tender_copies_revision_format_uniquemakes inserts idempotent (ON CONFLICT DO NOTHING); rows are never updated.estimate_tender_downloads: every tender file download, with revision,copy(stored,liveordraft), format,pricesLeftOut, who and when.
src/routes/tenderCopyRoutes.ts, all under /api/estimate):
The defaults are every section ticked, sub-items on, cost breakdown off, and prices in.
defaultDocumentIds are the documents in a folder segment named like “Tender documents”, or every document when there is none, never the company’s own pricing (isOwnPricingDocument in src/exports/tenderDownloadOptions.ts).
serveTenderDownload handles every Tender download (the submission PDF, workbook, and Word routes with sections): a draft renders live, and a tender frozen at its latest revision serves that revision’s stored copy unless prices are left out. A revision with no stored copy is drawn from its submitted figures: the estimate while it is still frozen at that revision, otherwise the revision’s saved version, stamped with the recorded submission date and the layout stored with its other files.
A stored copy always has prices in. A successful Submit runs captureSubmittedTenderCopy in the transition route’s onSend hook, before the response goes out. It renders PDF, XLSX, and Word in turn with the caller’s bearer, so a service token cannot drive it. It is fail-soft (a failure is logged and the submit stands) and time-boxed by SUBMIT_CAPTURE_BUDGET_MS (12 seconds): once the budget is spent no further format is started, so a slow capture cannot push the Submit response past API Gateway’s limit. The Tender download tab makes any missing files later through POST …/tender-copy while canCapture is true.
Project rates scoped per estimate
POST /projects/:projectId/project-rate-reprice accepts an optional ?estimateId=. When supplied, the re-price scans only that estimate’s work items and reads its project rates from api-rates with &estimateId=; when omitted, the project’s primary (default) estimate is used. The ProjectRatesChanged consumer passes the estimate id from the event, so each estimate’s copy of a project rate is re-priced independently. Project rates service documents how api-rates stores and resolves the estimate scope.
Multi-estimate project lifecycle (phase 3)
Several tender estimates on one project can be submitted at once. The project status follows the count of submitted tender estimates rather than any one estimate:submitcalls the globalsubmit_tenderpeer only when the count goes from 0 to 1. Later submits find the project already submitted and commit locally.revisecalls the globalrevise_tenderpeer only when the count goes from 1 to 0. Revising while other submitted estimates remain commits locally.
409 transition_in_flight), so two estimates cannot both read a zero count and both call the peer.
Award and hidden alternatives. Mark won names one submitted tender estimate. It becomes the primary with outcome: 'won'; every other live tender estimate gets outcome: 'not_awarded' and hidden_at. When the project holds several tenders, the project route without an estimate id returns 409 estimate_id_required. A hidden estimate:
- keeps its status (
submittedstayssubmitted, as the record of what was tendered); - is read-only — any write returns
409 estimate_frozenwithhidden: true; - is excluded from the project-level fan-out actions.
reject, archive, reopen and unarchive arrive on the project route (POST /projects/:id/estimate/transition) and fan out to the active (non-hidden) tender estimates in one transaction under the project lock:
reject(mark lost) — estimates indraft,revieworsubmitted.archive— every non-archived active estimate.reopenandunarchive— only the estimates the most recentrejectorarchivefan-out moved, so an estimate rejected or archived individually earlier is not revived.
correlationId (<callerCorrelationId>:estimate:<estimateId>); the primary keeps the original id, so the caller’s project-level idempotency is unchanged.
Admin award reversal. admin_set_status away from awarded clears outcome and hidden_at on every tender estimate and retires the Delivery estimate (see Award reversal). It is refused with 409 delivery_signed_off if the project’s Delivery estimate was ever signed off.
Estimate soft delete and retention
DELETE /estimates/:id stamps deleted_at and deleted_by rather than removing the row. The retention Lambda’s purge and the erase-user handler cover estimate_copy_jobs and soft-deleted estimates.
Delivery estimate (PRD 210 phase 4)
The Delivery estimate is a post-award working copy of the won tender. It is created automatically when the project is awarded (via theEstimateAwarded event consumer) and can also be created on demand through the HTTP route. At most one live Delivery estimate exists per project at any time.
All delivery routes answer 404 delivery_disabled while ESTIMATE_DELIVERY_ENABLED is off (the default in test and prod; true in dev). The switch is an SSM parameter (estimate_delivery_enabled in each Terraform tfvars). The API Lambda loads it at cold start with its other SSM parameters (see Required startup configuration) and refreshes an expired value in the background, so a change reaches warm containers within about five minutes without a redeploy. An unreadable parameter always reads as off.
Delivery routes
Routes are in
src/delivery/deliveryRoutes.ts and src/openapi/deliveryDescriptors.ts.
Delivery copy
POST /projects/:projectId/delivery calls createDeliveryForProject (src/delivery/deliveryCopy.ts), which runs the shared copy engine with Delivery-specific overrides inside the copy transaction:
kind = 'delivery', not primary, no outcome,source_estimate_id= won estimate,source_revision_id= won revision.contingency_pct = 0(R17: contingency is not carried into Delivery).- Every rate-exception acceptance in force on the won estimate is re-fingerprinted through the copy’s id maps, so accepted exceptions hold on Delivery.
- A parity assertion after the copy confirms that Σ top-level
total_price, Σtotal_cost, Σ tracking sell, and accepted rate-exception count all match the won revision to the cent. Any mismatch rolls the whole copy back as500 copy_parity_failed.
estimate_one_delivery_per_project (a unique index on live rows) means two racing creates produce one Delivery. The loser returns the winner’s row.
Tracking sell
Each copied work item row that maps to a contract line of the won revision receives atracking_sell_amount (and tracking_sell_unit_rate). Only top-level rows that are not a spread pool carry a tracking sell; sub-items and spread-pool rows are already counted in their parent’s line. Σ tracking sell equals the won revision’s items total (contract_total − contingency) to the cent.
The tracking sell is write-once: migration 0091’s database trigger refuses any later change to a written tracking sell, and refuses any tracking sell on a tender estimate. No recompute, rate change, or edit can move it.
Delivery has no contingency field and no client outputs. The SOQ, tender, and submission export routes refuse a Delivery estimate with 409 delivery_not_client_facing.
Sign-off state machine
Lead status is checked against api-global at decision time using the caller’s own token (
src/delivery/projectLeads.ts). A service token cannot trigger any delivery transition (403 actor_required). If api-global is unavailable, the route returns 503 with Retry-After: 5.
request_sign_off writes a frozen delivery revision (in the same estimate_revisions table, numbered per estimate) under a REPEATABLE READ transaction with totals lock and a fresh recompute. What a lead approves is that revision, not the live rows. A variation appended while the request is open falls after the revision into “variations after sign-off”. Reopening and re-requesting sign-off writes revision n+1; all revisions are kept.
Only one pending sign-off per estimate is allowed at a time (enforced by a partial unique index). A concurrent second request_sign_off is turned into 409.
request_sign_off is refused if any live component has price_choice_status = 'choice_required' (409 price_choice_required).
Delivery edits
Own edits (build-up, quantities, project rates, markup, margin) are allowed only while the Delivery estimate is indraft. in_sign_off and signed_off are frozen for own edits. Approved variation rows may be appended in any status (phase 5).
EstimateAwarded event consumer
src/delivery/awardConsumer.ts subscribes to EstimateAwarded events from the estate EventBridge bus on the estimate-delivery-award SQS queue. When the event arrives:
- With
ESTIMATE_DELIVERY_ENABLEDoff: the event is acknowledged and nothing is created. A later “Create delivery” request against the same project reads the won revision from the active baseline. - With the switch on:
createDeliveryForProjectruns. The copy is idempotent, so a redelivered event finds and returns the existing Delivery.
handleEvents’ dedupe transaction because the copy is multi-service and multi-transaction. Failure returns the record to SQS for redelivery and eventual dead-lettering (alarmed). Infrastructure is in terraform/delivery.tf.
Award reversal
When an admin reverses an award (admin_set_status away from awarded), retireDeliveryForAwardReversal (src/delivery/deliveryReversal.ts) soft-deletes the live Delivery estimate. If the project’s Delivery was ever signed off (any approved sign-off row, or a non-null signedOffRevisionId), reversal is refused with 409 delivery_signed_off.
Estimate list fields
GET /estimates, GET /projects/:projectId/estimate and GET /projects/:projectId/estimates include two fields on every estimate row:
On
GET /projects/:projectId/estimates, each row’s totalPrice and totalCost sum the estimate’s contract lines the same way selectContractLines (src/variations/valuation.ts) selects them: live, top-level, not struck. Rows in the Shared Costs section (is_spreadable) are left out once the pool has been spread (some live row carries spreadable_cost > 0), because their money is already inside the recipients’ totals. A pool that could not be spread is still counted. totalPrice excludes contingency and GST.
Migration 0091
drizzle/0091_delivery_sign_off.sql creates estimate_delivery_sign_offs and the database trigger that enforces write-once tracking sells. It also drops and re-creates estimate_estimates_project_tender_unique under the same name, narrowed to kind = 'tender' rows, in one transaction, so a Delivery estimate can sit beside the won tender before phase 2’s contract migration (0093) drops the index. The migration also adds the nullable tracking-sell columns.
Billing access guard
The auth plugin (src/auth/plugin.ts) registers registerBillingAccessGuard from @stralos-co-nz/api-common/billing after token verification. Service tokens skip the guard. The guard reads the billing table named by BILLING_ENTITLEMENTS_TABLE and is off when that variable is empty. The variable is set only when the enable_billing_enforcement Terraform variable (terraform/billing-entitlements.tf) is true. Its default is false, and each environment’s tfvars file overrides it.
With enforcement on, the Lambda role gets dynamodb:GetItem on the billing table limited to two leading keys:
ORG#<orgId>: the company’s billing entitlement.LINK#<linkId>: the contractor-company link state, read on every request from a contractor person. Without theLINK#*grant the read isAccessDeniedand the guard answers503 billing_unavailable.
plan and via (org, link, name) user-token claims and passes them to the guard; see Auth token architecture. A strict claim schema that drops via would hide the link from the guard and let a contractor person keep access until their token expired. With via parsed, the first request after the link ends returns 403 contractor_access_ended (test/contractorBillingGuard.unit.test.ts).
Required startup configuration
src/lambda.ts calls preloadApiSsmParameters() (src/config/ssm-preload.ts) before it serves any request, so no request waits on SSM. API_SSM_PARAMETERS lists every SSM parameter the API reads: the three database URLs and the global, activities, documents and rates peer URLs are required, and a failure to load one fails Lambda INIT. The ESTIMATE_VARIATIONS_ENABLED and ESTIMATE_DELIVERY_ENABLED rollout switches and the PostHog project token are optional: an unreadable switch reads as off and an unreadable token disables telemetry. An expired value is served while it refreshes in the background. A new *_SSM_PARAMETER read on the request path must be added to the list; test/ssmPreload.unit.test.ts fails until it is.
buildApp (src/app.ts) refuses to start without AUTH_JWKS_URL, in every environment; see Authentication deployment requirements. Tests that call buildApp pass a test-only TEST_JWKS_URL.
The HTTP entrypoints (src/lambda.ts and src/index.ts) also refuse to start without RUNTIME_DATABASE_URL, because project membership enforcement runs under the tenant-runtime role.
Lambda database pools
The Lambda entrypoint (src/lambda.ts) creates three Postgres pools — owner, runtime, and platform — one per configured role. Each pool is bounded by LAMBDA_POOL_BOUNDS from src/db/client.ts:
max: 2— one connection for the request’s transaction, one spare so a nested checkout does not deadlock against itself.connectionTimeoutMillis: 5000— a pool checkout that cannot acquire a slot within 5 seconds throws, which the safe error handler returns as a retryable503withRetry-After: 5.
registerSafeErrorHandler from @stralos-co-nz/api-common/http) catches any unhandled error before it reaches the response body. A pool timeout (pg error code 53300) produces a 503 Service Unavailable with code: "service_unavailable". Any other unhandled error produces 500 Internal Server Error with code: "internal_error". Both responses include a reference that matches the x-request-id header. Raw driver text is never included in the response body.
For cross-service ownership rules, see Service boundaries. For the project lifecycle orchestration protocol, see Project lifecycle orchestration.
Lambda connection pool management
The Lambda handler (src/lambda.ts) creates one connection pool per database URL on container initialisation. Each pool is bounded to max: 2 connections with a connectionTimeoutMillis of 5,000 ms.
Lambda containers are frozen between invocations. Because pg’s idle-eviction timer never fires on a frozen container, idle sockets stay open and consume database connection slots until the container is reaped. At scale this can exhaust the database’s connection limit across many simultaneous warm containers that are doing no active work.
To prevent this, each invocation calls releaseIdleConnectionsAfterInvocation in a finally block before the container freezes. This closes any idle connections the invocation’s pools are holding, so held connections equal in-flight requests rather than total warm containers.
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.
Estimate lazy-create flag
ESTIMATE_LAZY_CREATE (src/config/estimate-lazy-create.ts) controls what GET /projects/:id/estimate does when the project exists but has no estimate yet.
The flag is read per request, so a Terraform change to the
estimate_lazy_create variable takes effect on the next cold start with no code change. Each environment sets it in its own terraform/<env>.tfvars; turn it off for an environment only after that environment’s estimate backfill is verified.
The flag applies only to the GET path. PUT /projects/:id/estimate and the ProjectCreated consumer always create the estimate. See Project estimate for the client-facing responses.
ProjectCreated event consumer
The estimate service subscribes toProjectCreated events published by stralos-api-global on the estate EventBridge bus. When a project is committed in Global, the consumer creates the project’s 1:1 estimate row without calling back to Global.
How it works
The event carries the working day (hoursPerDay) and the organisation’s margin ladder frozen at project creation time. The consumer passes those values directly to ensureProjectEstimate — the same function the HTTP lazy-create and PUT ensure paths use — so the creation logic cannot drift between the two callers.
The event source mapping targets the estimate-project-created SQS subscription queue. The Lambda handler (src/events-lambda.ts) processes each SQS record sequentially, one handleEvents call per record, so each record’s transaction is scoped independently.
Deduplication and atomicity
handleEvents (from api-common) inserts a (consumer, eventId) row into the processed_events table before running the handler. Both the dedupe insert and the estimate insert happen in the same transaction:
- If the insert is new, the handler runs and both writes commit together.
- If the insert conflicts (duplicate delivery or a backfill republish), the handler is skipped. The estimate insert’s
ON CONFLICT DO NOTHINGmeans a second event for the same project is also a no-op. - If the handler throws, both the dedupe row and the estimate insert roll back, and the record is redelivered.
Tenant scope
Each record’s transaction runs on the RLS-subject runtime role (stralos_runtime_estimate) with app.current_org = orgId and app.project_ids = [projectId] set at the start of the transaction, before the dedupe and handler writes. This is the same scope a project-bound service caller receives from resolveProjectScope, so every FORCE RLS policy — tenant_isolation, project_membership, and the project_parent policies on estimate_margin_ladder — applies identically to this write and to a user’s request.
Billing
The billing check (402 guard) is deliberately not applied to the consumer. Creating the 1:1 estimate row is the platform finishing the project’s create, not a user write. Every subsequent write the organisation makes is still gated by the 402 guard.
Local development
pnpm worker:events:local polls EVENT_QUEUE_URL through LocalStack and feeds the same consumer handler. The visibility timeout matches the Lambda’s: 180 seconds (six times the 30-second function timeout, for the SQS/Lambda event-source mapping).
Infrastructure
The subscription, SQS queue, DLQ, event source mapping (withReportBatchItemFailures), and a dedicated least-privilege IAM role are provisioned in terraform/events.tf using the vendored event-consumer module. The rule and queue are created only when the environment’s core stack has published an event_bus_name output; an environment without that output still applies cleanly, but no events are consumed.
The events Lambda shares the same deployment package as the API and sweep Lambdas but uses a separate handler entry point (events-lambda.handler) and a separate IAM role. Its application-specific grants cover the runtime database URL parameter (including decrypt access) and SQS consumption. Standard VPC/logging and X-Ray managed policies also apply. It does not receive the owner or platform database URLs or the api-global mint invoke grants.
ProjectRatesChanged event consumer
The estimate service subscribes toProjectRatesChanged events that stralos-api-rates publishes after a project rate write. The consumer (src/events/projectRatesChanged.ts) re-prices the project’s estimate from current rate state by calling repriceProjectFromCurrentRates (src/pricing/projectRateReprice.ts), the same function behind POST /projects/:projectId/project-rate-reprice.
This is the retry path, not the first attempt. stralos-api-rates still calls the re-price route synchronously after its write commits. If that call already succeeded, the event’s re-price moves nothing, because it is computed from current state.
How it works
The event is thin: it names the project, arateSetVersion, and change hints (rateItemId, rateOptionId, change) but carries no rate values. For each record the consumer:
- Mints an
estimate-repriceservice token for the event’s organisation through api-global’smint-for-estimate-repricealias (src/service-tokens/repriceServiceToken.ts). An event has no user, so the peer reads carry this token instead of a user bearer. - Runs the shared re-price, which reads current state from
stralos-api-ratesandstralos-api-globalwithin the 8-second peer budget and writes only the lines that change.
Deduplication and atomicity
handleEvents deduplicates on (consumer, eventId) only. rateSetVersion is deliberately not a drop-older key: each event’s hints name different items, and a re-price scans only the items it is told about, so dropping an older event would skip its items.
The re-price runs inside the handleEvents transaction, scoped by withProjectTenant to the event’s organisation and project on the runtime role. The dedupe row and the re-price writes commit or roll back together.
Outcomes
The post-write rate-exception recount that the HTTP route runs is not run by the consumer. It is best-effort on the route too, and the next count read self-heals.
Infrastructure
The consumer runs in its ownreprice-events Lambda (src/reprice-events-lambda.ts, provisioned in terraform/reprice-events.tf) on the estimate-project-rates subscription queue, with a batch size of one. It is separate from the ProjectCreated events Lambda so that each has its own role, timeout, and alarms: only the reprice-events role holds the mint-for-estimate-reprice invoke grant and the peer URL parameter reads. It uses only the RLS-subject runtime database URL.
Locally, pnpm worker:events:local polls both subscriptions. It reads the estimate-project-rates queue from EVENT_QUEUE_URL_ESTIMATE_PROJECT_RATES, and warns and runs the ProjectCreated worker alone when that variable is not set.
Supplier and location name sync
Stralos keeps the stored supplier and location names on editable estimate build-up lines current when a company or location is renamed instralos-api-global. Two mechanisms work together: a real-time event consumer and a daily reconcile.
Both write through applyNameChange (src/pricing/nameSync.ts), the single guarded UPDATE that applies one name to every eligible live component row in one statement. The same function is used by both writers so neither can disagree about which lines a name reaches.
Name write guard
applyNameChange updates a component row’s stored name and version only when the stored pair is stale:
- The stored version is NULL (a selection made while api-global was unreachable, or a row that has not yet been through reference fill).
- The stored version is older than the incoming version.
- The stored version equals the incoming version but the stored name differs. This case repairs a name that the reference fill stamped at the current version without writing the name — leaving a wrong or NULL name that looked current to a version-only guard.
updated_at, because a name change moves no money and a bump would fail in-flight compare-and-set saves.
The guard runs only on editable estimates (status draft or review). Frozen estimates and revision snapshots retain the names they held at the time of freezing.
PartyChanged / LocationChanged event consumer
ThePartyChanged and LocationChanged event consumer (src/events/partyLocationChanged.ts) listens to the estate EventBridge bus. When a supplier or location is renamed in api-global, the consumer brings every affected line on every editable estimate for that organisation up to date.
Because a rename names no project, the consumer mints an estimate-name-sync service token for the event’s organisation, reads every project id for that organisation from api-global, then scopes the runtime role to all those projects before running the write. A mint failure or project-ids failure fails the record and re-queues it; an organisation with no projects is acknowledged with no work done.
Infrastructure. The consumer runs in the name-sync-events Lambda (src/name-sync-events-lambda.ts, provisioned in terraform/name-sync-events.tf) on the estimate-party-location-changed subscription queue, batch size one. Its IAM role holds the estimate-name-sync mint alias invoke grant, the runtime database URL parameter, and the api-global URL parameter. It does not hold the owner or platform database URLs, the reprice mint alias, or the api-rates URL.
Daily name reconcile
The daily reconcile (src/pricing/nameSyncReconcile.ts) is a scheduled Lambda that repairs names that the event consumer could not reach: lost or delayed events, lines written while the bus was unavailable, and lines from environments without a bus. It runs on a daily EventBridge schedule and can be invoked by hand for a one-off catch-up.
Per-run steps:
- Discovery — on the BYPASSRLS platform connection, find every organisation that has a live line on an editable estimate carrying a supplier or location reference, or a selection with no scope.
- Reference fill — for editable lines that carry a price selection but no scope (a UI price pick before PRD 211 PR C scoped its own writes), run
fillOrganisationReferencesusing the runtime role and anestimate-repricetoken for the api-rates read. This fill writes scope, ids, and versions but never a name; the repair step in step 4 brings the names. - Read current names — for each organisation, collect the distinct stored
(supplier id, version, name)and(location id, version, name)triples on live lines of editable estimates, then read the current names and versions from api-globalrate-references/resolve-batch. A failure on this read fails that organisation and writes nothing more for it. - Repair — apply every id whose stored pair is stale using the same
applyNameChangeguard the event consumer uses. A non-zeronamesChangedcount on an environment with a live consumer means an event was lost or is still in flight.
name-sync-reconcile Lambda (src/name-sync-reconcile-lambda.ts, provisioned in terraform/name-sync-reconcile.tf), with a 300-second timeout and 512 MB memory. Its IAM role holds both the estimate-name-sync mint alias and the estimate-reprice mint alias (for the reference fill’s api-rates read), the platform and runtime database URL parameters, the api-global URL parameter, and the api-rates URL parameter.
Structured log output. The Lambda writes raw JSON lines to stdout so CloudWatch metric filters can match the msg field:
DocumentUsagesChanged event publisher
The estimate service publishesDocumentUsagesChanged v1 (source stralos.api-estimate) so that the stralos-api-documents usage registry knows which documents each estimate uses. The registry drives drive-sync features such as the purge of fetched drive copies, the removed-from-drive flag, newer-version prompts, post-submission variation hints, and prefetch. The field contract is in Service boundaries.
Each event carries the full current usage set for one estimate (entityId is the estimate id), not a delta. label is the estimate name trimmed to 200 characters, frozen is true once the estimate leaves draft or review, and usageVersion comes from estimate_estimates.document_usage_version. Only live rows count: a soft-deleted measurement, work item, bid, package, or variation uses nothing.
Write sites. Any write that changes one of those sources, or the estimate’s frozen status, must call
enqueueDocumentUsages (src/events/documentUsages.ts) last inside its transaction. It bumps the version under the estimate row lock, reads the set, and enqueues the event to the outbox. The route then publishes it with publishAfterCommit. Writes with no request, such as the lifecycle sweep, leave it for the outbox flusher. test/documentUsagesEvent.test.ts drives each write site, and test/documentUsagesContract.test.ts runs the consumer’s schema against the publisher’s.
Over the cap. A set larger than 5,000 entries is not enqueued, and the write still succeeds. The API logs alert: "document_usages_over_cap", which a metric filter and alarm watch. The registry keeps the last set it applied until a later write brings the set back under the cap. The set is never truncated, because truncation would deregister documents the estimate still uses.
Infrastructure. terraform/document-usage-events.tf grants events:PutEvents on the shared API role, restricted to the stralos.api-estimate source. It also provisions the outbox flusher (src/event-flush-lambda.ts, rate(1 minute), platform database role), a dead-man’s-switch alarm on its schedule, and a backlog alarm on the oldest unsent outbox row. When the environment has no estate bus, the grant is omitted: the API still enqueues every event and skips publishing with a log line.
Soft-delete programme (migration 0067)
Migration 0067 (drizzle/0067_soft_delete.sql) converted ten estimate tables from hard deletes to soft deletes. A DELETE request on any of these tables stamps deleted_at and deleted_by on the row instead of removing it. The affected tables are:
estimate_work_itemsestimate_work_item_componentsestimate_sectionsestimate_takeoff_measurementsestimate_tender_clausesestimate_programme_itemsestimate_programme_dependenciesestimate_variationsestimate_variation_linesestimate_calculators
src/db/live.ts provides one predicate per table (e.g. liveWorkItems(), liveSections()). Every list, aggregate, copy, snapshot, and export read must add the matching predicate. Reads by ID stay unfiltered and return the row with deletedAt set. test/softDeleteLiveReads.unit.test.ts fails the build if a raw read of any of these tables neither calls a live*() predicate nor carries a // soft-delete: explanatory comment.
Set deletes — when a delete removes a set of related rows (a work item with its sub-items, components, and programme header; a variation with its lines; a calculator with its rows), every row in that set receives one shared deletedAt timestamp from deletionStamp(), so the whole set can be identified by that single value.
API behaviour for callers
DELETEreturns204 No Contenton success. A repeatDELETEon an already-deleted row also returns204and changes nothing. The calculator delete is the exception: it keeps its200with counts, and a repeat returns0for both.GETby ID returns the row withdeletedAtset; it is not filtered out.- Any write (
PUT, sub-item, component creation, etc.) to a deleted row returns409 { code: "deleted" }. - List and aggregate endpoints exclude deleted rows; callers see only live rows.
- A work-item delete still detaches its takeoff links and removes it from subcontract package membership. A later restore would not bring either back.
- A section delete still moves its items to Unsectioned (it clears
section_idexplicitly). - Pricing, tag, bid, rate-attachment, calculator-link and baseline rows stay attached to a deleted item; readers look past them.
- Re-accepting a bid suggestion whose target item was deleted returns
409. The bid-ratePUTandDELETEroutes, which also returned409 deletedhere, are retired. - A version restore (
restoreSavedVersioninsrc/versions/versionRestore.ts) stamps every live row of the working copy with one shared stamp, then copies in the rows from the version’s frozen estimate graph. This covers work items, components, sections, tender clauses, takeoff measurements, map sheets, subcontract packages, bid rates, programme items, and programme dependencies. Tag links and pricing rows are derived and are still replaced. A version’s graph holds only live rows and leaves out bids of deleted items. Versions saved before build 0089 have no graph copy and are read-only; a restore attempt returns409 version_read_only. - Stale calculator rows swept by the reconcile are soft-deleted (stamped), not hard-deleted. When a re-resolve or a project-rate re-price retires a managed ticked companion, it stamps the companion and every live row hanging off it (children and companions, to any depth) with one shared stamp, using
liveComponentSubtreeandstampComponentsinsrc/pricing/retiredComponents.ts. A component delete still strips its pricing overrides. - A deleted work item keeps its number, and a later live item may take the same number.
deleted_at IS NULL). A deleted row no longer blocks re-creating its live twin, so the same section title, suggestion, or generated key can be used again after deletion. The tender-clause base_item_id unique was deliberately kept full-table, so a deleted library clause keeps its slot and the sync-on-read cannot resurrect it.
Subcontract packages and bid rates (migration 0071)
Migration 0071 (drizzle/0071_soft_delete_subcontracts.sql) adds nullable deleted_at and deleted_by to estimate_subcontract_packages and estimate_subcontract_bid_rates, with live predicates liveSubcontractPackages() and liveBidRates(). A bid list also adds bidOnLiveWorkItem(), so it shows only bids whose work item is live.
- The bid-rate CRUD routes (
/subcontract-bid-ratesandGET /subcontract-packages/:id/bid-rates) are retired and return404.DELETE /subcontract-bid-rates/:idused to stamp the bid and its mirror component with one stamp and fall the work item’s pricing back to internal. Bids now arrive only through the contractor portal andaccept-bid-suggestion. A bid already stamped deleted is still excluded from the return review and cannot be selected as a pricing source. Theestimate_subcontract_bid_ratestable andcomponents.subcontract_bid_rate_idstay. - There is no package delete route. A package becomes deleted through a version restore, and any bid write under a deleted package returns
409 { code: "deleted" }. - The bid source-suggestion unique index is now live-only, so a deleted bid does not block a fresh accept of the same document suggestion.
estimate_subcontract_packages_token_uniquedeliberately still spans deleted rows: a deleted package keeps its portal token, and no other package can be issued it.
POST /subcontract-packages returns 422 package_due_date_required when dueDate is missing, empty or null, and 400 for a value that is not an ISO date or date-time. On PUT /subcontract-packages/:id, leaving dueDate out keeps the stored date, and an empty or null value returns the same 422 (test/subcontractPackageDueDate.test.ts).
Takeoff map sheets (migration 0073)
estimate_takeoff_map_sheets is soft-deleted from the start (liveTakeoffMapSheets()). Deleting a sheet stamps the sheet only. Its measurements are governed by association: measurement lists add takeoffOnLiveSheet(), so measurements on a deleted sheet drop out without being stamped. See Map takeoff.
Snap attachments
A drawing or map point may carry an optionalattach object inside the points jsonb, validated by takeoffPointAttachSchema in src/routes/estimate.ts:
measurementId names a live measurement or that the index is in range. A host deleted later is simply not followed by the client. A malformed attach fails the whole request with 400 (test/takeoffSnapAttach.test.ts). Points without attach are unchanged, and there is no migration.
On copy, points uses the text column rule in src/estimates/estimateGraph.ts, which rewrites embedded UUIDs through every copy-mode id map. A version save, restore or duplicate therefore re-points attach.measurementId at the copy’s own measurements. Client behaviour is in Estimate frontend: snap attachments.
Map takeoff (migrations 0073 and 0074)
Migration 0073 adds theestimate_takeoff_map_sheets table and geo columns on estimate_takeoff_measurements (coordinate_space, map_sheet_id, measured_at). A geo measurement stores WGS84 points and the server’s own geodesic quantity (src/takeoff/geodesic.ts, src/takeoff/geoMeasurement.ts) with scale = 'geodesic'. A client-supplied quantity is only checked against it.
When a measurement’s quantity changes on PUT, followMeasurementQuantity (src/takeoff/linkedQuantity.ts) moves the linked work item’s quantity in the same transaction if the item was created from the measurement (quantity_origin = 'measurement') and its quantity still equals its source_quantity. An item whose quantity was edited by hand keeps its number. This applies to drawing and map measurements.
Migration 0074 (0074_takeoff_utility_source) adds a nullable source_snapshot jsonb column for measurements created from a council utility feature. The snapshot is validated against utilitySourceSnapshotSchema (src/takeoff/canterburyUtilities.ts) at write time: its provider, layerId and sourceLayer must name a layer in the provider registry, and a canterbury-maps snapshot without sourceLayer still matches by layerId. It is client-submitted and not re-verified against the upstream source. Later point edits do not change it, and version snapshots include it through the full-row snapshot.
Council utilities. src/takeoff/utilityProviders.ts assembles the registry of reviewed public sources from its own entries, src/takeoff/additionalUtilityProviders.ts, src/takeoff/whanganuiUtilities.ts and src/takeoff/approvedCouncilUtilityProviders.ts: 27 providers covering parts of 41 council areas (the provider count is pinned by test/councilUtilities.unit.test.ts). Most sources are ArcGIS REST layers. Whanganui District Council is read through bounded WFS 2.0 requests (src/takeoff/wfsUtilities.ts). Clutha District Council (CC BY 4.0) and Rangitīkei District Council (published with the council’s written permission rather than a Creative Commons licence) are in the approved-council file. Where a layer publishes asset ownership, an owner filter keeps only council-owned assets. Each council area and network belongs to exactly one provider; assertExclusiveUtilityCoverage (src/takeoff/utilityCoverage.ts) fails the registry load on an overlap. Regional feeds filter their upstream queries to fixed council values and reject returned assets outside them. Clients cannot supply URLs or query text, and no source needs an API key. src/takeoff/councilUtilities.ts fetches with stable paging and a 12-second upstream deadline, and caches results per worker for 15 seconds, at most 24 entries or 8 MB, with at most eight distinct upstream requests in flight. The adapters read no estimate data. src/routes/canterburyUtilities.ts registers the provider, features, layer availability and property boundary routes. The legacy GET /takeoff-utilities/canterbury route has been removed; clients use GET /takeoff-utilities/providers and GET /takeoff-utilities/features.
The estimate-council-utilities PostHog flag gates only the feature-estimate overlay. It is not server authorisation: the routes are available to any authenticated caller with an organisation. The earlier estimate-canterbury-utilities-poc flag no longer controls the UI. Deploy the API before the UI, then enable the flag. Adding a provider needs no flag or UI change, because the UI reads the provider catalogue. Route and field details are in the Estimate API reference.
Provisional sum enforcement (migrations 0077 and 0081)
Provisional sum rule (migration 0077)
Migration 0077 (drizzle/0077_provisional_sum_no_build_up.sql) establishes the rule that a provisional sum item carries no build-up, no margin, and no sub-items. Its price and its cost are both its allowance. The unit provisional_sum (symbol PS) and the item_kind value provisional_sum are one fact — an item whose unit is PS is a provisional sum, and the two fields never disagree on a row the service writes. See src/lib/provisionalSum.ts.
The migration has two halves:
- Data half. Every live provisional sum in a draft or review estimate loses what it carried: live components, live sub-items, calculator links and defaults, project-rate attachments, and pricing overrides are removed or soft-deleted. The item’s quantity is forced to 1 and its pricing row is reset to
internal. Frozen estimates are left untouched. - Trigger backstop. Database triggers (
SQLSTATE PS001) refuse any later write that would store a live component, live sub-item, or quantity link on a provisional sum, or convert an item into a provisional sum while it still carries build-up.handleRouteErrormapsPS001to422 provisional_sum_no_build_up. Routes assert this themselves first; the triggers catch the twenty-odd other build-up writers so a missed path fails loudly.
lump_sum kind is retired: a write that sends item_kind: "lump_sum" is stored as measured (retireLumpSum in src/lib/provisionalSum.ts). The lump_sum value remains a valid state in the item_kind check constraint so that reads of frozen estimates with historic lump-sum rows continue to work, and so that an alias rollback to the previous release does not refuse writes.
Legacy row conversion (migration 0081)
Migration 0081 (drizzle/0081_convert_legacy_unit_provisional_sums.sql) fixes rows that migration 0077 left behind. Before 0077, it was possible to import or create a measured item with unit provisional_sum. Migration 0077’s data half did not touch those rows (it targeted item_kind = 'provisional_sum' only), and the routes only convert a row when its unit or kind changes on a PUT — so those rows were stuck in an inconsistent state and no normal estimator action could repair them.
Migration 0081 converts every live measured item whose unit is provisional_sum in an editable (draft or review) estimate into a proper provisional sum. The conversion follows the same sequence as 0077’s data half and the stripProvisionalSumBuildUp route helper:
- Sub-items (with their programme subtree, measurements, package membership, and components) are soft-deleted first, while the parent still qualifies.
- The parent’s live components are soft-deleted.
- Calculator links and defaults pointed at the parent are removed.
- Project-rate attachments are removed.
- The pricing row is reset to
internalwith no overrides. - Last, the item’s
item_kindflips toprovisional_sumwithallowance = old total_price,quantity = 1,cost = price = allowance, andquantity_link = NULL. The kind flips last so 0077’s trigger cannot fire while the row still carries build-up.
total_price, which already includes any sub-item totals, so the item’s price does not change. Its cost becomes the allowance; section and estimate cost aggregates self-correct at the estimate’s next recompute.
Retention Lambda
The retention Lambda (src/retention-lambda.ts, provisioned in terraform/retention.tf) runs the weekly organisation purge on an EventBridge schedule and handles the support-run erase-user event. It never serves HTTP and runs only on the platform database connection.
- Org purge. An organisation that api-global reports as disabled for at least
ORG_OFFBOARDING_PURGE_AFTER_DAYS(default 90) has all of its estimate data hard-deleted, including soft-deleted rows and frozen estimates. Status is checked through theestimate-offboardingservice token and re-checked before deleting. The clock lives inestimate_org_offboarding(migration 0070), whosepurged_atrecords that the purge ran. erase-user. api-estimate stores user ids only as audit references, with no names, emails, or phone numbers, so the handler returns empty counts.
retention Errors alarm. For the estate-wide design and the per-service table, see Retention Lambdas by service.
Build-up lines in an activity
Every new build-up line is meant to sit inside an activity.src/routes/lineActivity.ts (insertLineActivity) adds a manual activity named after the line in the same transaction as the line, shaped so the line’s money is unchanged:
- A time-unit line (hour, day, week, month) wraps by its own duration, as
POST /work-items/:id/time-resource-activitydoes. An hourly line wraps into a per-day activity at the estimate’s hours per day. - Any other line wraps into a one-day activity with daily production equal to the item quantity (or 1), and the line inside is charged
whole_activity.
POST /work-item-componentsandPOST /work-items/:id/day-rate-linestake an optionalwrapInActivity: trueat the top level. With it, aquantityBasisis422 wrap_quantity_basis_not_allowed, and on components a delivery link is422 wrap_delivery_link_not_allowed. The component response carriesactivityContainer(null when not wrapped). The flag is opt-in on the API; feature-estimate sends it for every new top-level line from the quick-add row, the line editor and a day-rate pick (WorkItemsPanel.tsx,dayRateLines.ts). Edits and lines added inside an activity never carry it. A new time-based labour or plant rate still goes throughtime-resource-activity.POST /estimates/:estimateId/work-items/insert-project-rate(src/routes/projectRateRoutes.ts): a target namingparentComponentIdadds the line at the end of that activity (library activities included; the line inherits itsactivityId). Onlytotalis accepted there (422 insert_per_unit_in_activity); the line is chargedwhole_activityin an activity with daily production, otherwise stored per unit of the item so its total is the typed quantity. WithoutparentComponentIdthe line goes into a new activity named after the rate, aper_unitquantity becoming quantity × the item’s quantity. Other refusals:insert_target_not_activity,insert_activity_deleted,insert_fixed_price_package,insert_zero_quantity_item.- Calculators always wrap. A new default destination, or a new layer link, that names a work item without an activity creates one activity per calculator per item, named after the calculator. Links and defaults that already sat on the bare item are left.
POST /work-items/:id/components/:componentId/move (src/routes/componentMoveRoutes.ts) is the only checked re-parent; the generic PUT still strips parentComponentId. Same work item only. A submitted estimate answers 409 estimate_frozen. Plain 422 refusals include move_cycle, move_target_not_activity, move_companion_line, move_activity_top_level, move_needs_activity, move_calculator_line, move_fixed_price_package, move_delivery_linked, move_linked_quantity and move_zero_quantity_item. A library activity is not refused: a line may move into or out of one, taking the new activity’s activityId, while a moved activity keeps its own. A time-unit line moved into a production-rate activity is stored per day so days multiply it; every other move keeps the line’s total quantity. The response carries quantityChanged and the item’s totals before and after. feature-estimate calls it from the build-up’s drag grip and the Move to activity… row menu (moveLineToActivity in WorkItemDetailPane.tsx), always appending at the end of the target; it offers no move on a read-only estimate, an activity row, a calculator line or a companion charge, and shows refusals in the server’s words.
Pricing route error handling
Pricing routes —re-resolve-prices, project rate routes, rate exceptions, rule change, and plant hire prices — use handlePricingRouteError (src/lib/http.ts) instead of the standard route error handler.
When the database has no free connection slot (including pg-pool’s own checkout timeout), the pricing error handler returns 503 Service Unavailable with a Retry-After header and code: "service_unavailable". This lets clients distinguish transient database saturation from a permanent pricing failure.
All other unhandled errors on pricing routes continue to return 500 with code: "pricing_resolution_failed".
The fan-out that reads rate data from peer services (src/pricing/reResolveFanout.ts) retries a 503 response up to twice before giving up, with delays of 250 ms and 750 ms. Retries are bounded by the caller’s peer budget and are never attempted past the budget deadline.
Price choice status on materialised lines
When an activity is materialised (POST /api/estimate/work-items/:id/materialise-activity), the estimate service prices each inserted leaf component using the project’s default option at insert time — applying the same preference ladder used for any component (project override, rate rules, pricing location, cheapest eligible option). Source pins from the recipe are not carried through as a hard pin on the materialised line.
A component for which the project cannot resolve a default price receives a price_choice_status of 'choice_required'.
Database columns
Three nullable columns onestimate_work_item_components persist the choice state:
A DB
CHECK pair (estimate_components_price_choice_pair_check) ensures the count and suggested id are always NULL when the status is NULL. All three columns are additive and nullable; existing rows are unaffected (every pre-migration row keeps NULL in all three).
Materialise response
The materialisation response includes apriceChoices array alongside the leaves array. Entries are in the same order as the leaves and each carries:
status—'pinned','single_option','choice_required','no_options', or'unavailable'.candidateOptionCount— how many live options were available when the status was assessed (set on'choice_required'and'no_options'; null for other statuses).suggestedRateOptionId— the id of the suggested option when exactly one option is at the project location and status is'choice_required'; null otherwise.
priceChoices array is outside the plan hash: the hash covers recipe fidelity, not the live state of the rates catalogue. Adding or removing an option between plan preview and materialise confirmation does not invalidate the hash.
Materialisation: null unit cost for lines needing a choice (D10)
When the materialise planner builds the plan preview, any leaf whosepriceChoices entry has status === 'choice_required' has its unitCost set to null in the preview response.
The same logic applies at write time: a materialised component with price_choice_status = 'choice_required' is stored with unit_cost = 0 and the flag set. The 0 represents “not yet priced”, not a $0 unit cost. When the project resolves a default option, the line is inserted without a choice flag. Lines the server cannot resolve, and legacy unresolved lines, still require an explicit choice.
A line from a library activity that was saved while it had no unit cost also receives choice_required during materialisation. Its stored null is interpreted the same way — the line needs a choice regardless of what the catalogue now says.
Tender submission gate (D10)
POST /api/estimate/projects/:projectId/estimate/transition (action submit) checks for open price choices before reserving a revision or recomputing totals. If any live component on the estimate has price_choice_status = 'choice_required', the transition is refused with:
- Status:
422 Unprocessable Entity - Code:
price_choice_required - Message:
This tender can't be submitted: N line(s) still need a price choice. Choose a price for: <item number item name: component name>; ... - Body:
{ lines: [{ componentId, componentName, workItemId, workItemNumber, workItemName }] }— live rows only, ordered by work item sort order then component sort order.
Activity promotion: no unit cost for unresolved choice lines (D10)
POST /api/estimate/work-items/:id/promote-to-activity stores null as the unit_cost on any library draft row whose source component has price_choice_status = 'choice_required' and a rate_item_id. A stored null in the library recipe is the defined representation of “this line needs a choice at materialise time”; it is not a $0 price and is not written as 0.
Lines with no rate_item_id (ad-hoc rows) keep their base stored unit_cost unchanged.
Write-path behaviour
PUT /api/estimate/work-item-components/:id/pricing-rule refuses a selection whose provenance is 'auto' when the component’s price_choice_status is 'choice_required'. The response is 422 Unprocessable Entity with code price_choice_required and the message:
unitCost to a new value, sets rateOptionId, or changes rateItemId — clears all three columns atomically in the same UPDATE statement (stored as the PRICE_CHOICE_CLEARED constant).
The PUT /api/estimate/work-item-components/:id general edit route also clears the choice when the write carries a new unitCost, a rateOptionId, or a changed rateItemId. An echoed, unchanged unitCost (which the full-form editor always sends) does not clear the flag.
Price-selection columns and value overrides (PRD 211, migrations 0086–0095)
A build-up line’s price selection used to live in thecomponent_overrides JSON on estimate_work_item_pricing. PRD 211 moved it onto the component row in four releases:
Drop of work_items.pricing_source (migration 0086)
Migration 0086 drops the pricing_source column from estimate_work_items. The column was a transition scaffold introduced in a previous release; every reader already fell back to the estimate_work_item_pricing row, no writer remained, and a parity audit found zero rows across all environments where the old and new effective values differed. The column was already absent from the Drizzle schema and the tender snapshot before this migration ran.
baselineFromRevision still reads a pricingSource token from any frozen revision that recorded it; the drop affects only the live table, not stored revision bytes.
Price-selection columns (migration 0087)
Migration 0087 is additive. It adds seventeen nullable columns toestimate_work_item_components and creates a new estimate_component_value_overrides table (see below).
Selection columns on estimate_work_item_components:
Manual value overrides table (estimate_component_value_overrides)
Migration 0087 also creates estimate_component_value_overrides. Each row records a single field override on a build-up line: the field name, the overridden value, the original value and its source, and the author and timestamp.
Overridable fields: name, category, quantity, resource_count, unit, unit_cost.
The table is soft-deleted. A live unique index (estimate_component_value_overrides_active_field_unique) enforces at most one active override per (organisation_id, component_id, field). Setting a field back to its original value soft-deletes the override row (reverts it); the history remains.
original_source records what the original was from:
PUT /work-item-components/:id/value-overrides route
PUT /api/estimate/work-item-components/:id/value-overrides records manual field edits for a build-up line. The public contract is in the Estimate API.
Request body: a subset of { name, category, quantity, resourceCount, unit, unitCost }, or { clear: true }. At least one field is required unless clear is set. clear: true reverts every active override and accepts no field values.
Behaviour:
- The server diffs each supplied field against the line’s current original. Only fields that differ from the original are stored as override rows. A field supplied with its current original value is a no-op for that field.
- A quantity override on an activity-instance child is refused with
422. - A
unitorunitCostoverride on a line with a frozen pricing selection is refused with422. - The estimate is recomputed after every successful write.
{ componentId, valueOverrides: [{ id, field, value, original, originalSource, overriddenBy, overriddenAt }] } — the line’s full set of active overrides after the write.
Error codes: 400 (validation), 404 (component not found), 409 (estimate frozen or component deleted), 422 (instance child quantity, frozen selection unit/cost, or managed companion).
Saved versions and tender snapshots
Saved versions have been estimate graphs since migration 0089 (see Saved versions as estimate graphs), so a version carries the selection columns and value-override rows as ordinary copied rows. The schema-2 JSON version format and its schema-1 upcaster were deleted with the rest of the JSON version code; a JSON version is view-only. The tender snapshot followed the same steps:- v32 (PR A, 0087) — component nodes carry the selection columns, and each work-item node gains
componentValueOverrides, the item’s active manual value overrides. - v33 (PR C, 0094) — the pricing node loses
componentOverrides, and the component node’srateOptionIdis the generated pin. - v34 (migration 0097) — component nodes carry the day-rate provenance columns.
test/snapshotTolerance.test.ts). The componentOverrides held by v1–v32 revisions is history; nothing reads it.
Tender check
api-estimate owns tender check runs and findings (migration 0102:estimate_tender_checks and estimate_tender_check_findings), the caps, the estimate snapshot and the rule signals, all in src/tenderCheck/. api-documents runs the recommend and review jobs; api-estimate calls it through BC-15 (see Sending contracts). Tender check documents the routes, run lifecycle, caps, document purposes, Rates Library import files and snapshot.
Backend contract tests
Backend callers —stralos-api-assistant and stralos-api-global — call estimate endpoints for reads and lifecycle transitions. The contract test suite pins the exact request validation and response JSON those callers rely on.
Tests live in test/requests.backendContract.unit.test.ts and use the *.backendContract.unit.test.ts suffix. Run them with:
Test boundaries
Route tests register production Fastify route handlers and execute actual validation, error mapping, and JSON serialisation. Only the persistence boundary is simulated. Fixtures are not prebuilt HTTP responses. Request-schema tests pin validation rules only and do not claim to exercise successful writes. SQL filtering, RLS, JWT signature verification, and distributed workflows remain the responsibility of the existing integration and security suites. The suite does not validate a sibling repository’s current client implementation or automatically trigger its CI.Covered contracts
Sending contracts
The estimate service also acts as a sender: it callsstralos-api-rates for pricing data, stralos-api-global for geography, organisation settings, and lifecycle transitions, and stralos-api-documents for tender check jobs.
BC-08: Pricing fan-out (estimate → rates)
Requests:GET /api/rates/rate-items; GET /api/rates/rate-items/:id; option, companion, and project override reads.
Implementation: src/pricing/reResolveFanout.ts, src/pricing/rateOptionCompanions.ts.
Sender tests: test/reResolvePrices.test.ts, test/helpers/reResolveStub.ts. DB-backed tests use a simulated HTTP peer and check bearer forwarding, numeric and metadata validation, pagination failure, and 502 handling.
Receiver notes: stralos-api-rates BACKEND_CONTRACTS.md.
BC-09: Pricing geography and organisation settings (estimate → global)
Requests:GET /api/global/projects/:id/location-routes?locationIds; GET /api/global/organisation-settings; project context reads.
Implementation: src/pricing/reResolveFanout.ts.
Sender tests: test/reResolvePrices.test.ts, test/helpers/reResolveStub.ts. The pricing integration suite supplies Global fixtures and verifies selected peer forwarding and failure behaviour.
Receiver notes: stralos-api-global BACKEND_CONTRACTS.md.
BC-14: Project lifecycle transition (estimate → global)
Requests:POST /api/global/internal/service-tokens; POST /api/global/projects/:projectId/transition.
Implementation: src/lifecycle/projectTransitionClient.ts.
Sender tests: test/estimateLifecycle.test.ts, test/lifecycleSweep.test.ts. Lifecycle orchestration tests exist but inject a ProjectTransitionClient stub, so they do not prove the wire contract.
Receiver notes: stralos-api-global BACKEND_CONTRACTS.md.
BC-15: Tender check jobs (estimate → documents)
Requests:POST /api/documents/tender-check-recommendations (estimateId, projectId, up to 500 usedDocuments); POST /api/documents/tender-check-reviews with the run’s frozen review request (estimateId, projectId, runId, up to 60 documentIds, usedDocuments, a snapshot of at most 150,000 JSON characters, signals, contractForm, and importFiles only when import files were chosen); GET /api/documents/tender-check-jobs/:id; POST /api/documents/tender-check-jobs/:id/start-now; POST /api/documents/tender-check-jobs/:id/cancel. Both job posts return 202 { jobId }. Supporting peer reads, each of which degrades rather than failing the check: GET /api/global/projects/:id/contract-profile, GET /api/global/projects/:id, GET /api/rates/project-rates, POST /api/rates/rate-import-files/lookup and GET /api/rates/rate-import-files/:id/text. Every call forwards the caller’s Authorization header.
Implementation: src/tenderCheck/documentsClient.ts, src/tenderCheck/routes.ts, src/tenderCheck/supplierTerms.ts, src/tenderCheck/importFiles.ts.
Sender tests: test/tenderCheck.test.ts ([backend-contract] BC-15), test/tenderCheckSignals.unit.test.ts, test/tenderCheckSupplierTerms.unit.test.ts, test/tenderCheckImportFiles.unit.test.ts. The database suite stubs api-documents at the fetch layer with strict mirrors of its recommend and review request schemas, asserts that the review request equals the run’s stored inputs, and drives the job through its outcome states.
Coverage limits: The mirror schemas are maintained by hand against api-documents.
Receiving contracts
The following services call the estimate API as backend callers.BC-02: stralos-api-assistant → stralos-api-estimate
Requests:GET /api/estimate/estimates; GET /api/estimate/estimates/:id; GET /api/estimate/estimates/:id/sections; work-item and variation draft writes.
Sender tests: stralos-api-assistant/test/readTools.test.ts, stralos-api-assistant/test/drafts-unit.test.ts, stralos-api-assistant/test/drafts.test.ts. Registry and path-resolution unit checks and DB-backed draft tests cover selected write URLs, request bodies, forwarded authorisation, and returned record IDs.
Coverage limits: The read-tool registry checks are not comprehensive HTTP request and response assertions for estimate reads. The receiver suite covers estimate and section reads and the estimate-create input schema, not the work-item and variation write response contracts.
Sender notes: stralos-api-assistant SENDING_CONTRACTS.md.
BC-13: stralos-api-global → stralos-api-estimate
Requests:POST /api/estimate/projects/:projectId/estimate/transition.
Sender tests: stralos-api-global/test/estimateTransitionClient.unit.test.ts. A dedicated sender client unit suite covers transition dispatch, forwarding, and upstream failure handling.
Coverage limits: This endpoint is outside the new receiver backend-contract suite. Existing estimate lifecycle integration tests provide separate receiver behaviour coverage.
Sender notes: stralos-api-global SENDING_CONTRACTS.md.
Frozen estimate writes
Every user write is gated byassertEstimateEditableById (or assertEstimateNotFrozen for a caller that already holds the estimate row). The rule lives in one place, freezeRefusal in src/lifecycle/estimateFrozen.ts, so a new frozen state is one case there and every writer follows. There is no single choke point: the lock and recompute helpers are also on the submit path, so the assert is per handler.
A deleted estimate is refused with
409 deleted before the kind check.
A frozen estimate returns 409 with code: "estimate_frozen" and a status field carrying the actual state token (plus hidden: true for a not-awarded tender, or kind for a saved version). estimateFrozenMessage words the message as the screens do, and as api-rates words its own refusal:
The gate covers every field on
PUT /estimates/:id (not only the repricing fields), section create, update, and delete, and the rate-exception acceptance, acceptance delete, and band-override clear routes, as well as the totals-affecting writers.
Rename exception: assertEstimateRenamableById gates a PUT /estimates/:id whose body is only name; it refuses only a deleted estimate or a saved version. PATCH /estimates/:id does no freeze check: readEstimate already returns 404 for a saved version. A PUT carrying name with any other field goes through assertEstimateEditableById as a whole.
Lifecycle transitions, variation writes, and version creates are not gated; those are the supported ways to act on a submitted job. test/frozenEstimateEveryWrite.test.ts pins each gated route.
Adding new contracts
Add a new[backend-contract]-tagged test for each endpoint that gains a backend caller. For each endpoint, cover:
- A valid request with a representative response.
- Required and optional parameters.
- Wrong types and out-of-bounds values.
- Status codes.
- Non-empty response data.
- Null and omitted fields.
- Error response shapes.

