> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Estimate service

> Internal architecture and backend contract testing for stralos-api-estimate.

Source repository: `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 that `GET /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:

| Phase | Migration | What it did |
| - | - | - |
| 1 — expand | 0089, 0090 | Estimate kinds and award columns, copy jobs, saved versions as graphs, per-estimate suggestion uniques. One live tender per project still enforced. |
| 2 — contract | 0093 | Drops the one-tender-per-project and org-wide suggestion uniques, so a project can hold any number of tender estimates. Adds the copy-job orphan discard. |
| 3 — lifecycle | — | Many submits per project; award names one estimate and hides the rest. |
| 4 — Delivery | 0091 | Delivery estimate, tracking sell, sign-off. See [Delivery estimate](#delivery-estimate-prd-210-phase-4). |
| Project setting | 0096 | The project's **Multiple estimates** setting, replacing the PostHog `multi-estimates` flag. |

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

Both are partial on `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](https://help.stralos.app/api/estimate.md#project-estimates):

* `GET /projects/:projectId/estimates` — the project's live tender and Delivery estimates, primary first; hidden estimates only with `includeHidden=true`; saved versions never. The response also carries `multipleEstimatesEnabled`.
* `POST /projects/:projectId/estimates` — a new, non-primary tender: blank, or a `draft` copy of a live tender of this project (`from.estimateId`) or of a saved version (`from.versionId`). `from.revisionId` returns `422 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 repeat `DELETE` is a `204` no-op.

The **Multiple estimates** setting lives in `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 in `stralos-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:

1. An `estimate_copy_jobs` row is inserted as `pending`, with the target id chosen and the job id as the correlation id.
2. 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.
3. One estimate transaction at `repeatable read`: the header, the full graph (`copyEstimateGraph`), the parity assertion, the caller's own rows (for example the `estimate_versions` row), and the job marked `done`. Parity asserts that the copied totals match the source to the cent and that work-item, calculator, acceptance and override counts match; a mismatch throws `500 copy_parity_failed` and nothing is half-copied.
4. 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.

A retry of the same job resumes from its state: a job at `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 frozen `saved_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 and `recomputeEstimateTotals` runs against today's rates.

A restore preview (`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 nullable `quote_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.

| Row | `identityId` |
| - | - |
| The project's original primary estimate (`isPrimary`, no `sourceEstimateId`, `workingVersion = 0`), including the one created from `ProjectCreated` or the lazy create | The project id, so it shares the project's own quote number |
| Any other new or copied tender estimate | The new estimate's id |
| A saved version | `versionEstimateId` when set, otherwise the version row's id |
| The working estimate after **Revise tender** | The transition's `correlationId` |
| Estimates reopened after Lost | `quoteOperationIdentity(correlationId, estimateId)`, a UUIDv5, so a replayed fan-out gets the same numbers |

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-service `reopen`) 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 committed `estimate_revisions` row. The date uses the project's effective timezone when the caller is a user, otherwise `Pacific/Auckland`. `recompute` is 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_id` with the partial unique index `estimate_versions_tender_revision_unique` keeps 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: `503` for revise and `422` for reopen (so api-global refuses the reopen and shows the message as is), both with `code: "tender_revision_not_saved"`. The refusal gate in `src/lifecycle/transitionEngine.ts` returns a `refused` boolean rather than the reply: a Fastify reply is thenable, so `await reply.send(...)` resolves to `undefined` and would read as "not refused", letting the handler carry on into the transition.
* `GET /estimates/:id/versions` items carry `tenderRevision` and `tenderRevisionId`. `GET /estimates/:id/revisions` items carry `tenderPrice` (the snapshot header's contract total, excluding GST), `totalCost` (the sum of top-level items' frozen cost) and `version` (the linked saved version, or null).

### Tender copies and download log

Tender download keeps a sent tender as sent. Migration 0113 adds three tables in `src/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 added `docx`), stored as `bytea` with a SHA-256, the options the file was drawn with and, since 0115, the tender layout. The unique index `estimate_tender_copies_revision_format_unique` makes inserts idempotent (`ON CONFLICT DO NOTHING`); rows are never updated.
* `estimate_tender_downloads`: every tender file download, with revision, `copy` (`stored`, `live` or `draft`), format, `pricesLeftOut`, who and when.

Routes (`src/routes/tenderCopyRoutes.ts`, all under `/api/estimate`):

| Method and path | Purpose |
| - | - |
| `GET /estimates/:id/tender-download/options` | The saved choices, or the defaults. Also returns `saved`, `locked` (the estimate is not editable), `defaultDocumentIds`, and `ownPricingDocumentIds`. |
| `PUT /estimates/:id/tender-download/options` | Save the choices (the whole strict body). `409 estimate_frozen` once the tender is not editable. |
| `POST /estimates/:id/revisions/:revision/tender-copy` | Make and store the missing files for a committed revision: `201` when files were made, `200` when all were already stored. `409 tender_copy_too_late` once the estimate is no longer frozen at that, its latest, revision. |
| `GET /estimates/:id/revisions/:revision/tender-copy` | What is stored: `stored`, `canCapture`, `files` (format, file name, size, SHA-256), the options, and who made it. |
| `GET /estimates/:id/revisions/:revision/tender-copy/:format` | A submitted revision's file. Serves the stored file (`X-Tender-Copy: stored`), or draws one from the submitted figures (`X-Tender-Copy: live`). `X-Tender-Revision` names the revision. Logged. |
| `GET /estimates/:id/tender-downloads` | The download log, newest first, at most 200 (`truncated` when more). Optional `revision` filter. |
| `GET /estimates/:id/tender-readiness` | The server facts for Ready to submit: rate exceptions, open price choices, project scope and client contact presence, saved documents, and the latest Tender review with its high and medium findings. |
| `GET /estimates/:id/tender-clause-links` | Every live D\&C's linked work items in one read. |

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](https://help.stralos.app/internal/project-rates-service.md#estimate-scoped-project-rates) 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:

* `submit` calls the global `submit_tender` peer only when the count goes from 0 to 1. Later submits find the project already submitted and commit locally.
* `revise` calls the global `revise_tender` peer only when the count goes from 1 to 0. Revising while other submitted estimates remain commits locally.

Every submit, revise and award on a project takes the project lifecycle lock first, and any estimate of the project holding a pending transition blocks a new orchestrated submit or revise (`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 (`submitted` stays `submitted`, as the record of what was tendered);
* is read-only — any write returns `409 estimate_frozen` with `hidden: true`;
* is excluded from the project-level fan-out actions.

**Project-level fan-out.** `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 in `draft`, `review` or `submitted`.
* `archive` — every non-archived active estimate.
* `reopen` and `unarchive` — only the estimates the most recent `reject` or `archive` fan-out moved, so an estimate rejected or archived individually earlier is not revived.

Each fan-out target gets a derived `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](#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 the `EstimateAwarded` 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](#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

| Method | Path | Operation id | Notes |
| - | - | - | - |
| `GET` | `/projects/:projectId/delivery` | `estimate.delivery.get` | Returns the project's Delivery estimate with its tracking-sell total and running margin. On `404 delivery_not_found`, the response body carries the latest copy job for the empty-state retry button. |
| `POST` | `/projects/:projectId/delivery` | `estimate.delivery.create` | Creates the Delivery estimate from the won tender revision (idempotent: `200` for an existing one, `201` for a new one). Returns `409 project_not_awarded` before award. |
| `POST` | `/estimates/:id/delivery-transition` | `estimate.delivery.transition` | Moves the Delivery estimate through its sign-off state machine. |
| `GET` | `/estimates/:id/sign-offs` | `estimate.delivery.signOffs` | Returns the sign-off record: every request, newest first, with its revision number and snapshot hash. |

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 as `500 copy_parity_failed`.

The copy is idempotent: `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 a `tracking_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

```
draft ──request_sign_off──▶ in_sign_off ──approve (any ONE lead)──▶ signed_off
  ▲                           │  reject (any lead) / withdraw            │
  └───────────────────────────┘                                          │
  └──────────────────────────── reopen (any lead) ───────────────────────┘
```

Actions and who may take them:

| Action | From | To | Who |
| - | - | - | - |
| `request_sign_off` | `draft` | `in_sign_off` | Any project member (project must have at least one lead) |
| `approve` | `in_sign_off` | `signed_off` | Any project lead |
| `reject` | `in_sign_off` | `draft` | Any project lead (reason required) |
| `withdraw` | `in_sign_off` | `draft` | Any project member |
| `reopen` | `signed_off` | `draft` | Any project lead |

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 in `draft`. `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_ENABLED` off: 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: `createDeliveryForProject` runs. The copy is idempotent, so a redelivered event finds and returns the existing Delivery.

The consumer does not use `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:

| Field | Type | Notes |
| - | - | - |
| `sourceEstimateId` | `uuid \| null` | The estimate this one was copied from (duplicate or Delivery copy). `null` for a blank estimate. |
| `signedOffRevisionId` | `uuid \| null` | The revision id of the approved sign-off. `null` until `approve` is called. |

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 the `LINK#*` grant the read is `AccessDenied` and the guard answers `503 billing_unavailable`.

The auth plugin parses the optional `plan` and `via` (`org`, `link`, `name`) user-token claims and passes them to the guard; see [Auth token architecture](https://help.stralos.app/internal/auth-token-architecture.md). 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](https://help.stralos.app/internal/deployment.md#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 retryable `503` with `Retry-After: 5`.

The worst-case connection count per Lambda container is 6 (three pools × 2). The local server and backfill scripts leave pool bounds unset and rely on the pg default of 10 per pool, which is appropriate for concurrent in-process workloads.

The safe error handler (`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](https://help.stralos.app/internal/service-boundaries.md). For the project lifecycle orchestration protocol, see [Project lifecycle orchestration](https://help.stralos.app/internal/service-boundaries.md#project-lifecycle-orchestration-global-→-estimate).

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

| Value | Behaviour |
| - | - |
| Unset or `true` (default) | The read creates the estimate, as it always has. |
| `false`, `0`, `off`, or `no` | The read creates nothing and returns `409 project_not_provisioned` with `Retry-After: 5`. The estimate is expected from the `ProjectCreated` consumer (below) or a backfill calling `PUT /projects/:id/estimate`. |

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](https://help.stralos.app/api/estimate.md#project-estimate) for the client-facing responses.

## ProjectCreated event consumer

The estimate service subscribes to `ProjectCreated` 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 NOTHING` means 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 (with `ReportBatchItemFailures`), 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 to `ProjectRatesChanged` 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, a `rateSetVersion`, and change hints (`rateItemId`, `rateOptionId`, change) but carries no rate values. For each record the consumer:

1. Mints an `estimate-reprice` service token for the event's organisation through api-global's `mint-for-estimate-reprice` alias (`src/service-tokens/repriceServiceToken.ts`). An event has no user, so the peer reads carry this token instead of a user bearer.
2. Runs the shared re-price, which reads current state from `stralos-api-rates` and `stralos-api-global` within 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

| Outcome | Behaviour |
| - | - |
| Re-price succeeds | Commits; the affected lines are updated. |
| `404 no_estimate`, `409 estimate_frozen`, `422 no_project_rate_changes` | Logged and acknowledged. The dedupe row commits, so a redelivery is a no-op. |
| Mint failure, peer failure, or any other error | Rolls back and fails the record. SQS retries it and dead-letters it after five receives. |

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 own `reprice-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 in `stralos-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.

A newer stored version is never overwritten, so a late or replayed event cannot revert a newer name. The write does not bump `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

The `PartyChanged` 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:**

1. **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.
2. **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 `fillOrganisationReferences` using the runtime role and an `estimate-reprice` token 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.
3. **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-global `rate-references/resolve-batch`. A failure on this read fails that organisation and writes nothing more for it.
4. **Repair** — apply every id whose stored pair is stale using the same `applyNameChange` guard the event consumer uses. A non-zero `namesChanged` count on an environment with a live consumer means an event was lost or is still in flight.

The reconcile stops starting new organisations when less than 30 seconds of Lambda invocation time remains; the next day's run takes those organisations. A run in which any organisation fails throws after attempting all others, so the Errors alarm fires.

**Infrastructure.** The reconcile runs in the `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:

| `msg` | Fields | Notes |
| - | - | - |
| `estimate name reconcile organisation` | `orgId`, `linesScoped`, `linesFixed`, `namesChanged` | One line per organisation. `linesScoped` counts lines the reference fill scoped; `linesFixed` counts all rows written (version fills included); `namesChanged` counts rows whose stored name actually changed. |
| `estimate name reconcile complete` | `organisations`, `reconciled`, `failedOrganisations`, `skippedOrganisations`, `linesScoped`, `linesFixed`, `namesChanged` | One line per run. |

## DocumentUsagesChanged event publisher

The estimate service publishes `DocumentUsagesChanged` 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](https://help.stralos.app/internal/service-boundaries.md#documentusageschanged-estimate-→-estate-eventbridge-bus-→-documents).

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.

| Purpose | Source |
| - | - |
| `takeoff_measurement` | Takeoff measurement `document_id` |
| `work_item_source` | Work item `source_document_id` |
| `bid_rate_source` | Subcontract bid rate `source_document_id` |
| `package_attachment` | Subcontract package `document_ids` |
| `rate_attachment` | Work item rate attachment `source_document_id` |
| `variation_contract` | A variation assessment run's `contractDocumentIds` input |

**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_items`
* `estimate_work_item_components`
* `estimate_sections`
* `estimate_takeoff_measurements`
* `estimate_tender_clauses`
* `estimate_programme_items`
* `estimate_programme_dependencies`
* `estimate_variations`
* `estimate_variation_lines`
* `estimate_calculators`

**Live predicates** — `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**

* `DELETE` returns `204 No Content` on success. A repeat `DELETE` on an already-deleted row also returns `204` and changes nothing. The calculator delete is the exception: it keeps its `200` with counts, and a repeat returns `0` for both.
* `GET` by ID returns the row with `deletedAt` set; it is not filtered out.
* Any write (`PUT`, sub-item, component creation, etc.) to a deleted row returns `409 { code: "deleted" }`.
* List and aggregate endpoints exclude deleted rows; callers see only live rows.

**Deliberate exceptions** (kept close to the pre-soft-delete behaviour):

* 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_id` explicitly).
* 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-rate `PUT` and `DELETE` routes, which also returned `409 deleted` here, are retired.
* A version restore (`restoreSavedVersion` in `src/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 returns `409 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 `liveComponentSubtree` and `stampComponents` in `src/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.

**Unique indexes** — six partial unique indexes were replaced with live-only equivalents (predicate `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-rates` and `GET /subcontract-packages/:id/bid-rates`) are retired and return `404`. `DELETE /subcontract-bid-rates/:id` used 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 and `accept-bid-suggestion`. A bid already stamped deleted is still excluded from the return review and cannot be selected as a pricing source. The `estimate_subcontract_bid_rates` table and `components.subcontract_bid_rate_id` stay.
* 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_unique` deliberately still spans deleted rows: a deleted package keeps its portal token, and no other package can be issued it.

A package needs a due date. `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](#map-takeoff-migrations-0073-and-0074).

## Snap attachments

A drawing or map point may carry an optional `attach` object inside the `points` jsonb, validated by `takeoffPointAttachSchema` in `src/routes/estimate.ts`:

```ts theme={null}
{ measurementId: string /* uuid */; vertex: number }             // integer >= 0
{ measurementId: string /* uuid */; segment: number; t: number } // segment integer >= 0, t in 0..1
```

The server stores it as sent and never checks it against other rows: it does not confirm that `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](https://help.stralos.app/internal/estimate-frontend.md#snap-attachments).

## Map takeoff (migrations 0073 and 0074)

Migration 0073 adds the `estimate_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](https://help.stralos.app/api/estimate.md#council-utilities).

## 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. `handleRouteError` maps `PS001` to `422 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.

The `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:

1. Sub-items (with their programme subtree, measurements, package membership, and components) are soft-deleted first, while the parent still qualifies.
2. The parent's live components are soft-deleted.
3. Calculator links and defaults pointed at the parent are removed.
4. Project-rate attachments are removed.
5. The pricing row is reset to `internal` with no overrides.
6. Last, the item's `item_kind` flips to `provisional_sum` with `allowance = old total_price`, `quantity = 1`, `cost = price = allowance`, and `quantity_link = NULL`. The kind flips last so 0077's trigger cannot fire while the row still carries build-up.

Frozen estimates are not rewritten. Row-level security is suspended and restored around every statement in the same pattern as migrations 0032, 0065, and 0077.

**Money.** The allowance is the item's stored `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 the `estimate-offboarding` service token and re-checked before deleting. The clock lives in `estimate_org_offboarding` (migration 0070), whose `purged_at` records 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.

A run in which any organisation fails throws after trying the rest, which raises the `retention` Errors alarm. For the estate-wide design and the per-service table, see [Retention Lambdas by service](https://help.stralos.app/internal/data-retention.md#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-activity` does. 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`.

Where it applies:

* `POST /work-item-components` and `POST /work-items/:id/day-rate-lines` take an optional `wrapInActivity: true` at the top level. With it, a `quantityBasis` is `422 wrap_quantity_basis_not_allowed`, and on components a delivery link is `422 wrap_delivery_link_not_allowed`. The component response carries `activityContainer` (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 through `time-resource-activity`.
* `POST /estimates/:estimateId/work-items/insert-project-rate` (`src/routes/projectRateRoutes.ts`): a target naming `parentComponentId` adds the line at the end of that activity (library activities included; the line inherits its `activityId`). Only `total` is accepted there (`422 insert_per_unit_in_activity`); the line is charged `whole_activity` in an activity with daily production, otherwise stored per unit of the item so its total is the typed quantity. Without `parentComponentId` the line goes into a new activity named after the rate, a `per_unit` quantity 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 on `estimate_work_item_components` persist the choice state:

| Column | Type | Notes |
| - | - | - |
| `price_choice_status` | `varchar(20)` | `'choice_required'` while the estimator has not yet chosen; `NULL` otherwise. A DB `CHECK` enforces the closed vocabulary. |
| `price_choice_option_count` | `integer` | Number of live options the component could take, snapshotted at materialisation time. Non-null only when `price_choice_status` is non-null. |
| `price_choice_suggested_option_id` | `uuid` | The rates-service option id of the single option located at the project's own pricing location, if exactly one exists. Non-null only when `price_choice_status` is non-null. This is a suggestion, never a pin. |

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 a `priceChoices` 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.

The `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 whose `priceChoices` 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.

The check runs inside the transition transaction and throws, causing an automatic rollback. Nothing is reserved or mutated.

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

```
This line needs a price choice: choose which rate option it takes for this project.
```

A deliberate selection — any request that changes the `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 the `component_overrides` JSON on `estimate_work_item_pricing`. PRD 211 moved it onto the component row in four releases:

| Release | Migration | Change |
| - | - | - |
| PR A — expand | 0087 | Adds the selection columns and `estimate_component_value_overrides`. The JSON stays the source; the columns are a dual-written projection. |
| PR B — read columns | — | Readers price from the columns. `GET /estimates/:id/work-item-pricing` synthesises `componentOverrides` from the columns and value overrides. |
| PR C — stop JSON | 0094 | The columns are the only copy. No JSON is written or read; `componentOverrides` on `PUT`/`PATCH /work-items/:id/pricing` returns `422 component_overrides_retired`. `rateOptionId` becomes the stored generated column `pinned_rate_option_id` (the pinned option, read back from the selection when `selection_provenance = 'pinned'`), so nothing can write it. The companion `CHECK` and ticked-companion unique move to the selected option. |
| PR D — drop JSON | 0095 | Drops `estimate_work_item_pricing.component_overrides`, `estimate_work_item_components.rate_option_id` and the `estimate_sync_price_selection` projection function, and takes the strict companion `CHECK` (a companion must hold a selected option). |

### 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 to `estimate_work_item_components` and creates a new `estimate_component_value_overrides` table (see below).

**Selection columns on `estimate_work_item_components`:**

| Column | Type | Notes |
| - | - | - |
| `selected_rate_option_id` | `uuid` | The selected rate option. Null means no selection. |
| `selection_provenance` | `varchar(10)` | `'auto'` or `'pinned'`. Null when no selection. Present if and only if `selected_rate_option_id` is non-null (enforced by a DB `CHECK`). |
| `selected_unit_cost` | `double precision` | The resolved scalar after pricing rules are applied. |
| `priced_unit` | `varchar(40)` | Non-null signals a frozen selection (the option's pricing unit). |
| `conversion_factor` | `double precision` | Unit conversion from the option's pricing unit to the component's unit. |
| `library_unit_cost` | `double precision` | The rate library's own scalar, kept for review. Never priced. |
| `resolved_tier` | `varchar(10)` | One of `'override'`, `'rule'`, `'project'`, `'office'`, `'default'`. |
| `resolved_location_label` | `varchar(200)` | Display label of the resolved location. |
| `resolved_rule` | `jsonb` | The applied pricing rule snapshot. |
| `resolved_rule_outcome` | `varchar(16)` | One of `'applied'`, `'unsatisfiable'`, `'policy_unmet'`. |
| `project_rate_override_id` | `uuid` | Set when `resolved_tier = 'override'`. |
| `selection_resolved_at` | `timestamptz` | When the selection was resolved. |
| `selected_option_scope` | `varchar(12)` | `'organisation'` or `'supplier'`. Lines selected before writes scoped themselves are filled by the reference fill (see [Daily name reconcile](#daily-name-reconcile)). |
| `selected_option_supplier_id` | `uuid` | Supplier for supplier-scoped options. |
| `selected_option_location_id` | `uuid` | api-global location id for the selected option. |
| `source_name_version` | `integer` | Row version of the source name snapshot. |
| `location_name_version` | `integer` | Row version of the location name snapshot. |

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

| Value | Meaning |
| - | - |
| `row` | The component row's own stored value. |
| `selection` | The selected rate option's resolved unit cost. |
| `library` | The rate library scalar. |
| `derived` | A quantity derived from a recipe or duration (cannot be user-typed). |

### `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](https://help.stralos.app/api/estimate.md#hand-changed-value-overrides).

**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 `unit` or `unitCost` override on a line with a frozen pricing selection is refused with `422`.
* The estimate is recomputed after every successful write.

**Response:** `{ 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](#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's `rateOptionId` is the generated pin.
* **v34** (migration 0097) — component nodes carry the day-rate provenance columns.

Every earlier revision still parses and re-hashes to the SHA-256 it was frozen with (`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](#sending-contracts)). [Tender check](https://help.stralos.app/internal/tender-check.md) 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:

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

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

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

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

Run one repository at a time.

## Test boundaries

Route tests register production 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

| Endpoint | Pinned assertions |
| - | - |
| `GET /api/estimate/estimates/:id` | UUID path; estimate identity, status and version, numeric pricing settings, ISO timestamps; `400` and `404` |
| `GET /api/estimate/estimates` | `search`, `start`, `length` query parameters; `{ items, totalCount, start, length }` envelope shape |
| `GET /api/estimate/estimates/:id/sections` | UUID path; section items with title and sort order; pagination envelope; invalid UUID rejected |
| `POST /api/estimate/estimates` | Request schema only: project UUID, default name, currency, GST, working hours, numeric types and bounds |

## Sending contracts

The estimate service also acts as a sender: it calls `stralos-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](https://github.com/stralos-co-nz/stralos-api-rates/blob/main/BACKEND_CONTRACTS.md#bc-08).

### 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](https://github.com/stralos-co-nz/stralos-api-global/blob/main/BACKEND_CONTRACTS.md#bc-09).

### 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](https://github.com/stralos-co-nz/stralos-api-global/blob/main/BACKEND_CONTRACTS.md#bc-14).

### 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](https://github.com/stralos-co-nz/stralos-api-assistant/blob/main/SENDING_CONTRACTS.md#bc-02).

### 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](https://github.com/stralos-co-nz/stralos-api-global/blob/main/SENDING_CONTRACTS.md#bc-13).

## Frozen estimate writes

Every user write is gated by `assertEstimateEditableById` (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.

| Estimate kind | Frozen when |
| - | - |
| `tender` | Status is `submitted`, `accepted`, `rejected`, or `archived`, or `hidden_at` is set (not awarded at mark-won) |
| `delivery` | Status is anything other than `draft` (`deliveryOwnEditAllowed`) |
| `saved_version` | Always; restore or duplicate it instead |

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:

| Status | Message |
| - | - |
| `submitted` | This tender has been submitted, so it's read-only. Please use an alternative estimate to make changes. |
| `accepted` | This tender has been accepted, so it's read-only. Please raise a variation to change the price. |
| `rejected` | This tender was not won, so it's read-only. Please reopen the project to price it again. |
| `archived` | This estimate is archived, so it's read-only. Please unarchive the project to make changes. |
| `in_sign_off` | This Delivery estimate is out for sign-off, so it's read-only. Please withdraw the request to make changes. |
| `signed_off` | This Delivery estimate is signed off, so it's read-only. A project lead can reopen it. |

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.

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

When a listed method, path, parameter, required header, status code, or response field changes, update both the receiver and sender notes and associated test descriptions using the same contract ID.


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