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

> Estimate module API

The Estimate API owns project estimate data and references global projects, contractors, rate items, activities, and subcontractor bid rates by ID. Pricing ownership is reached indirectly through Rates records — specifically through a [rate option](https://help.stralos.app/api/rates.md#rate-options), which carries `scope`, `supplierId`, `locationId`, and cost.

## Base prefix

```text theme={null}
/api/estimate
```

## Owns

* Estimates and sections.
* Estimate document references.
* Takeoff measurements and map sheets.
* Work items, components, pricing snapshots, and TAG links.
* Subcontractor packages and bid review.
* Programme items and dependencies.
* TAGs/tender clauses.
* Import jobs.
* Contract baselines, variations, lines, revisions, events, and assessment runs.

## Endpoint groups

| Group | Routes |
| - | - |
| Service version | `GET /version` |
| Onboarding summary | `GET /onboarding-summary` |
| Project access scope | `GET /access-scope` |
| Project estimate (primary) | `GET /projects/:projectId/estimate`, `PUT /projects/:projectId/estimate` |
| Project estimates | `GET /projects/:projectId/estimates`, `POST /projects/:projectId/estimates`, `PATCH /estimates/:id`, `POST /estimates/:id/make-primary`, `DELETE /estimates/:id` |
| Project estimate settings | `GET /projects/:projectId/estimate-settings`, `PUT /projects/:projectId/estimate-settings` |
| Estimates | `GET /estimates`, `GET /estimates/:id`, `POST /estimates`, `PUT /estimates/:id` |
| Estimate lifecycle | `POST /estimates/:id/transition`, `POST /projects/:id/estimate/transition` |
| Tender revisions | `GET /estimates/:id/revisions` |
| Document references | `GET /estimates/:id/document-references` |
| Sections | `GET /estimates/:id/sections`, `POST /sections`, `PUT /sections/:id`, `DELETE /sections/:id` |
| Documents | `GET /estimates/:id/documents`, `GET /documents`, `GET /documents/:id`, `POST /documents`, `PUT /documents/:id`, `DELETE /documents/:id` |
| Takeoff | `GET /estimates/:id/takeoff-measurements`, `POST /takeoff-measurements`, `PUT /takeoff-measurements/:id`, `DELETE /takeoff-measurements/:id` |
| Takeoff map sheets | `GET /estimates/:id/takeoff-map-sheets`, `POST /takeoff-map-sheets`, `PUT /takeoff-map-sheets/:id`, `PATCH /takeoff-map-sheets/:id/view`, `DELETE /takeoff-map-sheets/:id` |
| Takeoff utilities | `GET /takeoff-utilities/providers`, `GET /takeoff-utilities/features` |
| Takeoff map layers | `GET /takeoff-map/layers`, `GET /takeoff-map/property-boundaries` |
| Work items | `GET /estimates/:id/work-items`, `GET /work-items`, `GET /work-items/:id`, `POST /work-items`, `PUT /work-items/:id`, `DELETE /work-items/:id` |
| Work item batch | `POST /estimates/:estimateId/work-items/batch` |
| Work item reorder | `POST /estimates/:estimateId/work-items/reorder` |
| Shared Costs | `POST /estimates/:estimateId/spreadable-rates` |
| Work item components | `GET /work-items/:id/components`, `POST /work-item-components` (adds a line in a new activity of its own), `PUT /work-item-components/:id`, `DELETE /work-item-components/:id` |
| Work item TAGs | `GET /work-items/:id/tags`, `POST /work-items/:id/tags` |
| Work item pricing | `GET /estimates/:id/work-item-pricing`, `PUT /work-items/:id/pricing`, `PATCH /work-items/:id/pricing`, `POST /estimates/:id/recompute-totals` |
| Re-resolve prices | `POST /estimates/:estimateId/re-resolve-prices` |
| Project rate links | `GET /estimates/:estimateId/work-items/:workItemId/project-rates`, `POST /estimates/:estimateId/work-items/:workItemId/project-rate-attachments`, `DELETE /project-rate-attachments/:id`, `GET /estimates/:estimateId/project-rate-usage`, `POST /estimates/:estimateId/work-items/insert-project-rate` |
| Automatic re-price | `POST /projects/:projectId/project-rate-reprice` |
| Rule change preview and apply | `POST /estimates/:estimateId/rule-change-preview`, `POST /estimates/:estimateId/rule-change-apply` |
| Rate exceptions | `GET /estimates/:estimateId/rate-exceptions`, `GET /estimates/:estimateId/rate-exceptions/count`, `POST /estimates/:estimateId/rate-exceptions/accept`, `DELETE /estimates/:estimateId/rate-exceptions/accept` |
| Rate exception decisions | `POST /estimates/:estimateId/lines/:componentId/apply-rule`, `POST /estimates/:estimateId/lines/:componentId/choose-option`, `POST /estimates/:estimateId/lines/:componentId/unpin`, `POST /estimates/:estimateId/lines/:componentId/clear-band-override` |
| Plant hire prices | `GET /plant-hire-prices` |
| Work item name suggestions | `GET /work-items/suggestions` |
| Work item similarity search | `GET /work-items/similar` |
| Work item creation | `POST /estimates/:id/work-items/accept-suggestion`, `POST /estimates/:id/work-items/from-measurement` |
| Work item export | `POST /estimates/:id/work-items/export`, `POST /estimates/:id/work-items/export/pdf`, `POST /estimates/:id/work-items/export/docx`, `GET /estimates/:id/work-items/export/build-up-rows` |
| Work item duplication | `POST /estimates/:estimateId/work-items/:itemId/duplicate` |
| Linked quantity candidates | `GET /estimates/:estimateId/work-items/:itemId/quantity-link/candidates` |
| Work item sub-items | `POST /estimates/:estimateId/work-items/:itemId/sub-items` |
| Work item build-up copy | `POST /estimates/:estimateId/work-items/:itemId/copy-build-up` |
| Project work item import | `POST /estimates/:estimateId/work-items/import-project/preview`, `POST /estimates/:estimateId/work-items/import-project` |
| Activity materialisation | `POST /work-items/:id/materialise-activity/preview`, `POST /work-items/:id/materialise-activity` |
| Manual activity | `POST /work-items/:id/manual-activity` |
| Time-resource activity | `POST /work-items/:id/time-resource-activity` |
| Activity children | `POST /work-item-components/:id/children` (adds a line inside an existing activity) |
| Move a build-up line | `POST /work-items/:id/components/:componentId/move` |
| Enable daily production | `POST /work-item-components/:id/enable-daily-production` |
| Save as activity | `POST /work-items/:id/promote-to-activity` |
| Project calculators | `GET /estimates/:id/calculators`, `POST /estimates/:id/calculators`, `PUT /calculators/:id`, `DELETE /calculators/:id` |
| Calculator copy | `POST /calculators/:id/copy` |
| Calculator links | `PUT /calculators/:id/links` |
| Calculator row unlink | `POST /work-item-components/:id/unlink-calculator` |
| Build-up line quantity-link candidates | `GET /estimates/:estimateId/components/:componentId/quantity-link/candidates` |
| Calculator substance usage | `GET /calculators/substance-usage` |
| Margin ladder | `GET /estimates/:id/margin-ladder`, `PUT /estimates/:id/margin-ladder`, `DELETE /estimates/:id/margin-ladder/:scopeKind/:scopeRef` |
| Estimate workspace versions | `GET /estimates/:id/versions`, `POST /estimates/:id/versions`, `POST /estimates/:id/versions/:version/create` |
| Subcontractors | `GET /estimates/:id/subcontract-packages`, `POST /subcontract-packages`, `PUT /subcontract-packages/:id` |
| Subcontract bid review | `GET /subcontract-packages/:id/review` |
| Contractor portal | `GET /subcontract-portals/:token`, `PUT /subcontract-portals/:token/rates/:workItemId`, `POST /subcontract-portals/:token/response` |
| Programme | `GET /estimates/:id/programme-items`, `POST /programme-items`, `PUT /programme-items/:id`, `DELETE /programme-items/:id` |
| Programme dependencies | `GET /estimates/:id/programme-dependencies`, `POST /programme-dependencies`, `PUT /programme-dependencies/:id`, `DELETE /programme-dependencies/:id` |
| TAGs/tender clauses | `GET /estimates/:id/tender-clauses` *(sync-on-read)*, `POST /tender-clauses`, `PUT /tender-clauses/:id`, `DELETE /tender-clauses/:id` |
| Import jobs | `GET /estimates/:id/import-jobs`, `GET /import-jobs`, `GET /import-jobs/:id`, `POST /import-jobs`, `PUT /import-jobs/:id`, `DELETE /import-jobs/:id` |
| Portfolio values | `GET /estimates/portfolio-values` |
| Contract baseline | `GET /estimates/:id/baseline`, `POST /estimates/:id/baseline`, `POST /estimates/:id/baseline/supersede` |
| Contract variations | `GET /estimates/:id/variations`, `POST /estimates/:id/variations`, `GET /variations/:id`, `PUT /variations/:id`, `DELETE /variations/:id`, `POST /variations/:id/transition` |
| Variation lines | `POST /variations/:id/lines`, `PUT /variations/:id/lines/:lineId`, `DELETE /variations/:id/lines/:lineId` |
| Variation assessments | `POST /variations/:id/assess`, `GET /variations/:id/assessment-runs/:runId`, `POST /variations/:id/assessment-runs/:runId/override` |

## Adding build-up lines

Resources (labour, plant, materials, subcontract) go inside an activity. Every new build-up line sits inside an activity of a work item.

* **Add a line to an activity that already exists:** use `POST /work-item-components/:id/children`, where `:id` is the activity. The line's `quantityBasis` sets how it is charged inside the activity, for example `whole_activity` (its quantity once) or `per_allocated` (per unit of the activity's quantity).
* **Add several lines together:** create one activity with `POST /work-items/:id/manual-activity`, then add each line with `POST /work-item-components/:id/children`.
* **Add one line on its own:** a line sent to `POST /work-item-components` has no parent, so the server puts it in a new activity named after it, in the same transaction. A line in hours, days, weeks or months sets the activity's duration. Any other line goes into a one-day activity, charged `whole_activity`. The `201` response is the line, whose `parentComponentId` is the new activity, plus `activityContainer`, the new activity.

`wrapInActivity` can be left out or sent as `true`. On `POST /work-item-components`, these return `422`:

| Sent | Code | What to do |
| - | - | - |
| `wrapInActivity: false` | `line_needs_activity` | Leave it out, or add the line with `POST /work-item-components/:id/children`. |
| `quantityBasis` | `wrap_quantity_basis_not_allowed` | Set it on a line inside an activity instead. |
| `deliveryActivityComponentId` | `wrap_delivery_link_not_allowed` | Add the line without the link. |
| `generatedKey` | `generated_key_not_allowed` | Leave it out. Calculators write their own lines. |

`POST /work-items/:id/day-rate-lines` follows the same rule. With `parentComponentId`, the line goes inside that activity. Without one, a Flat line priced per hour, day, week or month goes into a time container, and any other line (a Fixed line, or a non-time Flat line) goes into a new one-day activity named after it. The response's `container` is the new activity. At the top level, `wrapInActivity: false` returns `422 line_needs_activity` and a `quantityBasis` returns `422 wrap_quantity_basis_not_allowed`.

`POST /work-items/:id/components/:componentId/move` moves a line to another place in the same work item's build-up. A line can't move out of every activity (`422 move_needs_activity`). A line whose quantity is linked to a takeoff or calculator can't move into an activity (`422 move_linked_line_into_activity`): unlink its quantity first, then move it.

Some older lines can still sit outside an activity, where a line has no equal inside one (for example, a line with a delivery link). They keep their price.

## Onboarding summary

```text theme={null}
GET /onboarding-summary
```

Returns a read-only checklist of the seven key "Your first estimate" milestones for the caller's organisation, plus the most recently updated project that has an estimate.

The endpoint issues a single database read — seven existence probes in one statement — and never creates an estimate or any other record. Soft-deleted rows never count. Results are scoped by the caller's organisation and by project membership under row-level security, so a member sees only the projects they belong to.

**Authentication:** requires a valid bearer token with a resolved organisation context. The response carries `Cache-Control: no-store`.

### Response

**200 OK**

```json theme={null}
{
  "hasEstimate": true,
  "hasTakeoff": false,
  "hasCalculator": false,
  "hasWorkItem": true,
  "hasLinkedQuantity": false,
  "hasBuildUpLine": false,
  "hasSubmittedEstimate": false,
  "latestProjectId": "uuid-a"
}
```

| Field | Type | Description |
| - | - | - |
| `hasEstimate` | boolean | `true` when at least one estimate exists in the caller's visible projects. |
| `hasTakeoff` | boolean | `true` when at least one live takeoff measurement exists on a live map sheet in the caller's visible projects. |
| `hasCalculator` | boolean | `true` when at least one live project calculator exists in the caller's visible projects. |
| `hasWorkItem` | boolean | `true` when at least one live work item exists in the caller's visible projects. |
| `hasLinkedQuantity` | boolean | `true` when at least one live work item has a quantity link (via a work item quantity link, a takeoff measurement linked to a work item, or a calculator row linked to a work item). |
| `hasBuildUpLine` | boolean | `true` when at least one live work item component is linked to a rate item or activity in the caller's visible projects. |
| `hasSubmittedEstimate` | boolean | `true` when at least one estimate has a status of `submitted`, `accepted`, or `rejected`, or was archived from one of those statuses. |
| `latestProjectId` | UUID or `null` | The project ID of the most recently updated estimate in the caller's visible projects. `null` when no estimates exist. |

### Error responses

| HTTP | Code | Cause |
| - | - | - |
| `401` | `auth_required` | Missing or invalid bearer token. |
| `403` | `org_required` | Valid token but no resolved organisation context. |

### Retired routes

The following retired routes return `404`. Use the active routes above for current integrations.

| Retired route | Use instead |
| - | - |
| `GET /sections`, `GET /sections/:id` | `GET /estimates/:id/sections` |
| `GET /takeoff-measurements`, `GET /takeoff-measurements/:id` | `GET /estimates/:id/takeoff-measurements` |
| `GET /takeoff-map-sheets`, `GET /takeoff-map-sheets/:id` | `GET /estimates/:id/takeoff-map-sheets` |
| `GET /work-item-components`, `GET /work-item-components/:id` | `GET /work-items/:id/components`, or the `components` array on `GET /work-items/:id` |
| `GET /subcontract-packages`, `GET /subcontract-packages/:id` | `GET /estimates/:id/subcontract-packages` |
| `GET /programme-items`, `GET /programme-items/:id` | `GET /estimates/:id/programme-items` |
| `GET /programme-dependencies`, `GET /programme-dependencies/:id` | `GET /estimates/:id/programme-dependencies` |
| `GET /tender-clauses`, `GET /tender-clauses/:id` | `GET /estimates/:id/tender-clauses` |
| `GET /subcontract-packages/:id/bid-rates`, `GET /subcontract-bid-rates`, `GET /subcontract-bid-rates/:id` | `GET /subcontract-packages/:id/review` |
| `POST /subcontract-bid-rates`, `PUT /subcontract-bid-rates/:id`, `DELETE /subcontract-bid-rates/:id` | Contractors submit bids through the portal (`PUT /subcontract-portals/:token/rates/:workItemId`). Estimators add a bid from a document suggestion with `POST /work-items/:id/accept-bid-suggestion`. |
| `GET /estimates/:id/revisions/:revision` | `GET /estimates/:id/revisions` |

The `POST`, `PUT` and `DELETE` routes for sections, takeoff measurements, map sheets, work item components, subcontract packages, programme items, programme dependencies and tender clauses are unchanged.

### Retired estimate field

`projectScope` (column `estimates.project_scope`) is retired. `POST /estimates` and `PUT /estimates/:id` no longer accept it, and estimate reads no longer return it. A body that still sends `projectScope` has it stripped rather than refused. A saved estimate version that still carries the key restores normally.

## Resolve estimate access scope

```text theme={null}
GET /access-scope
```

Returns the list of estimate IDs visible to the caller under their current project membership. The server resolves project membership fresh from the Global API on every call and runs the query through the same project-scoped RLS that all other estimate routes use.

Use this endpoint to synchronise a client's local estimate list with the server's current membership view, for example after a project membership change or after an initial sign-in, without re-fetching each estimate individually.

**Authentication:** requires a valid bearer token with a resolved organisation context.

### Response

**200 OK**

```json theme={null}
{
  "organisationId": "uuid-org",
  "estimateIds": ["uuid-a", "uuid-b"]
}
```

| Field | Type | Description |
| - | - | - |
| `organisationId` | UUID | The caller's organisation, echoed from the verified token. |
| `estimateIds` | UUID\[] | IDs of every estimate visible to the caller under their current project membership. Empty when the caller holds no project memberships in this organisation. |

The response carries `Cache-Control: no-store`. Project membership is resolved fresh on each call; do not cache the result.

### Error responses

| HTTP | Code | Cause |
| - | - | - |
| `401` | `project_access_required` | Missing or invalid bearer token. |
| `403` | `project_access_required` | Valid token but project membership verification failed. |
| `503` | `project_access_unavailable` | The Global API could not be reached within 5 seconds. Retry after the `Retry-After` interval. |

## Portfolio values

```text theme={null}
GET /estimates/portfolio-values
```

Returns the current stored tender price for each project in a batch of up to 100 project IDs. The endpoint reads settled work-item totals and applies contingency once — it never creates an estimate, triggers a recompute, or resolves rates. Missing projects or projects with no estimate in the caller's organisation are omitted without error.

**Authentication:** requires a valid bearer token with a resolved organisation context.

### Query parameters

| Parameter | Type | Required | Description |
| - | - | - | - |
| `projectIds` | string | Yes | Comma-separated list of project UUIDs. Minimum 1, maximum 100. Duplicates are deduplicated before lookup. |

### Response

**200 OK**

```json theme={null}
{
  "items": [
    {
      "projectId": "uuid-a",
      "estimateId": "uuid-b",
      "currency": "NZD",
      "tenderPrice": 1250000.00,
      "basis": "current_stored_estimate"
    }
  ]
}
```

Each entry in `items` corresponds to one matched estimate in the caller's organisation:

| Field | Type | Description |
| - | - | - |
| `projectId` | UUID | Project ID from the request. |
| `estimateId` | UUID | ID of the project's estimate. |
| `currency` | string or `null` | Three-letter ISO currency code stored on the estimate, or `null` when not set. Currency is never converted. |
| `tenderPrice` | number or `null` | Stored item sell totals plus contingency, excluding GST, in the estimate's currency. `null` when the settled money cannot be read (for example, an estimate with no work items or with unsettled totals). |
| `basis` | `"current_stored_estimate"` | Identifies that the values reflect the last stored state, not a fresh recompute. |

Projects with no estimate in the caller's organisation, or estimates whose totals are not readable, are omitted from `items`. The response always returns `200`; an empty `items` array means none of the requested projects matched.

### Error responses

| HTTP | Cause |
| - | - |
| `400` | `projectIds` is missing, contains a non-UUID value, or exceeds 100 entries. |
| `401` | Missing or invalid bearer token. |
| `403` | Caller token has no resolved organisation context. |

## Calculator substance usage

```text theme={null}
GET /calculators/substance-usage
```

Returns how many of the organisation's project calculators name each disposal substance, across all projects. Use this to populate the "Most used" group in a substance dropdown — the client applies the ranking cut (for example, top five), and this endpoint supplies the full counted list.

A calculator counts once per substance regardless of how many strata within it name that substance. Soft-deleted calculators (those with `deleted_at` set) are excluded from the count. The response covers all three calculator types: trench, pit, and volume, which all store their strata under `existingLayers` in the calculator config.

**Authentication:** requires a valid bearer token with a resolved organisation context.

### Response

**200 OK**

```json theme={null}
{
  "items": [
    { "substanceId": "uuid-a", "count": 12 },
    { "substanceId": "uuid-b", "count": 7 }
  ]
}
```

The `items` array is sorted by `count` descending, then by `substanceId` ascending as a tie-break. Only substances with a count of at least one appear.

| Field | Type | Description |
| - | - | - |
| `substanceId` | string | The disposal substance ID as stored in the calculator stratum snapshot. This is the ID from the organisation's disposal substance register at the time the substance was selected; the endpoint does not re-resolve it against the current register. |
| `count` | integer (≥ 1) | Number of distinct calculators in the organisation that name this substance in at least one stratum. |

### Error responses

| HTTP | Cause |
| - | - |
| `401` | Missing or invalid bearer token. |
| `403` | Caller token has no resolved organisation context. |

## Section deletion

```text theme={null}
DELETE /sections/:id
```

Deletes a section and returns `204 No Content`. The estimate must be editable (`draft` or `review`). On a frozen estimate (submitted, accepted, rejected, or archived) the delete returns `409 estimate_frozen` with the estimate's `status`, and the section is kept, because the delete recomputes the estimate's totals.

The operation acquires the estimate-totals advisory lock before the delete so the lock is held across both the stamp and the recompute.

The delete is soft: the section is stamped `deletedAt` and leaves every list. Its work items are moved to the Unsectioned pool in the same transaction (the route clears their `section_id` explicitly, as the foreign key's `ON DELETE SET NULL` did while the delete was hard). A later restore would not move them back.

Estimate totals are recomputed unconditionally after deletion. This covers both ordinary sections and the Shared Costs section, which has additional side effects:

* Deleting an **ordinary section** moves its rows to Unsectioned and updates the estimate totals to reflect the new membership.
* Deleting the **Shared Costs section** removes that section's rows from the pool, clears every persisted `spreadableCost` share that was distributed to other sections, and recomputes totals so the formerly pooled rows are counted normally.

| HTTP | Cause |
| - | - |
| `401` | Missing or invalid bearer token. |
| `403` | Caller is not authorised for the estimate. |
| `404` | Section not found, belongs to a different organisation, or is outside the caller's project access. |

## Stored rates and totals

Every work item stores its sell rate to the cent, and its price is always `round2(quantity × unitRate)` in exact decimal arithmetic. Each section stores its `subtotalPrice`, and the estimate stores its totals. Clients read these figures rather than summing rows.

### Sections response envelope

`GET /estimates/:id/sections` returns the standard list envelope (`items`, `totalCount`, `start`, `length`) plus the estimate's stored totals as top-level fields, so a client that re-reads only the sections after an edit never shows a stale total. The read stores any missing item rates and totals before it answers.

| Field | Type | Description |
| - | - | - |
| `itemsTotal` | number or `null` | Section subtotals plus unsectioned top-level items, excluding contingency. |
| `contingencyTotal` | number or `null` | Contingency, applied once to `itemsTotal`. |
| `tenderTotal` | number or `null` | `itemsTotal` + `contingencyTotal`: the tender price excluding GST. |
| `unsectionedTotal` | number or `null` | Subtotal of top-level items in no section. |

`null` means the estimate has no stored totals yet.

### Work item `unitRate`

Work item reads carry `unitRate`, the stored sell rate to 2 decimal places. It is `null` for provisional sums, rows with a quantity of zero or less, and frozen rows whose stored price no cent rate reproduces. Use `unitRate` when it is non-null; derive a display rate from `totalPrice ÷ quantity` only when it is `null`.

When sell rate rounding moved the rate, `unitRateUnrounded` holds the cent rate before rounding; otherwise it is `null`. See [Sell rate rounding](#sell-rate-rounding).

## Shared Costs

```text theme={null}
POST /estimates/:estimateId/spreadable-rates
```

Creates or returns the estimate's single Shared Costs pool. Creation adds a section flagged `isSpreadable`, titled **Shared Costs**, numbered `S`, and seeded with a **Shared Costs item**. The section receives an end-of-list `sortOrder` so existing SOQ numbering is not disturbed, while estimate reads surface it first.

The endpoint is idempotent:

* `201 Created` creates the section and placeholder item.
* `200 OK` returns the existing section without adding another item.

Both responses contain `{ section, workItem, created }`. `workItem` is the seeded row on creation and `null` when the pool already existed.

During recompute, the pool's rolled cost and sell totals are apportioned independently among non-pool top-level rows. Both apportionments use each recipient's rolled non-fixed cost as the weight: fixed-price portions and provisional sums do not receive a share, while an open portion of a partly fixed row does. Largest-remainder allocation reconciles each set of integer-cent shares exactly to its own pool total. The cost share is added to `totalCost` and recorded in `spreadableCost`; the independently calculated sell share is added to `totalPrice`, so the two shares can differ when the pool carries margin.

`GET /estimates/:id/work-items` includes `spreadablePrice` on each work item row: the row's Shared Costs sell share. It is already inside `totalPrice`, exactly as `spreadableCost` (the cost share) is already inside `totalCost`. The value is `0` when the row takes no share, and `null` when the estimate's rows predate a stored spread base. The field is omitted entirely on estimates with no Shared Costs pool.

Distribution runs when either the pool cost or pool sell total is positive, the non-fixed recipient base is positive, and at least one recipient exists. This includes a zero-cost pool with a positive sell total. Once distributed, pool rows remain visible but their section contributes zero to estimate totals because their cost and sell have moved into the recipients. If there is no valid base or recipient, the pool remains an ordinary section for that pass and its rows continue to count. Sub-items are never separate recipients; their non-fixed cost rolls into the top-level parent.

Add further pool rows through the normal work-item routes using the returned section ID.

| HTTP | Cause |
| - | - |
| `400` | Invalid estimate ID. |
| `401` | Missing or invalid bearer token. |
| `403` | Caller is not authorised for the estimate. |
| `404` | Estimate not found. |

## Plant hire prices

```text theme={null}
GET /plant-hire-prices
```

Returns separate supplier hire quotes for a plant rate item — one for a day basis and one for an hour basis. Use this endpoint to display indicative hire costs when the project schedule has a machine shortfall and the system needs to present external hire options to the estimator.

The endpoint is display-only. No estimate or schedule is written.

**Authentication:** requires a bearer token for a signed-in organisation member. Service tokens are not accepted.

### Query parameters

| Parameter | Type | Required | Description |
| - | - | - | - |
| `rateItemId` | UUID | Yes | The plant rate item to quote. Must belong to the caller's organisation and have `rateGroup: "plant_vehicles"`. Project-scoped rate items are not accepted. |
| `deficitDays` | integer | Yes | Number of machine-days short. Used as the day quote quantity. Must be between 1 and 1,000,000. |
| `projectId` | UUID | No | When supplied, the endpoint applies the project's pricing rules and rate context to select the day and hour options. Omit to return the best library option without project context. |
| `condition` | string | No | Rate condition token. Defaults to `"standard"`. Accepted format: lowercase alphanumeric with single spaces, underscores, or hyphens between words. Maximum 40 characters. |

### Price resolution

The endpoint uses the project's current price-selection order, restricted to **supplier** options. Internal (own-fleet) options are excluded before selection. A supplier project override wins first. The project's rate rules and pricing location then form one ordered preference list; the first entry with an eligible supplier option wins. A broad rule placed before a specific rule can win, and the pricing location can win before a rule when it comes first. If no preference yields an option, the project's office location and then the deterministic lowest-amount supplier option are tried.

The day and hour quotes are resolved independently. A day option does not derive hours from the day rate, and a week option is not converted to days. Only options whose unit spelling maps to the target time basis are candidates for that quote.

Accepted unit spellings:

* Day basis: `day`, `days`, `d`
* Hour basis: `hour`, `hours`, `hr`, `hrs`, `h`

### Response

**200 OK**

| Field | Type | Description |
| - | - | - |
| `rateItemId` | UUID | The requested rate item. |
| `projectId` | UUID or `null` | The project context used, or `null` when none was supplied. |
| `deficitDays` | integer | The requested deficit day count. |
| `condition` | string | The condition token used. |
| `day` | object | Day-basis quote (see below). Quantity equals `deficitDays`. |
| `hour` | object | Hour-basis quote (see below). Quantity is always 1. |

Each quote object contains:

| Field | Type | Description |
| - | - | - |
| `unit` | `"day"` or `"hour"` | Time basis for this quote. |
| `quantity` | integer | Quantity charged: `deficitDays` for the day quote; `1` for the hour quote. |
| `status` | `"available"` or `"unavailable"` | Whether a supplier price was resolved. |
| `unavailableReason` | `"no_supplier_price"` or `"no_comparable_price"` or `null` | When `status` is `"unavailable"`: `"no_supplier_price"` means no supplier option exists for this basis; `"no_comparable_price"` means options exist but all were excluded (see `exclusions`). `null` when available. |
| `optionId` | UUID or `null` | ID of the winning rate option, or `null` when unavailable. |
| `normalisedRate` | number or `null` | Unit cost of the winning option in the option's priced unit. `null` when unavailable. |
| `chargedAmount` | number or `null` | Total charged cost (`normalisedRate × quantity`, after any minimum-quantity floor). `null` when unavailable. |
| `currency` | string or `null` | Three-letter currency code of the winning option. |
| `pricedUnit` | string or `null` | The unit the winning option is priced in (the canonical spelling stored on the rate option, not the normalised alias). |
| `scope` | `"organisation"` or `"supplier"` or `null` | Whose price this is. `"organisation"` means the organisation's own rates; `"supplier"` means an external supplier. Every quote returned by this endpoint is supplier-scoped by construction. `null` when unavailable. |
| `supplierId` | UUID or `null` | Supplier of the winning option. |
| `sourceName` | string or `null` | Display name of the rate source. |
| `locationId` | UUID or `null` | Resolved location ID for the winning option. |
| `locationName` | string or `null` | Display name of the resolved location. |
| `tier` | `"override"`, `"rule"`, `"project"`, `"office"`, or `"default"` or `null` | Pricing tier that supplied the winning option. `null` when unavailable. |
| `ruleOutcome` | string or `null` | Outcome of rule evaluation when a project rule was evaluated. `null` when no rule applies. |
| `exclusions` | object\[] | Options that existed but were excluded. Each entry contains `optionId` (UUID) and `reason` (string). |

### Error responses

| HTTP | Cause |
| - | - |
| `400` | A query parameter fails validation (for example, `deficitDays` is zero, non-integer, or out of range; or `condition` contains invalid characters). |
| `403` | The caller is not a signed-in organisation member. |
| `404` | The rate item does not exist, belongs to a different organisation, is project-scoped, is not in the `plant_vehicles` rate group, has been deleted, or the supplied `projectId` is not accessible. |
| `502` | A peer service (Rates API or Global API) read failed or returned an unreadable response. The error message names the failing service. |

## Work item export

```text theme={null}
POST /estimates/:id/work-items/export
POST /estimates/:id/work-items/export/pdf
POST /estimates/:id/work-items/export/docx
GET  /estimates/:id/work-items/export/build-up-rows
```

These routes produce the work-items export workbook, the tender submission PDF, the tender Word file, and the workbook's build-up level preview.

### Download the export workbook

```text theme={null}
POST /estimates/:id/work-items/export
```

Builds and streams a `.xlsx` workbook for the estimate's work items. The response is a binary `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` file with `Content-Disposition: attachment` and `Cache-Control: private, no-store`.

**Authentication:** requires a valid bearer token with a resolved organisation context.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `format` | `"xlsx"` | Yes | Must be `"xlsx"`. |
| `layout` | `"work_items"` \| `"submission"` \| `"original"` | No | Which workbook to build. `"work_items"` (default) is the estimator's full-detail export. `"submission"` is the client-facing schedule; its **Unit** column uses the display symbols from the company's unit catalogue (for example `m³`, not `cubic_metre`). `"original"` returns the original uploaded workbook identified by `sourceDocumentId`. |
| `sourceDocumentId` | UUID | Conditional | Required when `layout` is `"original"`. |
| `columns` | string\[] | No | Screen column keys to include, in the page's own key names. Omit to include all columns. Must contain at least one entry when supplied. Unknown keys return `400`. |
| `level` | `"work_items"` \| `"build_up_resources"` | No | Row level for the `"work_items"` layout. `"work_items"` (default) writes one row per item. `"build_up_resources"` follows each item row with one row per resource in its build-up. Refused with `400` on `"submission"` and `"original"` layouts. |
| `includeSubItems` | boolean | No | Whether sub-items are written as rows of their own. Defaults to `true`, which groups each sub-item one outline level under its parent. `false` leaves sub-items out of the `"work_items"` and `"submission"` layouts; a parent's amount already includes them. Ignored by the `"original"` layout. |
| `includeCostBreakdown` | boolean | No | `"submission"` layout only. Defaults to `true`: adds a **Cost breakdown** sheet with each item's build-up at sell, reconciling to its price. Its rows count toward the row cap. Ignored by the other layouts. |
| `sections` | object | No | `"submission"` layout only. The tender download sections to write; see [Tender download sections](#tender-download-sections). Absent: the submission workbook is unchanged. |
| `documentIds` | string\[] | No | `"submission"` layout only. Up to 500 project document IDs to list under **Documents received**. |
| `leavePricesOut` | boolean | No | `"submission"` layout only, and only with `sections`. Draws the schedule without rates, totals, or summary money. |

**`level: "build_up_resources"` behaviour**

Each item row is followed by one row per resource in its own build-up, in the build-up grid's order. Subcontract mirrors are excluded. Activity containers are not rows; each resource carries its nearest enclosing activity as columns (activity path, instances, allocated quantity, allocation unit, production rate, and duration in days).

Resource rows carry separate columns (build-up group, resource count, usage, charged quantity, rate unit, resource unit cost, resource cost rate, resource total cost) plus the pricing engine's per-line markup or margin percentage, sell rate, and total sell. Every item money and quantity column is left empty on resource rows so no item total is counted twice.

A line priced by a rate table with no row chosen, or by a stepped rate with no step picked, has no price. Its money cells hold the text `Row needed` or `Step needed` instead of an amount, and it contributes nothing to the totals.

Two item-level columns are added in this mode:

* `shared_costs_sell_share`: the item's share of the Shared Costs pool's sell.
* `item_price_adjustment`: the item's price before Shared Costs less its resource line sells. Captures allowance amounts, fixed-package sells, and rounding so the reconciliation closes.

Sell figures come from the pricing engine using the estimate's frozen margin ladder and live bid rates. A line with no margin sells at cost. A selected subcontract bid is written as one line at the engine's margin; its replaced build-up rows show cost with a zero sell.

The 10,000-row cap applies to the combined item and resource count at this level.

**Response headers**

| Header | Description |
| - | - |
| `Content-Disposition` | `attachment; filename="<project name> - Work Items.xlsx"`, or `"<project name> - Schedule.xlsx"` for the `"submission"` layout. Falls back to a generic name when the project name cannot be fetched. |
| `X-Stralos-Export-Degraded` | Comma-separated list of reference lists that did not load (`unit-catalogue`, `rate-group-labels`). Present only when at least one list failed to load. The workbook is built with built-in defaults in that case. |

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `400` | | Unknown column key, `level` supplied for a non-`work_items` layout, `sourceDocumentId` missing for the `original` layout, or `sections` or `documentIds` supplied on a layout other than `"submission"`. |
| `400` | | Unknown or invalid body field. |
| `401` | | Missing or invalid bearer token. |
| `403` | | Caller is not authorised for the estimate. |
| `404` | | Estimate not found. |
| `409` | `delivery_not_client_facing` | A `"submission"` or `"original"` layout requested for a Delivery estimate, which has no client outputs. |
| `422` | | Row count exceeds the 10,000-row cap (the message includes the row count), the workbook exceeds 4 MB, zero columns resolved, Shared Costs cannot be spread on a tender layout, or a [tender download](#tender-download-sections) refusal. |
| `502` | See [Tender download sections](#tender-download-sections) | A tender download read failed. |

### Build-up level preview

```text theme={null}
GET /estimates/:id/work-items/export/build-up-rows
```

Returns the resource rows the `build_up_resources` level workbook would write, as JSON, without building the file. Use this to show a preview of the build-up level on the export page without a client-side copy of the row builder.

Applies the same 10,000-row cap check as the export route. Refused with `422` when the cap is exceeded.

**Authentication:** requires a valid bearer token with a resolved organisation context.

**Response: 200 OK**

| Field | Type | Description |
| - | - | - |
| `itemColumns` | object\[] | Item-level columns added at the build-up level. Each entry has `key`, `label`, and `kind`. |
| `itemCells` | object | Map of item ID to item-level cell values for the added columns. |
| `columns` | object\[] | Resource-level columns. Each entry has `key`, `label`, and `kind`. |
| `rows` | object\[] | Resource rows. Each entry carries the item ID (`itemId`), section, and a cell value for each resource column. |

The response carries `Cache-Control: private, no-store`.

**Error responses**

| HTTP | Cause |
| - | - |
| `401` | Missing or invalid bearer token. |
| `403` | Caller is not authorised for the estimate. |
| `404` | Estimate not found. |
| `422` | Row count exceeds the 10,000-row cap. |

### Tender submission PDF

```text theme={null}
POST /estimates/:id/work-items/export/pdf
```

Builds and streams the tender submission as a PDF from the same reads as the `"submission"` workbook. The response is `application/pdf` with `Content-Disposition: attachment; filename="<project name> - Schedule.pdf"` and `Cache-Control: private, no-store`.

The PDF carries a title block with the project, estimate, revision, client, site, reference, and date; the section summary and headline totals; the schedule grouped by section, with GST and the total including GST; and the departures and clarifications. Every figure comes from the estimate's stored totals. A spread Shared Costs pool is not listed, because each item's amount already includes its share. The PDF uses the company's brand colour and logo, falling back to the Stralos look, and the unit catalogue's display symbols.

**Authentication:** requires a valid bearer token with a resolved organisation context.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `includeSubItems` | boolean | No | Print sub-items as rows under their parent. Defaults to `true`. A parent's amount already includes them. |
| `includeCostBreakdown` | boolean | No | Print each item's breakdown under it, in sell figures that add up to the item's amount; never internal cost or margin. Defaults to `true`. |
| `sections` | object | No | The tender download sections to print; see [Tender download sections](#tender-download-sections). Absent: the PDF is unchanged. |
| `documentIds` | string\[] | No | Up to 500 project document IDs to list under **Documents received**. |
| `leavePricesOut` | boolean | No | Only with `sections` (`400` otherwise). Draws the tender without rates, totals, or summary money. |

**Limits.** The PDF is refused with `422` before drawing when it would exceed 4,000 rows (items, breakdown lines, section headings and subtotals, and departures and clarifications), and after drawing when it exceeds 4 MB. Both messages suggest exporting the XLSX. The 4 MB message also suggests turning off the cost breakdown, and the row message does so when the breakdown is on. A terms PDF appended by the tender download (at most 10 MB) is not counted toward the 4 MB.

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `401` | | Missing or invalid bearer token. |
| `403` | | Caller is not authorised for the estimate. |
| `404` | | Estimate not found. |
| `409` | `delivery_not_client_facing` | The estimate is a Delivery estimate, which has no client outputs. |
| `422` | | Row or size cap exceeded, Shared Costs cannot be spread, or a [tender download](#tender-download-sections) refusal. |
| `502` | See [Tender download sections](#tender-download-sections) | A tender download read failed. |

### Tender Word file

```text theme={null}
POST /estimates/:id/work-items/export/docx
```

Builds the tender as an editable Word document, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, with `Cache-Control: private, no-store`. It takes the PDF's body, except that `sections` is required (`400` without it): the Word file exists only as a tender download.

It draws the same blocks as the PDF, in the same order and from the same model and [tender layout](https://help.stralos.app/guides/admins/tender-layout.md). Block headings are Word Heading 1, the project name is the Title, and the schedule, summary, documents, and totals are Word tables whose header rows repeat. Page numbers are Word fields. Inter, and Sora when the layout uses it, are embedded. A terms PDF can't go inside a Word file, so the terms block says the terms are a separate PDF.

**Limits.** Refused with `422` before drawing above 4,000 rows, and after drawing above 4 MB.

**Error responses** are those of the [tender submission PDF](#tender-submission-pdf), plus `400` when `sections` is absent.

### Tender downloads, names, and stored copies

A request with `sections` to the `"submission"` workbook, the PDF, or the Word route is a tender download:

* The file is named `<project> - Tender - Rev N` with its extension, with the estimate's name after the project for an alternative estimate, and ` - No prices` when prices are left out. A draft carries the revision it will become, stamped with today's date; a submitted revision carries its own number and submission date.
* On a tender frozen at its latest revision, the response is that revision's stored as-submitted file (`X-Tender-Copy: stored`), or one drawn from its submitted figures when none was stored (`X-Tender-Copy: live`). A request with `leavePricesOut` is drawn fresh. A draft answers `X-Tender-Copy: draft`.
* Every tender download is logged.

See [Tender copies](https://help.stralos.app/internal/estimate-service.md#tender-copies-and-download-log) for the stored copies, the saved Tender download options, and the download log.

### Tender download sections

The `"submission"` workbook, the submission PDF, and the tender Word file take `sections` and `documentIds`, read and checked the same way. Without `sections`, the workbook and the PDF are what they were before the tender download.

`sections` is an object of six booleans, all required: `projectScope`, `summary`, `schedule`, `documentsReceived`, `departures`, `terms`. The ticked sections are written in that fixed order. The workbook writes a sheet for each one; the cost breakdown is written only when `schedule` is ticked.

* **Project scope:** the project's rich-text `scope` from the Global API. A ticked scope that is empty is skipped, unless it is the only tick.
* **Documents received:** the `documentIds` the project holds, read from the Documents API, by title (else file name), folder, and received date in the project's time zone. IDs the project does not hold are ignored.
* **Terms and conditions:** the company's terms from `GET /api/global/organisation-terms`. Typed terms print as their own section. A terms PDF is appended after the PDF's last page, and the section says the terms are attached; the workbook and the Word file say they are attached as a separate PDF.

Every ticked section is read and checked before anything is built, so the output never silently drops one.

| HTTP | Code | Cause |
| - | - | - |
| `422` | `tender_download_nothing_ticked` | No section is ticked. |
| `422` | `tender_project_scope_missing` | Project scope is the only tick and the project has no scope. |
| `422` | `tender_documents_none` | Documents received is ticked and none of the `documentIds` belong to the project. |
| `422` | `tender_terms_missing` | Terms are ticked and the company has none. |
| `422` | `tender_terms_pdf_unreadable` | The company's terms PDF cannot be appended. |
| `502` | `tender_project_scope_unavailable` | The project read failed. |
| `502` | `tender_documents_unavailable` | The documents read failed. |
| `502` | `tender_terms_unavailable` | The terms read, or the terms PDF download or checksum, failed. |

## Work item name suggestions

```text theme={null}
GET /work-items/suggestions
```

Returns a deduplicated list of past work-item names across the organisation's projects. Callers can use it to surface previously used names while an estimator types.

Names are deduplicated case-insensitively on their trimmed value. The `search`
value is matched as literal text, so `%`, `_`, and `\` are ordinary characters.
When more than one work item shares the same normalised name, the most recently
updated occurrence is the exemplar — its stored spelling and project are what
the response returns.

### Query parameters

| Parameter | Type | Required | Description |
| - | - | - | - |
| `search` | string | No | Substring to filter on. Case-insensitive. Maximum 500 characters. Omit to return the most recent distinct names without filtering. |
| `months` | `"3"` \| `"6"` \| `"12"` \| `"all"` | No | How far back to look, based on the work item's last-updated date. Defaults to `"3"`. |
| `limit` | integer | No | Maximum number of distinct names to return. Range: 1–50. Defaults to 50. |
| `projectIds` | string | No | Comma-separated project UUIDs, 1 to 100 after blanks are dropped. Only work items in these projects are counted and returned. Omit for the whole organisation. Present with no UUIDs returns `400`. |
| `facets` | `"projects"` | No | Adds a top-level `projects` array to the response. Omit for no extra key. |

### Response

**200 OK** — a list envelope `{ items, totalCount, start, length }`.

Each `items` entry contains:

| Field | Type | Description |
| - | - | - |
| `sourceWorkItemId` | string | ID of the most recently updated work item with this name. |
| `name` | string | Stored name of the exemplar (original spelling and whitespace, not normalised). |
| `projectId` | string | Project that owns the exemplar work item. |
| `estimateId` | string | Estimate that owns the exemplar work item. |
| `updatedAt` | string | ISO 8601 timestamp of the exemplar's last update. |

`totalCount` is the count of distinct normalised names matching the query, not the count of raw matching rows. When `limit` is lower than `totalCount`, the response carries the truncated set and the full count so callers can render a "Showing N of M" message.

The response does not include project or client names. Those live in the Global service; join the returned `projectId` against the project register the caller already holds.

With `facets=projects`, the response also carries `projects`: one `{ projectId, count }` entry per project with live work items in the `months` window, where `count` is that project's number of distinct normalised names. The facet ignores `search` and `projectIds`, so a client filter built from it lists every project with work in the period. The **Reuse a past build-up** sheet groups these projects by client for its **Clients** filter and sends the chosen clients' projects as `projectIds`.

### Error responses

| HTTP | Cause |
| - | - |
| `400` | A query parameter fails validation (for example, `months` is not one of the accepted values, `limit` is out of range, or `projectIds` is empty, holds more than 100 entries, or holds a value that is not a UUID). |

## Re-resolve prices

```text theme={null}
POST /estimates/:estimateId/re-resolve-prices
```

Re-resolves default prices for eligible components using the current project rate context, project pricing rules, and project office location. Call this endpoint after a rate-library or location change, or when carrying an estimate into a new pricing context.

The endpoint supports two calling modes:

* **Dry run** (`dryRun: true`) — returns the per-component analysis without writing any changes and includes a `previewHash` that can be used in the confirmed mode.
* **Confirmed apply** (`dryRun: false`, `previewHash`, `resolutionInstant`) — hash-gated atomic apply. The hash echoes the one returned from a prior dry run, binding every resolved input; the server rejects the write if the preview has changed since the dry run.

### Eligibility (D12 candidates)

A component is eligible for re-resolution when all of the following are true:

* It has a live `rateItemId` (not a generated or mirror row).
* Its pricing provenance is `auto`, **or** it is a pinned or legacy component explicitly listed in `convertToAuto`.

Pinned components are excluded by default. Activity containers have no rate item and are excluded. Rate-bearing recipe-derived children remain eligible, but the apply step never writes a quantity override onto them.

Managed fixed-charge companions (components with `companionArrival: "ticked"`) cannot be re-resolved directly. They are reviewed through their parent component. Attempting to include one in `convertToAuto` returns `409` with code `managed_companion_requires_parent`.

### Request body

The request body is a discriminated union:

**Dry-run mode** (`dryRun: true`)

| Field | Type | Required | Description |
| - | - | - | - |
| `dryRun` | `true` | Yes | Returns the preview without writing changes. |
| `convertToAuto` | UUID\[] | No | Component IDs to treat as auto-provenance for this operation. |

**Confirmed apply mode** (`dryRun: false` with hash)

| Field | Type | Required | Description |
| - | - | - | - |
| `dryRun` | `false` | Yes | Applies the resolved prices. |
| `previewHash` | string | Yes | SHA-256 hex digest returned by a prior dry run. The server rejects with `409 stale_re_resolve_preview` if the preview has changed. |
| `resolutionInstant` | ISO 8601 datetime | Yes | Timestamp from the dry-run response. Stamped onto every resolved pricing entry. |
| `convertToAuto` | UUID\[] | No | Same list as used in the dry run. |

### Price resolution

A component carrying a pinned `rateOptionId` is excluded before automatic selection. For other components, project-scoped override options are considered first. The project's rate rules and pricing location form one ordered preference list, using each entry's `position`; the first matching entry with an eligible option wins. List position takes precedence over rule specificity. An earlier rate-group rule can beat a later item rule, and the pricing location can beat a rule when placed earlier. If no preference produces an option, the project's office location and then the deterministic lowest-amount candidate are tried.

A rule that covers the line but matches no candidate records `resolvedRuleOutcome` as `unsatisfiable` or `policy_unmet`, and selection continues to the next entry. Within a matching entry, the candidate with the lowest resolved line amount at the line's quantity wins, with option id ascending as the tie-break. A display rate alone does not account for bands, matrix, floors, or loadings.

**`source` rules — All-locations targets match only location-less prices.**
A `source` rule with `locationId: null` (an All-locations target) matches only the owner's prices that carry no `locationId`. It never matches a place-specific price from that owner, even a cheaper one. If the owner has no all-locations price for the item, the rule records `resolvedRuleOutcome` as `unsatisfiable` and selection continues to the next entry. A `source` rule with a specific `locationId` is unchanged.

### Response

**200 OK**

| Field | Type | Description |
| - | - | - |
| `previewHash` | string | SHA-256 hex digest of all resolved inputs. Echo this back in a subsequent confirmed apply to guarantee atomic consistency. |
| `resolutionInstant` | string | ISO 8601 timestamp used as the pricing resolution instant. Echo this back in the confirmed apply. |
| `items` | object\[] | Per-component resolution result (see below). |
| `managedTickRemovals` | object\[] | Managed fixed-charge companion components that will be removed when the parent's option changes. Each entry includes `componentId`, `componentName`, `workItemId`, `parentComponentId`, `currentOptionId`, `currentOptionUpdatedAt`, and money fields. Review the listed parent component IDs in `companionReviewParentIds` before confirming. |
| `companionReviewParentIds` | UUID\[] | Distinct parent component IDs that have at least one managed companion scheduled for removal. |
| `summary.byTier` | object | Count of resolved components by tier: `override`, `rule`, `project`, `office`, `default`. |
| `summary.changedCount` | integer | Total count of components and managed removals that differ from current pricing. |
| `summary.currency` | string | Currency code for the money summary. |
| `summary.currentAmount` | number | Current total cost across the changed components and managed removals in the preview. |
| `summary.resolvedAmount` | number | Resolved total cost across those same entries. |
| `summary.deltaAmount` | number | Difference (`currentAmount − resolvedAmount`). |
| `pinned` | object\[] | Components skipped because they have a manual override provenance. Each entry includes `componentId` and `componentName`. |
| `legacyPinned` | object\[] | Components with no provenance that are eligible for `convertToAuto`. Each entry includes `componentId` and `componentName`. Pass these IDs in `convertToAuto` on a subsequent call to include them in re-resolution. |
| `skippedChanged` | object\[] | Components omitted because the resolved money exceeded a safe magnitude or could not be represented safely. Each entry includes `componentId` and `componentName`. |
| `invalidConvertIds` | UUID\[] | IDs supplied in `convertToAuto` that are not eligible pinned or legacy candidates, or that have no eligible rate option. |
| `applied` | boolean | `true` when `dryRun` was `false` and the apply step ran. |

Each `items` entry contains:

| Field | Type | Description |
| - | - | - |
| `componentId` | UUID | Component being resolved. |
| `componentName` | string | Display name of the component. |
| `workItemId` | UUID | Work item that owns this component. |
| `tier` | string | Winning tier: `override`, `rule`, `project`, `office`, or `default`. |
| `resolvedLocation` | string \| null | Location label of the winning option, or `null` when the tier has no location (`override`, `rule`, and `default`). |
| `currentOptionId` | UUID \| null | ID of the currently stored rate option, or `null` when none is stored. |
| `currentLabel` | string \| null | Label of the currently stored option, or `null` when the option no longer exists. |
| `currentRate` | number \| null | Currently stored rate, or `null` when none is stored. |
| `resolvedOptionId` | UUID | ID of the rate option the server resolved. |
| `resolvedLabel` | string \| null | Label of the resolved rate option. |
| `resolvedRate` | number | Rate for the resolved option, inclusive of project adjustments. A value of `0` is valid. |
| `chargedQuantity` | number | Quantity the line is charged at after any minimum-quantity floor. |
| `resolvedRule` | object \| null | The project pricing rule evaluated for the line. Contains `ruleId`, `ruleKind`, `scopeKind`, `scopeRef`, `scopeLabel`, and source (`partyId`, `locationId`, `partyName`, `locationName`) or policy (`policyKind`, `policyRadiusKm`) fields. It can accompany an unsatisfied rule even when a later tier supplied the option. |
| `resolvedRuleOutcome` | `"applied"` \| `"unsatisfiable"` \| `"policy_unmet"` \| null | Resolution outcome for rule-gated selection. `null` when no rule applies. |
| `currency` | string | Three-letter currency code. |
| `currentAmount` | number | Current total cost for this component. |
| `resolvedAmount` | number | Resolved total cost for this component. |
| `deltaAmount` | number | Difference (`currentAmount − resolvedAmount`). |

> [!WARNING]
>
> `currentPriceId` and `resolvedPriceId` were renamed to `currentOptionId` and
> `resolvedOptionId`. They are option ids now, not price ids, and no alias is
> served for the old names.

### Apply behaviour

When `dryRun` is `false`, the server:

1. Runs the fan-out read (project rate context, office location, project rate rules, and rate option alternatives) before opening a transaction.
2. Opens the estimate advisory lock.
3. In confirmed mode, computes the preview hash and compares it with the supplied `previewHash`. If they differ, returns `409` with code `stale_re_resolve_preview` and a fresh `preview` payload in the error body.
4. Verifies under the estimate lock that the estimate, pricing rows, work items, and related component rows still match the fan-out snapshot. A difference aborts the whole write and returns `409 stale_re_resolve_preview` with a fresh preview.
5. Writes the previewed pricing changes atomically. A line that moves onto the mirror of its current option (same project, supplier, yard, and condition, confirmed by `mirrorsOptionId`) is not treated as a parent change; its ticked fixed-charge companions are preserved.
6. Removes managed fixed-charge companions listed in `managedTickRemovals`.
7. Never writes a quantity override on recipe-derived child rows.
8. Recomputes estimate totals once after all writes.

### Error responses

| HTTP | Code | Cause |
| - | - | - |
| `400` | Validation error | Required fields missing or invalid, or a `convertToAuto` entry is not a valid UUID. |
| `404` | — | Estimate not found or belongs to a different organisation. |
| `409` | `stale_re_resolve_preview` | Confirmed apply — the resolved inputs changed since the dry run. The error body includes a `preview` field with the current preview. |
| `409` | `managed_companion_requires_parent` | A managed fixed-charge companion was listed in `convertToAuto`. The error body includes `parentComponentId`. |
| `502` | — | A peer service (Rates API or Global API) read failed or returned an unreadable response. The error message names the failing peer. |

## Rule change preview and apply

```text theme={null}
POST /estimates/:estimateId/rule-change-preview
POST /estimates/:estimateId/rule-change-apply
```

These two endpoints let a caller preview the pricing impact of an ordered project rate rule-set change before committing it, then apply the change atomically once the Rates API has saved the new rule set.

A project's rate preferences are one ordered list: the project's rules plus the pricing location entry, in position order. The first preference in that list that covers a line prices it. The preview resolves the estimate under both the proposed rule set and the currently stored settings in a single pass, so the caller can show the user exactly what will move before anything is written.

A **frozen** estimate (`submitted`, `accepted`, `rejected`, or `archived`) returns `409 estimate_frozen` on both routes. Variation lines are never read or modified by either route.

### Rule change preview

```text theme={null}
POST /estimates/:estimateId/rule-change-preview
```

Resolves the estimate under the proposed rule set and under the currently stored settings from one shared peer-read snapshot, and returns the lines that would move plus a digest. Nothing is written.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `entries` | object\[] | Yes | The full ordered rule-set entry list — exactly one `location` entry plus every live rule, in position order (1..n). The entries use the same `source`/`policy` rule shapes as the Rates rule-set write. This preview accepts at most 200 entries; the Rates write API accepts at most 500. |
| `detail` | `"counts"` \| `"lines"` | Yes | `"counts"` returns summary counts only. `"lines"` also returns the full per-line breakdown in `groups`. |

Each entry in `entries` is one of two shapes:

**Location entry** (`kind: "location"`) — represents the pricing-location preference:

| Field | Type | Description |
| - | - | - |
| `kind` | `"location"` | Identifies this entry as the location preference. |
| `source` | `"auto"` \| `"manual"` \| `"none"` | How the location is resolved. `"none"` means the project deliberately prices from no location. |
| `locationId` | UUID \| null | Required and non-null when `source` is `"manual"`; must be null when `source` is `"none"`. |

**Rule entry** (`kind: "rule"`) — a discriminated union keyed on `ruleKind`:

| Field | Type | Description |
| - | - | - |
| `kind` | `"rule"` | Identifies this entry as a rule. |
| `id` | UUID | Present when updating an existing rule; absent when creating a new one. |
| `scopeKind` | `"group"` \| `"category"` \| `"item"` | Scope level. |
| `scopeRef` | string | Rate-group token for `group` scope; a UUID for `category` or `item` scope. |
| `ruleKind` | `"source"` \| `"policy"` | Rule type. |
| `partyId` | UUID \| null | Required for `source`; `null` targets the organisation’s own prices. |
| `locationId` | UUID \| null | Required for `source`; `null` targets the selected owner’s location-less **All locations** price. A named location selects that place. |
| `policyKind` | `"cheapest_within_km"` | Required when `ruleKind` is `"policy"`. |
| `policyRadiusKm` | number | Required when `ruleKind` is `"policy"`. Positive, at most 2000, multiple of 0.1. |
| `notes` | string \| null | Optional annotation. Maximum 500 characters. |

Entry order is position order: the first entry is position 1, the second is position 2, and so on. A live rule whose `id` is absent from the entries is deleted. A rule entry without an `id` creates a new rule.

**Response — 200 OK**

The same response shape is returned by the rule change apply endpoint on success. The `applied` flag distinguishes the two.

| Field | Type | Description |
| - | - | - |
| `impactDigest` | string | SHA-256 hex digest of the impact outcome. Pass this to the apply endpoint to guard against stale previews. The digest binds the outcome — which lines move, from which option to which, at what amounts, and under which rule semantics — not rule IDs, labels, or revision timestamps. |
| `estimateId` | UUID | Estimate the preview was computed against. |
| `currency` | string | Three-letter currency code. |
| `applied` | boolean | `false` for a preview; `true` after a successful apply. |
| `counts.repriced` | integer | Lines whose outcome changes because of the proposed rule set. |
| `counts.outOfDate` | integer | Lines the stored settings already move to the same outcome (existing drift). |
| `counts.pinnedDefaultChanges` | integer | Pinned lines whose default-by-rules differs from the pin under the proposal. |
| `counts.pinnedUnchanged` | integer | Pinned lines whose default does not change. |
| `counts.restamped` | integer | Lines whose option and amount do not change but whose governing rule snapshot will be re-stamped. |
| `totals.currentAmount` | number | Current total cost across re-priced and out-of-date lines. |
| `totals.resolvedAmount` | number | Resolved total cost across those same lines. |
| `totals.changeAmount` | number | Difference (`resolvedAmount − currentAmount`). Positive means the estimate becomes dearer. |
| `preferences` | object\[] | One row per entry in the proposed rule set (see below). |
| `groups` | object\[] \| null | Rate-group breakdown. `null` when `detail` is `"counts"`. |
| `skipped` | object\[] | Lines omitted because the resolved money could not be represented safely. Each entry contains `componentId` and `componentName`. |
| `companionReviewParentIds` | UUID\[] | Parent component IDs that have a managed fixed-charge companion scheduled for removal. |

Each entry in `preferences` contains:

| Field | Type | Description |
| - | - | - |
| `position` | integer | 1-based position of this preference in the ordered list. |
| `linesPriced` | integer | Lines the proposal prices from this preference. |
| `linesCovered` | integer | Lines this preference covers (scope matches), regardless of whether it prices them. |
| `shadowedByPosition` | integer \| null | When `linesCovered > 0` and `linesPriced === 0`, the position of the preference that actually prices those covered lines. Null when this preference prices at least one line. |

When `detail` is `"lines"`, `groups` is an array sorted by rate group:

Each entry in `groups` contains:

| Field | Type | Description |
| - | - | - |
| `rateGroup` | string \| null | Rate group token, or `null` for items outside a named group. |
| `label` | string | Display name of the rate group. |
| `lineCount` | integer | Total lines in this group. |
| `changeAmount` | number | Net cost change across all lines in this group. |
| `items` | object\[] | Rate items within this group (see below). |

Each entry in `items` contains:

| Field | Type | Description |
| - | - | - |
| `rateItemId` | UUID | Rate item ID. |
| `rateItemName` | string | Rate item display name. |
| `unit` | string | Rate item unit. |
| `lineCount` | integer | Number of changed lines for this item. |
| `changeAmount` | number | Net cost change across those lines. |
| `from` | object \| null | Current option summary: `optionId`, `supplier`, `locationName`, `unitPrice`. `null` when lines differ. |
| `to` | object \| null | Proposed option summary. `null` when lines differ or when the outcome removes the option. |
| `alternatives` | object\[] | Eligible alternatives for this item. Each entry includes `optionId`, `supplier`, `locationName`, `unitPrice`, `totalAmount`, `pickedByPosition` (the preference position that would pick this alternative), and `isDefault`. |
| `lines` | object\[] | Individual changed lines (see below). |

Each entry in `lines` contains:

| Field | Type | Description |
| - | - | - |
| `componentId` | UUID | Component being re-priced, listed as pinned, or removed. |
| `componentName` | string | Component display name. |
| `workItemId` | UUID | Work item that owns this component. |
| `workItemNumber` | string \| null | Work item number. |
| `workItemName` | string | Work item display name. |
| `quantity` | number | Line quantity. |
| `unit` | string | Line unit. |
| `kind` | `"reprice"` \| `"pinned"` \| `"companion_removed"` | `"reprice"` — the proposal changes this line's option or amount. `"pinned"` — a pinned line whose default-by-rules differs from the pin (listed unticked; the apply step decides whether to re-pin or leave it). `"companion_removed"` — a managed fixed-charge companion removed because its parent changes. |
| `cause` | `"rule"` \| `"drift"` \| null | `"rule"` — the proposal causes this change. `"drift"` — the stored settings already move it the same way. `null` on `"pinned"` and `"companion_removed"` lines. |
| `from` | object | Current option: `optionId`, `supplier`, `locationName`, `unitPrice`. |
| `to` | object | Proposed option. Fields may be `null` when the outcome removes the option. |
| `currentAmount` | number | Current cost for this line. |
| `resolvedAmount` | number | Proposed cost for this line. |
| `changeAmount` | number | Difference (`resolvedAmount − currentAmount`). |
| `pricedByPosition` | integer \| null | Position of the preference that prices this line under the proposal. `null` for `"companion_removed"` lines and for pinned lines (which are priced by the pin, not a preference). |

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `400` | — | Request body fails schema validation. |
| `401` | — | Missing or invalid bearer token. |
| `403` | — | Caller is not authorised for this estimate. |
| `404` | — | Estimate not found or belongs to a different organisation. |
| `409` | `estimate_frozen` | The estimate is frozen (`submitted`, `accepted`, `rejected`, or `archived`). Rule changes never re-price a frozen estimate. |
| `422` | `invalid_rule_set` \| `duplicate_rule_scope` \| `duplicate_project_rate_rule` \| `unknown_project_rate_rule` \| `rule_scope_immutable` \| `inactive_context_location` | The proposed rule set fails validation. |
| `502` | — | A peer service (Rates API or Global API) read failed. |

### Rule change apply

```text theme={null}
POST /estimates/:estimateId/rule-change-apply
```

Re-plans the estimate under the rule set the Rates API **now stores** and applies the result atomically, but only when the outcome still matches the previewed digest. Call this endpoint after `PUT /project-rate-settings/rule-set` in the Rates API has committed the new rule set.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `impactDigest` | string | Yes | SHA-256 hex digest from the rule change preview response. The server returns `409 stale_rule_change_preview` with a fresh preview if the current outcome no longer matches. |
| `apply` | UUID\[] | Yes | Component IDs of re-priced lines to accept at their new resolved option. Every re-priced line must appear in exactly one of `apply` or `pin`. Maximum 5000 entries. |
| `pin` | object\[] | Yes | Lines to pin at a chosen option instead of the default. Each entry contains `componentId` (UUID) and `optionId` (UUID); `optionId` must be an eligible alternative for that line. Maximum 5000 entries. |
| `unpin` | UUID\[] | Yes | Component IDs of listed pinned lines to unpin, taking their default by rules. Maximum 5000 entries. |

**Apply behaviour**

The server:

1. Re-plans under the rules currently stored in the Rates API.
2. Computes a fresh digest and compares it with the supplied `impactDigest`. If they differ, returns `409 stale_rule_change_preview` with a fresh preview. Nothing is written.
3. Validates the `apply`, `pin`, and `unpin` selections against the plan. Every re-priced line must be in exactly one of `apply` or `pin`.
4. Opens the estimate advisory lock.
5. Writes all resolutions, pins, and unpins in one transaction, together with re-stamped lines and managed companion removals.
6. Recomputes estimate totals.
7. Recounts the rate exception badge from the new state.

**Response — 200 OK**

Same response shape as a line-detail preview, with `applied: true` and populated `groups`. The response does not include a `detail` field.

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `400` | `unaccounted_lines` | A re-priced line is missing from both `apply` and `pin`, or appears more than once. |
| `400` | `unknown_rule_change_line` | A component ID in `apply`, `pin`, or `unpin` is not a line this rule change re-prices or lists as pinned. |
| `400` | `ineligible_option` | An `optionId` in `pin` is not an eligible alternative for its component. |
| `404` | — | Estimate not found or belongs to a different organisation. |
| `409` | `estimate_frozen` | The estimate is frozen. |
| `409` | `stale_rule_change_preview` | The outcome has changed since the preview. The error body includes a `preview` field with the current `"lines"` preview. |
| `502` | — | A peer service read failed. |

## Rate exceptions

Rate exceptions identify components where the current pricing selection or frozen pricing-rule outcome needs review. The server computes them on demand by running a counterfactual resolution pass against today's pricing context and comparing it with what is stored.

### List rate exceptions

```text theme={null}
GET /estimates/:estimateId/rate-exceptions
```

Returns the full list of rate exception rows for an estimate. The response is computed fresh on every call and the result is persisted as a cache (keyed by an inputs digest) so the count badge can use it without recomputing.

**Response** — `200 OK`

| Field | Type | Description |
| - | - | - |
| `rows` | object\[] | Exception rows (see below). |
| `count` | integer | Total number of non-suppressed exception rows. |
| `computedAt` | string | ISO 8601 timestamp of when this pass was computed. |

Each exception row contains:

| Field | Type | Description |
| - | - | - |
| `kind` | string | Exception kind. See [Exception kinds](#exception-kinds). |
| `lineRef.workItemId` | UUID | Work item that owns this component. |
| `lineRef.workItemName` | string | Display name of the work item. |
| `lineRef.componentId` | UUID | Component the exception applies to. |
| `lineRef.componentName` | string | Display name of the component. |
| `sentence` | string | Human-readable explanation of the exception. |
| `money` | object \| null | Cost difference when applicable. Contains `currency`, `deltaAmount`, and `perUnit`. `null` when no comparative cost is available. |
| `actions` | object\[] | Available resolution actions. Each entry has a `token` (see [Action tokens](#action-tokens)) and a `label`. Some actions include a `payload` with a `target`, `id`, and optional `fieldKey`. |
| `fingerprint` | string | SHA-256 hex digest of the exception's inputs. Changes when the underlying rate data changes. Used to verify acceptances. |

#### Exception kinds

| Kind | Meaning |
| - | - |
| `pin_beats_rule` | A manually pinned option is held over a project rule that would select a different option. |
| `override_beats_rule` | A project rate override is held over a rule that would select a different option. |
| `rule_unsatisfiable` | A covering rule matched no eligible option; the component is priced from the best available option instead. |
| `policy_unmet` | A policy rule's distance constraint is not satisfied for this component's location. |
| `rule_changed` | The component was priced under a rule that has since been changed or deleted. |
| `pin_unresolvable` | A pinned rate option is no longer available or eligible. |
| `no_eligible_option` | No eligible rate option exists for this component. |
| `band_override` | A manually selected band differs from the band the frozen rule resolves at the charged quantity. |
| `matrix_secondary_unresolved` | A rate table's side value or row choice is missing, so the line has no price. The only action is `supply_input` (**Choose row**); there is no `accept`. |
| `condition_token_miss` | The frozen rules do not contain the condition token charged on the line, so no loading applied. |
| `minimum_over_override` | A minimum-quantity rule floors a line priced from a project override. |
| `step_not_picked` | A line priced by a stepped rate has no step picked, so it has no price. The only action is `supply_input` with `fieldKey: "step"`; there is no `accept`. |
| `step_retired` | The line's picked step is no longer offered. It still prices at that step's amount. Actions are `accept` and `supply_input`. |

#### Action tokens

| Token | Meaning |
| - | - |
| `keep_pin` | Acknowledge the divergence and keep the current pinned selection. |
| `apply_rule` | Replace the current selection with the rule-selected option. Calls `POST .../apply-rule`. |
| `accept` | Record an acceptance for this exception. Calls `POST .../rate-exceptions/accept`. |
| `widen_policy` | Navigate to the pricing rule to widen its policy radius. The `payload` identifies the rule. |
| `choose_option` | Open the option picker for this component. Calls `POST .../choose-option`. |
| `follow_rule` | Clear the manual pin and let the rule select. Calls `POST .../unpin`. |
| `clear_band_override` | Clear only the manual band choice and use the automatic band. Calls `POST .../clear-band-override`. |
| `supply_input` | Navigate to a matrix or band input that is unresolved. The `payload` identifies the field. |

### Count rate exceptions

```text theme={null}
GET /estimates/:estimateId/rate-exceptions/count
```

Returns the persisted exception count without recomputing the full pass. Indicates whether the badge count is stale (i.e. the inputs have changed since it was last computed).

**Response** — `200 OK`

| Field | Type | Description |
| - | - | - |
| `count` | integer | Last persisted exception count. |
| `stale` | boolean | `true` when the current pricing inputs differ from the inputs that produced the stored count, or when no count has been persisted. |
| `computedAt` | string \| null | ISO 8601 timestamp of the last persisted computation. `null` when no count has been computed yet. |

The endpoint reads the persisted badge count and checks whether the digest of the current inputs still matches. When a peer service is unavailable during the stale check, `stale` is set to `true` rather than returning a false "fresh" status.

### Accept a rate exception

```text theme={null}
POST /estimates/:estimateId/rate-exceptions/accept
```

Records a persistent acceptance for a specific exception, suppressing it from the badge count and list. Only user tokens can accept exceptions; service tokens return `403`.

Accepting an exception records a suppression and refreshes the badge count. It does not repin a rate option or recompute totals, but a frozen estimate is read-only, so the acceptance is refused on one.

The acceptance is keyed on `(componentId, kind)`. Accepting a later exception for the same key replaces the previous acceptance.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `componentId` | UUID | Yes | Component the exception applies to. |
| `kind` | string | Yes | Exception kind to accept. |
| `fingerprint` | string | Yes | Current fingerprint from the exception row. The server returns `409 stale_fingerprint` if the exception has changed since you read it. |

**Response** — `200 OK`

| Field | Type | Description |
| - | - | - |
| `componentId` | UUID | Component the acceptance applies to. |
| `kind` | string | Accepted exception kind. |
| `fingerprint` | string | Fingerprint that was accepted. |
| `acceptedBy` | UUID | User who recorded the acceptance. |
| `acceptedAt` | string | ISO 8601 timestamp. |
| `count` | integer | Updated non-suppressed exception count after this acceptance. |

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `403` | — | Caller is not a user (only users can accept exceptions). |
| `409` | `estimate_frozen` | The estimate is frozen. See [Frozen estimate writes](#frozen-estimate-writes). |
| `409` | `stale_fingerprint` | The exception has changed since you last read it. Refresh and retry. The error body includes a `current` field with the new exception row. |

### Delete a rate exception acceptance

```text theme={null}
DELETE /estimates/:estimateId/rate-exceptions/accept
```

Removes a previously recorded acceptance for a `(componentId, kind)` pair, making the exception visible again. Invalidates the persisted badge count so the next panel open recomputes it.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `componentId` | UUID | Yes | Component whose acceptance to remove. |
| `kind` | string | Yes | Exception kind whose acceptance to remove. |

**Response** — `204 No Content`

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `estimate_frozen` | The estimate is frozen. An acceptance cannot be removed on a frozen estimate. |

### Apply rule to a component

```text theme={null}
POST /estimates/:estimateId/lines/:componentId/apply-rule
```

Replaces the component's current pricing selection with the option the active project rule would select. For a `rule_changed` exception whose old rule no longer covers the line, it follows the ordinary resolution ladder. The new selection is written with provenance `auto`.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `fingerprint` | string | Yes | Current fingerprint from the exception row. Returns `409 stale_fingerprint` if stale. |

**Response** — `200 OK` — the updated component and pricing override:

| Field | Type | Description |
| - | - | - |
| `component` | object | Updated component row. |
| `pricingOverride` | object \| null | Updated pricing override entry for this component. |

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `estimate_frozen` | The estimate is submitted, accepted, rejected, or archived. Rate-exception decisions are not permitted on a frozen estimate. |
| `409` | `stale_fingerprint` | Exception changed since you read it. |
| `409` | — | Rule no longer selects an eligible option. |

### Choose an option for a component

```text theme={null}
POST /estimates/:estimateId/lines/:componentId/choose-option
```

Manually pins a specific rate option onto a component. The new selection is written with provenance `pinned`.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `rateOptionId` | UUID | Yes | Rate option to apply. Must be eligible and comparable for this component. |

**Response** — `200 OK` — same shape as apply-rule response.

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `estimate_frozen` | The estimate is submitted, accepted, rejected, or archived. Rate-exception decisions are not permitted on a frozen estimate. |
| `422` | — | The chosen rate option is not eligible and comparable for this component. |

### Unpin a component

```text theme={null}
POST /estimates/:estimateId/lines/:componentId/unpin
```

Clears a manual pin and sets provenance back to `auto`, allowing the next re-resolve pass to apply the project rule or default selection. Returns `422` if no eligible option is available after unpinning.

**Request body** — empty object `{}`

**Response** — `200 OK` — same shape as apply-rule response.

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `estimate_frozen` | The estimate is submitted, accepted, rejected, or archived. Rate-exception decisions are not permitted on a frozen estimate. |
| `422` | — | No eligible option is available after unpinning. |

### Clear a band override

```text theme={null}
POST /estimates/:estimateId/lines/:componentId/clear-band-override
```

Clears a manual band selection without unpinning the rate option or refreshing its frozen pricing-rule snapshot. The component returns to the automatic band for its stored charged quantity.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `fingerprint` | string | Yes | Current fingerprint from the `band_override` exception row. |

**Response** — `200 OK` — same shape as the apply-rule response.

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `estimate_frozen` | The estimate is frozen. A band override cannot be cleared on a frozen estimate. |
| `409` | `stale_fingerprint` | The exception or its offered actions changed since you read it. The body includes the component's current exception rows. |

## Project rate links

These routes manage the relationship between work items in an estimate and the project's rate catalogue. They cover three distinct operations:

* **Attaching** a project rate option to a work item as a candidate (before it is priced).
* **Inserting** a project rate into one or more work items as a priced component.
* **Reading** which project rate options are used in an estimate and where.

All write endpoints take the estimate-totals lock and validate before the first write. A refused write never leaves a partially applied change. These endpoints refuse with `409 estimate_frozen` when the estimate status is `submitted`, `accepted`, `rejected`, or `archived`.

### Get project rates for a work item

```text theme={null}
GET /estimates/:estimateId/work-items/:workItemId/project-rates
```

Returns the project rate links for one work item:

* `attached` — rate options attached to this work item but not yet inserted as priced components.
* `costed` — priced component lines on this work item that are priced from the project's rates.

**Response — 200 OK**

| Field | Type | Description |
| - | - | - |
| `estimateId` | uuid | |
| `workItemId` | uuid | |
| `projectId` | guid | The project this estimate belongs to. |
| `attached` | array | Attachment records — see below. |
| `costed` | array | Costed lines — see below. |

Each `attached` entry:

| Field | Type | Description |
| - | - | - |
| `id` | uuid | Attachment ID. |
| `estimateId` | uuid | |
| `workItemId` | uuid | |
| `rateOptionId` | guid | The attached rate option. |
| `rateItemId` | guid | The rate item the option prices. |
| `suggestedQuantity` | number or null | The quantity suggested for insertion, or `null` when no suggestion is recorded. |
| `quantityBasis` | `per_unit` or `total` | Whether the suggested quantity is per work-item unit or a whole-item total. |
| `source` | string | Where the attachment originated (e.g. `document_suggestion`). |
| `sourceDocumentId` | guid or null | The source document, when applicable. |
| `createdBy` | guid or null | User who created the attachment. |
| `createdAt` | datetime | |
| `updatedAt` | datetime | |

Each `costed` entry:

| Field | Type | Description |
| - | - | - |
| `componentId` | uuid | The work item component row that carries this line. |
| `workItemId` | uuid | |
| `componentName` | string | |
| `rateOptionId` | guid | The project rate option this line is priced from. |
| `rateItemId` | guid | |
| `quantity` | number | |
| `quantityBasis` | `per_unit`, `total`, or null | `per_unit` means the quantity is multiplied by the work item's quantity; `total` means the stored quantity is charged once. `null` for lines where neither basis applies (e.g. an activity child or a derived quantity row). |
| `unit` | string | |
| `unitCost` | number or null | |
| `lineCost` | number | |
| `provenance` | `auto`, `pinned`, or `legacy` | How the option was resolved. |

### Attach a project rate to a work item

```text theme={null}
POST /estimates/:estimateId/work-items/:workItemId/project-rate-attachments
```

Records that a project rate option should be considered for insertion on a work item. An attachment is a candidate, not a priced line. Use it to flag work items for later bulk insertion.

Re-attaching the same rate option to the same work item updates the suggestion fields rather than returning `409`, so a document import that records the same work item twice is idempotent.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `rateOptionId` | guid | Yes | A current project rate option for this estimate's project. |
| `rateItemId` | guid | Yes | Must match the option's rate item. |
| `suggestedQuantity` | number or null | No | A suggested quantity for later insertion. |
| `quantityBasis` | `per_unit` or `total` | Yes | How to interpret the suggested quantity. |
| `source` | string | Yes | Origin of the attachment (e.g. `document_suggestion`, `manual`). |
| `sourceDocumentId` | guid or null | No | Source document reference. |

**Response**

* `201 Created` — the attachment was created.
* `200 OK` — an existing attachment for the same option was updated.

The response body is the attachment record (same shape as the `attached` array entries above).

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `estimate_frozen` | Estimate is frozen. |
| `422` | `not_a_project_rate` | The rate option is not a current project rate for this estimate's project. |
| `422` | `rate_item_mismatch` | The supplied `rateItemId` does not match the option's item. |

### Delete a project rate attachment

```text theme={null}
DELETE /project-rate-attachments/:id
```

Removes a project rate attachment. Returns `204 No Content`. The estimate must not be frozen.

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `404` | — | Attachment not found or belongs to a different organisation. |
| `409` | `estimate_frozen` | Estimate is frozen. |

### Get project rate usage

```text theme={null}
GET /estimates/:estimateId/project-rate-usage
```

Returns, for every project rate option referenced in the estimate, where it is attached and where it is priced. Use this to build a summary view of all project rate usage across an estimate.

**Response — 200 OK**

| Field | Type | Description |
| - | - | - |
| `estimateId` | uuid | |
| `projectId` | guid | |
| `currency` | string | The estimate's currency. |
| `options` | array | One entry per project rate option referenced in the estimate. |

Each `options` entry:

| Field | Type | Description |
| - | - | - |
| `rateOptionId` | guid | |
| `rateItemId` | guid | |
| `attached` | array | Work items with an uninserted attachment for this option. Each entry includes `workItemId`, `workItemNumber`, `workItemName`, `attachmentId`, `suggestedQuantity`, and `quantityBasis`. |
| `costed` | array | Work item component lines priced from this option. Each entry includes `workItemId`, `workItemNumber`, `workItemName`, `componentId`, `quantity`, `quantityBasis`, `unit`, `lineCost`, and `provenance`. |
| `costedLineCost` | number | Sum of all costed line costs for this option in this estimate, exact to the cent. |

### Insert a project rate into work items

```text theme={null}
POST /estimates/:estimateId/work-items/insert-project-rate
```

Inserts a project rate as a priced component into one or more work items in a single transaction. The rate item is inserted at the cheapest current project rate, which may differ from the rate option you requested.

Any attachments on the targeted work items for the same rate item are deleted as part of the insert — the insertion fulfils them. You can also name specific attachment IDs in `attachmentIds` to delete additional attachments from this rate item on the targeted work items.

The insert validates every target before writing. If any target fails (e.g. the item is already priced from this project's rates), the entire operation is refused and nothing is written.

After a successful insert, Stralos recomputes the rate exception count for the estimate.

Every inserted line sits inside an activity. A target with `parentComponentId` (an activity on that work item, library activities included) adds the line at the end of that activity; only `total` is accepted there. In an activity with daily production the line is charged `whole_activity`; otherwise it is stored per unit of the item so its total is the typed quantity. A target without one puts the line in a new activity named after the rate, so the line's money is what the caller previewed. Each `inserted` entry also carries `parentComponentId` and, when the insert created one, `activityContainer`.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `rateOptionId` | guid | Yes | A current project rate option. The item is inserted at the cheapest project rate, not necessarily this option. |
| `targets` | array | Yes | Work items to insert into (1–100 entries). Each target has `workItemId` (uuid), `quantity` (positive number), `quantityBasis` (`per_unit` or `total`), and optional `parentComponentId`. |
| `attachmentIds` | uuid\[] | No | Additional attachment IDs to delete on commit (up to 500). |

**Response — 201 Created**

| Field | Type | Description |
| - | - | - |
| `estimateId` | uuid | |
| `requestedOptionId` | guid | The option from the request body. |
| `inserted` | array | One entry per target. Each entry includes `workItemId`, `componentId`, `pricedOptionId` (the cheapest project rate actually used), `lineCost`, `component` (the full component row), and `pricingOverride` (the saved pricing override entry). |
| `workItems` | array | Updated `totalCost` and `totalPrice` for each affected work item. |
| `deletedAttachmentIds` | uuid\[] | Attachment IDs removed as part of this insert. |

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `estimate_frozen` | Estimate is frozen. |
| `409` | `already_inserted` | The rate item is already priced from this project's rates on one of the targeted work items. |
| `422` | `not_a_project_rate` | The rate option is not a current project rate for this estimate's project. |
| `422` | `duplicate_target` | A work item appears more than once in `targets`. |
| `422` | `project_rate_unpriceable` | The project rate cannot be priced on one of the targeted work items at the given quantity or unit. |
| `422` | `attachment_not_found` | An ID in `attachmentIds` is not an attachment of this rate item on the targeted work items. |
| `422` | `insert_target_not_activity` | `parentComponentId` is not an activity on that work item. |
| `422` | `insert_activity_deleted` | The named activity was deleted. |
| `422` | `insert_per_unit_in_activity` | A `per_unit` quantity was sent with `parentComponentId`. |
| `422` | `insert_fixed_price_package` | The named activity has a fixed price. |
| `422` | `insert_zero_quantity_item` | The work item's quantity is zero, so a per-unit quantity cannot be worked out. |

## Automatic re-price

```text theme={null}
POST /projects/:projectId/project-rate-reprice
```

Called automatically by the Rates service after a project rate write commits. It scans the named estimate for lines priced from the changed rate items and moves each line to the best current project rate. The operation is server-authored and idempotent: a second call with the same changes moves nothing.

This endpoint is called with the user's bearer token by `api-rates` after a project rate write. Rule-list changes use the separate rule-change preview and apply flow. It is not normally called directly by client applications.

After the re-price commits, Stralos recomputes the rate exception count even when no line moved — a new quote can create an exception without moving any existing line.

**Query parameters**

| Parameter | Type | Description |
| - | - | - |
| `estimateId` | uuid (optional) | The specific estimate to re-price. When omitted, the project's primary (default) estimate is used. Supply this parameter when re-pricing an alternative tender estimate on a project with multiple estimates. |

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `changes` | array | Yes | Rate option changes to scan (1–500 entries). Each entry has `rateItemId` (guid), `rateOptionId` (guid), and `change` (`created`, `updated`, or `deleted`). |

**Response — 200 OK**

| Field | Type | Description |
| - | - | - |
| `estimateId` | uuid | The estimate that was re-priced. |
| `linesMoved` | number | Total lines moved (including removed companion lines). |
| `lines` | array | One entry per moved line. Each entry includes `componentId`, `workItemId`, `fromOptionId`, `toOptionId`, `fromUnitCost`, and `toUnitCost`. A removed companion line has `toOptionId: null` and `toUnitCost: null`. |

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `404` | `no_estimate` | The project has no estimate to re-price. |
| `409` | `estimate_frozen` | The estimate is frozen. |
| `422` | `no_project_rate_changes` | None of the supplied changes names a rate option of this project. |

***

## Subcontract bid review

```text theme={null}
GET /subcontract-packages/:id/review
```

Returns the whole-return review for a subcontract package. The review gives estimators a side-by-side comparison of each contractor bid rate against the internal build-up cost, plus a `selectable` flag and per-rate warnings to guide rate selection.

The response is computed under the estimate-totals advisory lock and covers the package, its associated work items, and all bid rates received for those items.

**Authentication:** requires a valid bearer token scoped to the package's organisation. An unknown package returns `404` without leaking cross-tenant information.

### Response

**200 OK**

| Field | Type | Description |
| - | - | - |
| `package` | object | The subcontract package row. |
| `currency` | string | Estimate currency code. |
| `workItems` | object\[] | Work items assigned to this package (see below). |
| `bidRates` | object\[] | Bid rates received for this package (see below). |
| `revision` | string | SHA-256 hex digest of the review snapshot. Echo this to the batch endpoint as `reviewedBidPackage.revision` to guard against concurrent changes before a rate selection is applied. |

Each `workItems` entry contains:

| Field | Type | Description |
| - | - | - |
| `id` | UUID | Work item ID. |
| `itemNumber` | string \| null | Work item number. |
| `name` | string | Work item display name. |
| `unit` | string | Work item unit. |
| `quantity` | number | Work item quantity. |
| `parentWorkItemId` | UUID \| null | Parent work item ID for sub-items. |
| `updatedAt` | string | ISO 8601 timestamp of last update. |
| `internalCost` | number \| null | Internal build-up cost in dollars, computed from the item's own frozen build-up regardless of any selected subcontract pricing source. `null` when the build-up cannot be represented (for example, when an unrepresentable unit cost is stored). |
| `pricingSource` | string | Current pricing source for this item: `"internal"` or `"subcontract:<bidRateId>"`. |
| `hasPackagePricing` | boolean | `true` when the item has a package-sell component. Items with package pricing are not eligible for individual rate selection. |

Each `bidRates` entry contains:

| Field | Type | Description |
| - | - | - |
| `id` | UUID | Bid rate ID. |
| `workItemId` | UUID | Work item this bid rate was submitted for. |
| `rate` | number | Submitted unit rate. |
| `unit` | string \| null | Unit submitted by the contractor. `null` for lump-sum quotes. |
| `quantity` | number \| null | Quantity submitted by the contractor. `null` for lump-sum quotes. |
| `notes` | string \| null | Optional notes from the contractor. |
| `amount` | number \| null | Effective bid amount in dollars. `null` when the amount cannot be computed (triggers `amount_unavailable` warning). |
| `selectable` | boolean | Whether this bid rate is eligible to be applied to the work item via the batch endpoint. `false` when any disqualifying condition is present (see warnings). |
| `warnings` | string\[] | Zero or more warning tokens (see below). |

**Warning tokens**

| Token | Meaning |
| - | - |
| `amount_unavailable` | The effective bid amount could not be computed. |
| `zero_rate` | The submitted rate is zero. |
| `lump_sum` | The bid was submitted without a unit or quantity (lump-sum format). |
| `item_unavailable` | The work item this rate was submitted for is no longer in the package. |
| `basis_changed` | The submitted unit or quantity no longer matches the work item's current unit or quantity. |
| `internal_cost_unavailable` | The internal build-up cost could not be computed for comparison. |
| `package_uses_internal` | The work item has a package-sell component; individual rate selection is not supported. |
| `duplicate_rate` | More than one bid rate exists for this work item in this package. |

A bid rate is selectable only when: `amount` is not null, `internalCost` is not null, `hasPackagePricing` is false, `basisMatches` is true, the rate is the only bid for its work item, the package response has not been declined, and the package status is one of `draft`, `issued`, or `estimates_received`.

### Error responses

| HTTP | Cause |
| - | - |
| `401` | Missing or invalid bearer token. |
| `404` | Package not found or belongs to a different organisation. |

***

## Contractor portal capability tokens

The token in `GET /subcontract-portals/:token` is a bearer capability. Treat it as a
credential: do not log it, place it in analytics payloads, or include it in support
messages.

An unknown capability returns `404` with the generic message
`Subcontract portal not found`. The API deliberately does not echo the supplied token
in that response.

All three public portal routes are unauthenticated. They carry `security: []` in the OpenAPI document. The organisation is resolved server-side from the capability token using a `SECURITY DEFINER` function; no bearer token is accepted or required.

The `GET /subcontract-portals/:token` read endpoint remains accessible regardless of estimate status. A contractor can open their portal and read back their submitted rates even after the tender has been submitted. The two write endpoints (`PUT .../rates/:workItemId` and `POST .../response`) return `409 estimate_frozen` when the estimate has been submitted.

## Save a portal bid rate

```text theme={null}
PUT /subcontract-portals/:token/rates/:workItemId
```

Saves or updates the contractor's quoted rate for one work item in the package. This is an unauthenticated write — no bearer token is needed or accepted.

The unit and quantity must match the work item's values in the estimate. The server canonicalises the supplied unit string before comparing. If the unit or quantity does not match, the request returns `422`.

Saving a rate does not select it as the active pricing source. The estimator makes that selection explicitly in the estimate workspace.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `rate` | number | Yes | Quoted rate. Must be ≥ 0 and less than the maximum money value. Up to 2 decimal places. |
| `unit` | string | Yes | Unit that the rate is quoted in. Must match the work item's unit after canonicalisation. |
| `quantity` | number | Yes | Quantity the rate applies to. Must match the work item's quantity and be greater than 0. |
| `notes` | string | No | Optional notes from the contractor. Maximum 4,000 characters. |

**Response — 200 OK**

The saved bid rate record, with internal fields (`organisationId`, `sourceDocumentId`) stripped:

| Field | Type | Description |
| - | - | - |
| `id` | UUID | Bid rate ID. |
| `packageId` | UUID | Package the bid rate belongs to. |
| `workItemId` | UUID | Work item this rate is for. |
| `rate` | number | Saved rate. |
| `unit` | string | Saved unit. |
| `quantity` | number | Saved quantity. |
| `notes` | string \| null | Saved notes. |
| `validUntil` | string \| null | Validity date, if set. |
| `updatedAt` | string | ISO 8601 timestamp of the last save. |

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `404` | — | Token not found, or the work item is not listed in this package. |
| `409` | `estimate_frozen` | The tender has been submitted. The package is closed for contractor writes. |
| `409` | — | The package is not in an editable state (the contractor has already submitted or declined, or the package is closed or awarded). |
| `422` | — | Unit or quantity does not match the work item's values in the estimate. |

## Submit or decline a portal quote

```text theme={null}
POST /subcontract-portals/:token/response
```

Records the contractor's formal response for the package. This is an unauthenticated write — no bearer token is needed or accepted.

Two actions are available:

* **`submit`** — marks the package as `estimates_received` and sets `responseStatus` to `submitted`. At least one bid rate must be saved before submitting. Duplicate or unresolvable rates return `409`.
* **`decline`** — marks the package as `closed` and sets `responseStatus` to `declined`. No rates are required.

A response is idempotent when the same action is sent more than once — the stored timestamp (`respondedAt`) is preserved and not updated on replay. Sending a different action after a response has been recorded returns `409`.

After a response is recorded, the portal becomes read-only. The contractor can no longer save or edit rates. The estimator must reopen the package (set its `status` back to `issued`) before any further edits are possible. Reopening clears `responseStatus` and `respondedAt`.

A response never changes the estimator's selected pricing source, recalculates money, or awards the package.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `action` | `"submit"` \| `"decline"` | Yes | The contractor's response. |

**Response — 200 OK**

| Field | Type | Description |
| - | - | - |
| `id` | UUID | Package ID. |
| `status` | string | Updated package status (`estimates_received` or `closed`). |
| `responseStatus` | string | `submitted` or `declined`. |
| `respondedAt` | string | ISO 8601 timestamp when the response was first recorded. |
| `missingCount` | number | Number of work items in the package with no saved rate. |
| `zeroCount` | number | Number of saved rates with a value of 0. |

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `404` | — | Token not found. |
| `409` | — | A different response action has already been recorded for this package. |
| `409` | `estimate_frozen` | The tender has been submitted. The package is closed for new contractor responses. A previously recorded response is still returned (idempotent replay is allowed). |
| `409` | — | The package is not in an editable state (already closed, awarded, or in another terminal state). |
| `422` | — | `action` is `submit` and no bid rates have been saved. |
| `409` | — | `action` is `submit` and duplicate rates exist for a work item. Ask the estimator to resolve them. |

## Package `responseStatus` and `respondedAt`

Two fields are added to subcontract package rows to track contractor responses:

| Field | Type | Description |
| - | - | - |
| `responseStatus` | `"submitted"` \| `"declined"` \| null | Set when the contractor submits or declines via the portal. `null` means no response has been recorded, or the package has been reopened. |
| `respondedAt` | string \| null | ISO 8601 timestamp when the contractor first responded. Cleared to `null` when the package is reopened. |

These fields are returned on the package list endpoint, `GET /estimates/:id/subcontract-packages`, and on the `package` object of `GET /subcontract-packages/:id/review`. They are read-only outside of the portal response and the reopen path.

**Reopening a package**

Setting `status` to `"issued"` on `PUT /subcontract-packages/:id` when `responseStatus` is not null is treated as a reopen. The server clears `responseStatus` and `respondedAt` in the same write, making the portal editable again. Any other `PUT` on a package with a recorded response returns `409 Conflict` until it is reopened.

**Deletion guard**

`DELETE /work-items/:id` returns `409 Conflict` when the work item is a member of a package that has a recorded response. The estimator must reopen the package to `issued` before the work item can be deleted.

## Work item TAG links

```text theme={null}
GET /work-items/:id/tags
POST /work-items/:id/tags
```

`GET /work-items/:id/tags` returns the tender clauses linked to the work item,
ordered by group (case-insensitive, ungrouped clauses last), then `sortOrder`, then `id`.

`POST /work-items/:id/tags` adds links, removes links, and creates new free-text
TAGs in one mutation transaction. If any mutation fails, every mutation rolls
back. The handler lists the resulting links after the transaction commits, so
a later response-read or transport failure does not undo a successful write.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `add` | UUID\[] | No | Existing tender-clause IDs to link. Each clause must belong to the work item's estimate. Defaults to `[]`. |
| `remove` | UUID\[] | No | Tender-clause IDs to unlink. Defaults to `[]`. |
| `create` | `{ text: string, group?: string \| null }[]` | No | New free-text TAGs to create and link. `text` is required and limited to 4,000 characters. `group` is optional free text (absent, `null`, or `""` means ungrouped). Defaults to `[]`. |

An ID appearing in both `add` and `remove` returns `422`.

Each `create` entry produces an included manual clause in the
`tags_clarifications` list. No code is generated. `baseItemId` is `null`, its sort order follows
the existing list, and the organisation, project, and estimate IDs come from
the work item and estimate rather than the request. Creation and linking are
atomic, including when the request also adds or removes existing links.

### Response

**200 OK**

| Field | Type | Description |
| - | - | - |
| `items` | object\[] | Complete post-write list of clauses linked to the work item. |
| `created` | `{ id, group, text }[]` | Clauses created by this request; empty when `create` was omitted. `group` is the stored group value, or `null` when ungrouped. |

### Error responses

| HTTP | Code | Cause |
| - | - | - |
| `400` | — | The path or request body fails schema validation. |
| `404` | — | The work item is missing or outside the caller's organisation. |
| `409` | `estimate_frozen` | The estimate is submitted, accepted, rejected, or archived. TAG links and free-text clause creation are not permitted on a frozen tender. |
| `422` | — | `add` and `remove` overlap, or a clause in `add` does not belong to the work item's estimate. |

## TAGs/tender clauses sync-on-read

`GET /estimates/:id/tender-clauses` triggers a sync step before listing. On each call, the server fetches the organisation's departures and clarifications from the global API (`GET /api/global/departures-clarifications`) and inserts any items not yet present on the estimate, using the global item's ID as `baseItemId` to detect duplicates. The response is then returned ordered by group (case-insensitive, ungrouped clauses last), sort order, and ID.

### Tender clause schema

| Field | Type | Description |
| - | - | - |
| `group` | string \| null | Free-text group label (for example `"Concrete"`). `null` means ungrouped; displayed as **General** and listed last. Accepted on `POST` and `PUT`; blank or `""` clears the group to `null`. |
| `code` | string \| null | Clause code. Now nullable — new clauses written by the server carry no code. Existing clauses retain their stored code. Still accepted on `POST` and `PUT` for backwards compatibility; omit it to write no code. |
| `text` | string | Clause text. |
| `included` | boolean | Whether the clause is included in the tender position. |
| `sortOrder` | integer | Position within the group. |
| `listKey` | string | Legacy list identifier. Defaults to `tags_clarifications` when omitted. |

### Sync behaviour

* **Single source**: the server calls one endpoint (`GET /api/global/departures-clarifications`), not two separate setting lists. Clauses from the legacy `tags_clarifications` and `allowances_assumptions` lists that were previously copied retain their stored `listKey`; new synced clauses use `tags_clarifications`.
* **Group stamping**: each synced clause receives the global item's `group` value (trimmed; blank or absent becomes `null`). No code is written on synced rows.
* **Idempotent**: rows already present (matched by `estimateId` + `baseItemId`) are skipped via `ON CONFLICT DO NOTHING`. Concurrent first reads are safe.
* **Non-destructive**: an existing clause that has been edited, regrouped, or excluded by the project retains its project-specific values. The sync never overwrites an existing row, including a clause whose group was changed on the estimate.
* **Frozen-safe**: the sync step is skipped entirely when the estimate is frozen (submitted, accepted, rejected, or archived). The endpoint returns `200` with whatever clauses exist on the frozen estimate. This ensures a frozen tender's clause list reflects exactly what was submitted.
* **Fail-soft**: if the global service is unreachable, returns a non-2xx response, or returns an unreadable body, the sync step is silently skipped and the endpoint returns `200` with whatever clauses already exist.
* **Org-gated**: the sync only runs for authenticated callers with a resolved organisation ID. Anonymous callers receive `401`; authenticated platform or null-org callers receive `403`.
* **Field mapping**: each synced clause is stamped with `organisationId` and `projectId` from the estimate row (not from the caller's token). The global item's `label` maps to `text`. Values that exceed the column length limit are clamped with a server-side warning rather than causing an error.

The generic CRUD write routes (`POST /tender-clauses`, `PUT /tender-clauses/:id`, `DELETE /tender-clauses/:id`) are unaffected and do not trigger any sync. The CRUD write routes (`POST`, `PUT`, `DELETE`) return `409 estimate_frozen` when the estimate is frozen.

## Takeoff map sheets

A map sheet is a named, saved map view (centre, zoom, and imagery layer) that map measurements are drawn on. It is the map equivalent of a drawing page.

```text theme={null}
GET    /estimates/:id/takeoff-map-sheets
GET    /takeoff-map-sheets
GET    /takeoff-map-sheets/:id
POST   /takeoff-map-sheets
PUT    /takeoff-map-sheets/:id
PATCH  /takeoff-map-sheets/:id/view
DELETE /takeoff-map-sheets/:id
```

`GET /estimates/:id/takeoff-map-sheets` returns the estimate's live sheets, oldest first. A sheet row returns its view as `centreLat`, `centreLng`, `zoom`, and `layer`.

**Create request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `estimateId` | UUID | Yes | Estimate the sheet belongs to. |
| `name` | string | Yes | Display name. |
| `centre` | object | Yes | `{ lat, lng }` in WGS84 degrees (`lat` -90 to 90, `lng` -180 to 180). |
| `zoom` | number | Yes | 0 to 22. |
| `layer` | string | Yes | `google-satellite`, `google-hybrid`, `google-road`, or `linz-aerial`. |

`PUT /takeoff-map-sheets/:id` accepts `{ name }` only. The view is saved separately with `PATCH /takeoff-map-sheets/:id/view`, which accepts `{ centre, zoom, layer }` in the same shapes, so a view save sent while the map pans never races a rename. The `PATCH` returns `409 deleted` on a deleted sheet.

`DELETE /takeoff-map-sheets/:id` soft-deletes the sheet and returns `204`. A repeat delete also returns `204`. The delete is refused with `409 map_sheet_in_use` and `{ linkedMeasurementCount }` while any live measurement on the sheet is linked to a work item; unlink or delete those measurements first. Otherwise only the sheet is stamped: its measurements are not deleted, but they drop out of measurement lists because lists include only measurements on live sheets.

Every write returns `409 estimate_frozen` on a frozen estimate.

## Map measurements

Takeoff measurements carry a `coordinateSpace`:

| Field | Description |
| - | - |
| `coordinateSpace` | `page` (default) for a drawing measurement, or `geo` for a map measurement. Cannot change after creation. |
| `mapSheetId` | The map sheet a `geo` measurement is drawn on. `null` for `page` measurements. |
| `measuredAt` | When the server last computed a `geo` measurement's quantity. Moves only when the points change. `null` for `page` measurements. |
| `sourceSnapshot` | Optional provenance for a `geo` measurement created from a [council utility](#council-utilities) feature. `null` otherwise. |

**Creating a map measurement.** `POST /takeoff-measurements` with `coordinateSpace: "geo"` takes `estimateId`, `mapSheetId`, `label`, `type` (`line`, `area`, or `count`), `points` as `{ lat, lng }` (at most 2,000), `quantity`, and optional `workItemId`, `category`, `unit`, and `sourceSnapshot`. There is no `documentId`, `scale`, `paperSize`, or `pageNumber`. The server computes the quantity geodesically and stores it with `scale: "geodesic"`. The unit is fixed by type (`line` in `metre`, `area` in `square_metre`, `count` in `each`) and may be omitted.

**Updating a map measurement.** `PUT /takeoff-measurements/:id` recomputes the quantity from new points under the same rules. When the quantity changes, a linked work item that was created from the measurement and whose quantity has not been edited by hand takes the new quantity in the same transaction. This also applies to drawing measurements.

| HTTP | Code | Cause |
| - | - | - |
| `409` | `deleted` | The map sheet has been deleted. |
| `422` | `map_sheet_not_in_estimate` | The map sheet does not belong to the measurement's estimate. |
| `422` | `too_few_points` | Fewer points than the type needs (line 2, area 3, count 1). |
| `422` | `self_intersecting_area` | An area's edges cross each other. |
| `422` | `unit_mismatch` | The supplied unit is not the type's fixed unit. |
| `422` | `quantity_mismatch` | The supplied `quantity` differs from the server's by more than `max(0.5%, 0.01)`. The body includes `clientQuantity`, `serverQuantity`, and `tolerance`. |
| `422` | `coordinate_space_immutable` | A `PUT` tried to change `coordinateSpace`. |
| `422` | `wrong_coordinate_space_field` | A `PUT` sent a drawing-only field to a map measurement, or `mapSheetId` to a drawing measurement. |
| `422` | `wrong_coordinate_space_points` | A `PUT` sent points of the other kind (`{ x, y }` versus `{ lat, lng }`). |

## Multi-line and Multi-segment takeoff

A Multi-line (`type: "multi_line"`) and a Multi-segment (`type: "multi_segment"`) are structured measurement types that group spans or lines by org takeoff tag. Both types store a `parts` descriptor alongside the flat `points` array.

### `parts` structure

| `kind` | Description |
| - | - |
| `"lines"` | Multi-line. `starts` is an array of point indices (≤ 200) where each line begins. A Multi-line may carry an optional `tags` array (one UUID or `null` per line, same length as `starts`) and a `tagMeta` object mapping each used tag UUID to `{ label, colour }`. Omitting `tags` creates an untagged Multi-line. |
| `"spans"` | Multi-segment. `tags` is required (one UUID or `null` per span, `points.length − 1` entries). `tagMeta` maps each used tag UUID to `{ label, colour }`. |

Clients supply `spanLengths` on create and on any `PUT` that changes the number of lines, adds or removes points, or shifts line starts. When a `PUT` changes only the `tags` in `parts` and leaves `starts` unchanged, the server re-groups the already-stored per-line lengths without requiring `spanLengths`.

### `quantityBreakdown`

The server stores and returns a `quantityBreakdown` field alongside `parts`. It is never accepted from the client.

| Field | Present when |
| - | - |
| `v: 1, kind: "lines", lineLengths` | Any Multi-line. Per-line lengths in the same order as `starts`. |
| `byTag` | Tagged Multi-line and Multi-segment. Array of `{ tag, quantity, spans, lines? }` objects in tag-encounter order. `lines` is present on Multi-line entries. |

### Tagging and quantity links

When a Multi-line or Multi-segment has tags, each tag subtotal becomes a linkable source alongside the measurement total:

* **Candidates** (`GET /estimates/:estimateId/work-items/:itemId/quantity-link/candidates` and the component variant) include a `byTag` array on each tagged measurement candidate. Each entry has `tag`, `label`, `quantity`, `spans`, and `lines` (Multi-line only).
* **Usage** (`GET /estimates/:id/takeoff-measurements/usage`) returns one entry per source (total or tag). Each entry carries `measurementId`, `tag` (UUID or `null` for the total), `workItems`, `components`, and `calculatorFields`.
* **Creating a work item from a tagged measurement.** `POST /estimates/:id/work-items/from-measurement` accepts an optional `tag` UUID. When supplied, the new work item takes its quantity from that tag subtotal and is linked to it. The work item name is `<measurement label> — <tag label>`.
* **Quantity link descriptor.** A `takeoff` source with a non-empty `tag` links to that tag's subtotal. A descriptor may not mix the total of a measurement with one of its own tag subtotals (`quantity_link_takeoff_overlap`).

### R15 — refusing edits that remove a linked tag

`PUT /takeoff-measurements/:id` checks whether the edit would remove a linked tag from the measurement. An edit removes a linked tag when it repoints that tag's last line (Multi-line) or last span (Multi-segment) to another tag, or removes the points that carry it.

When the edit would remove a linked tag, the server returns `409 takeoff_source_removed` without writing. The body contains:

| Field | Description |
| - | - |
| `code` | `"takeoff_source_removed"` |
| `removed.total` | `true` if the edit also removes the measurement's total as a source. |
| `removed.tags` | Array of linked tag UUIDs that would be removed. |
| `usage` | Per-source usage list, the same shape as the usage endpoint. |

Adding `?unlink=true` to the request applies the edit and unlinks all affected users. The response body then includes `unlinked`: an array of `{ workItemId, quantity }` for every work item that was unlinked (frozen at its last value).

| HTTP | Code | Cause |
| - | - | - |
| `409` | `takeoff_source_removed` | The edit would remove a tag (or the total) that is linked to a work item, build-up component, or calculator input. |
| `422` | `invalid_takeoff_parts` | `tags` length does not match `starts` (Multi-line) or the number of spans (Multi-segment), or `tagMeta` is present without `tags`. |
| `422` | `span_lengths_required` | The `PUT` changed line starts without supplying `spanLengths`. |
| `422` | `quantity_link_tag_missing` | A `tag` supplied in a quantity-link source or in `from-measurement` is not a tag on the measurement. |
| `422` | `quantity_link_takeoff_overlap` | The quantity-link descriptor includes both the total of a measurement and one of its tag subtotals. |

## Council utilities

These routes return reviewed public council three-waters assets (stormwater, wastewater, and drinking-water nodes and pipes) for map takeoff. They require normal authentication and an organisation context, and read no estimate data. The Takeoff panel shows utility overlays only when a feature flag is on; the flag does not restrict the routes.

The sources cover parts of 41 council areas through 27 providers. Coverage is not complete for any council or asset type, and an empty response is not proof that no utilities exist. Display the returned `attribution` wherever the data is shown or exported.

Two map routes sit alongside them: [layer availability](#layer-availability) for the councils and layers in a view, and [property boundaries](#property-boundaries) from LINZ. They have the same authentication and organisation guard.

### Providers

```text theme={null}
GET /takeoff-utilities/providers
```

Returns `{ providers }`, the catalogue of supported sources. Each provider has `id`, `name`, `bounds`, `sourceUrl`, `licenceUrl`, `attribution`, `coverageNote`, `checkedOn`, and `layers`. Each layer has `key`, `name`, `network` (`stormwater`, `wastewater`, or `drinking-water`), `kind` (`node` or `pipe`), `areas` (the council areas it covers), and optional `bounds`. Each council area and network belongs to one provider only. The `bounds` are coarse discovery rectangles, not council boundaries.

### Features

```text theme={null}
GET /takeoff-utilities/features
```

Returns one provider layer's assets for a small map view, as WGS84 GeoJSON.

**Query parameters**

| Parameter | Type | Description |
| - | - | - |
| `provider` | string | A provider `id` from the catalogue, for example `canterbury-maps`. |
| `layer` | string | A layer `key` from that provider's `layers`. |
| `west`, `east` | number | Longitude bounds, 165 to 180. `east` must be greater than `west`. |
| `south`, `north` | number | Latitude bounds, -48 to -33. `north` must be greater than `south`. |

The view can be at most 0.025° of longitude by 0.02° of latitude. No other parameters are accepted.

**Response.** A GeoJSON `FeatureCollection` with `fetchedAt`, `provider`, `providerName`, `sourceLayer` (the layer key), `sourceUrl`, `attribution`, and `licenceUrl`. Node layers return `Point` features and pipe layers return `LineString` features; a multipart pipe is split into separately selectable parts. Each feature's `id` is `<provider>:<layerKey>:<objectId>:<part>`, and its `properties` hold `provider`, `sourceLayer`, `layerId`, `objectId`, `part`, `network`, `kind`, `assetId`, `assetType`, `material`, `diameter`, `district`, `loadedAt`, and the source's scalar `attributes`.

A view with more than 2,000 feature parts, 50,000 vertices, or a 3 MB response fails rather than returning a partial result. Each API worker caches a response for up to 15 seconds and coalesces identical requests in flight.

| HTTP | Code | Cause |
| - | - | - |
| `400` | `utility_source_unknown` | The `provider` or `layer` is not in the catalogue. |
| `400` | `utility_outside_coverage` | The view does not intersect the provider's or layer's published bounds. |
| `400` | `utility_view_too_dense` | Too many assets in the view. Zoom in and try again. |
| `400` | Validation error | A parameter is missing or out of range, or the view is too large. |
| `502` | `utility_source_unavailable` | The upstream source could not be read. |
| `503` | `utility_source_busy` | The worker's upstream request limit is reached. Retry after the `Retry-After` interval (2 seconds). |

### Layer availability

```text theme={null}
GET /takeoff-map/layers
```

Returns the council areas in a map view and the provider layers registered for them. It takes `west`, `east`, `south` and `north` with the same ranges as [Features](#features), but no size limit, so a regional view can be checked. No other parameters are accepted.

**Response.** `{ providers, councils, coverage, maxView }`:

* `providers`: the [provider catalogue](#providers) narrowed to providers whose bounds intersect the view and that have a layer for a council in view. Each layer's `areas` lists only the councils in view.
* `councils`: `{ id, name }` for each territorial authority in view.
* `coverage`: `sourceUrl`, `attribution` and `licenceUrl` for the Stats NZ council boundaries.
* `maxView`: `{ longitude: 0.025, latitude: 0.02 }`, the largest view that [Features](#features) and [Property boundaries](#property-boundaries) accept.

Each API worker caches a response for up to 60 seconds and coalesces identical requests in flight.

| HTTP | Code | Cause |
| - | - | - |
| `400` | Validation error | A bound is missing or out of range, or `east` or `north` is not greater than `west` or `south`. |
| `502` | `utility_coverage_unavailable` | The council boundary source could not be read. |
| `503` | `utility_coverage_busy` | The worker's upstream request limit is reached. Retry after the `Retry-After` interval (2 seconds). |

### Property boundaries

```text theme={null}
GET /takeoff-map/property-boundaries
```

Returns LINZ NZ Property Boundaries outlines for a small map view. It takes `west`, `east`, `south` and `north` with the same ranges as [Features](#features), and the view can be at most 0.025° of longitude by 0.02° of latitude. No other parameters are accepted.

**Response.** `{ outlines, fetchedAt, sourceUrl, attribution, licenceUrl }`. Each outline is `{ id, points }`, where `id` is `<objectId>:<part>` and `points` are `{ lat, lng }`. No parcel attributes are returned.

A view with more than 2,000 parcels, 5,000 outlines, 100,000 vertices, or a 3 MB response fails rather than returning a partial result. Each API worker caches a response for up to 15 seconds and coalesces identical requests in flight.

| HTTP | Code | Cause |
| - | - | - |
| `400` | `property_view_too_dense` | Too many property boundaries in the view. Zoom in and try again. |
| `400` | Validation error | A bound is missing or out of range, or the view is larger than 0.025° by 0.02°. |
| `502` | `property_source_unavailable` | The LINZ source could not be read. |
| `503` | `property_source_busy` | The worker's upstream request limit is reached. Retry after the `Retry-After` interval (2 seconds). |

### Recording provenance

When a map measurement is created from a utility feature, the client may send `sourceSnapshot`:

| Field | Description |
| - | - |
| `provider` | The feature's `provider`, for example `canterbury-maps`. |
| `sourceLayer` | The feature's `sourceLayer`. Optional for a `canterbury-maps` snapshot, which then matches by `layerId`. |
| `providerName`, `sourceUrl`, `licenceUrl`, `attribution` | Optional. The collection's source details. URLs must use `https`. |
| `layerId`, `objectId`, `part` | The feature's identity from its `properties`. |
| `fetchedAt` | The collection's `fetchedAt`. |
| `loadedAt` | The feature's `loadedAt`, or `null`. |
| `attributes` | The feature's `attributes`: up to 256 keys, with strings up to 4,000 characters, finite numbers, booleans, or `null`. |
| `originalPoints` | The feature's original `{ lat, lng }` points (1 to 10,000). |

The server checks the snapshot's shape and that `provider`, `sourceLayer`, and `layerId` name a layer in the catalogue, but does not check it against the upstream source, and still computes the quantity itself. Later point edits do not change the stored snapshot, and workspace versions include it.

## Frozen estimate writes

A frozen estimate refuses every user write except a rename. An estimate is frozen when it is:

* a tender whose status is `submitted`, `accepted`, `rejected`, or `archived`
* a tender that was not awarded (hidden when another estimate was marked won)
* a Delivery estimate in `in_sign_off` or `signed_off`
* a saved version

Tenders in `draft` or `review` and Delivery estimates in `draft` are editable. Delivery is a separate phase that creates a copy of the estimate data; the submitted tender is preserved exactly as submitted.

Every write route that changes the estimate returns `409 estimate_frozen` on a frozen estimate. This covers work items, components, calculators, the margin ladder, pricing, subcontract packages, project rate links, rate-exception decisions, acceptances and band clears, sections, all fields on `PUT /estimates/:id`, and the tender document data:

| Route | Data |
| - | - |
| `POST /sections`, `PUT /sections/:id`, `DELETE /sections/:id` | Sections |
| `POST /takeoff-measurements`, `PUT /takeoff-measurements/:id`, `DELETE /takeoff-measurements/:id` | Takeoff measurements |
| `POST /takeoff-map-sheets`, `PUT /takeoff-map-sheets/:id`, `PATCH /takeoff-map-sheets/:id/view`, `DELETE /takeoff-map-sheets/:id` | Takeoff map sheets |
| `POST /tender-clauses`, `PUT /tender-clauses/:id`, `DELETE /tender-clauses/:id` | Tender clauses (TAGs) |
| `POST /programme-items`, `PUT /programme-items/:id`, `DELETE /programme-items/:id` | Programme items |
| `POST /programme-dependencies`, `PUT /programme-dependencies/:id`, `DELETE /programme-dependencies/:id` | Programme dependencies |

**Rename exception.** `PATCH /estimates/:id`, and a `PUT /estimates/:id` whose body contains only `name`, succeed on a frozen tender (submitted, accepted, rejected, archived, or not awarded) and on a Delivery estimate in sign-off or signed off. A saved version stays fully read-only: the PATCH returns `404 estimate_not_found` and the PUT returns `409 estimate_frozen`. A PUT that carries `name` alongside any other field is refused as a whole.

The `409` response body includes the error code `estimate_frozen`, a human-readable message naming the actual status and how to proceed, and a `status` field for client branching:

```json theme={null}
{
  "message": "This tender has been submitted, so it's read-only. Please use an alternative estimate to make changes.",
  "code": "estimate_frozen",
  "status": "submitted"
}
```

The message text varies by status:

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

A not-awarded tender answers "This estimate was not awarded, so it's read-only." and adds `hidden: true`. A saved version answers "This is a saved version, so it's read-only." and adds `kind: "saved_version"`. A deleted estimate returns `409` with code `deleted` instead.

**Read endpoints are not affected.** All `GET` endpoints remain available regardless of estimate status.

**Sync-on-read GETs write nothing.** `GET /estimates/:id/tender-clauses` skips its library-sync step on a frozen estimate. `GET /estimates/:id/programme-items` skips its programme-header backfill step. Both return `200` with whatever data the frozen estimate holds.

**Lifecycle transitions, variation writes, and version creates are not gated.** Those are the supported ways to act on a frozen estimate.

## Document references

`GET /estimates/:id/document-references?documentId={uuid}` returns every reference within an estimate that points at a given document. This endpoint is used by the cloud-drive document-sync service as a pre-deletion guard: if the response contains an empty `references` array the document is safe to unroute; a non-empty array means at least one estimate record still depends on it.

### Query parameters

| Parameter | Type | Required | Description |
| - | - | - | - |
| `documentId` | UUID | Yes | The ID of the document to check for references. |

### Document reference response

```json theme={null}
{
  "references": [
    { "kind": "takeoff_measurement", "id": "...", "label": "Slab area" },
    { "kind": "package_attachment",  "id": "...", "label": "Earthworks package" },
    { "kind": "work_item_provenance","id": "...", "label": "Pour slab" },
    { "kind": "bid_provenance",      "id": "...", "label": "Earthworks package" }
  ]
}
```

### Reference kinds

| Kind | Source record | Matched by |
| - | - | - |
| `takeoff_measurement` | Takeoff measurement drawn from the document | `document_id` column |
| `package_attachment` | Subcontract package that carries the document as an attachment | `document_ids` JSON array |
| `work_item_provenance` | Work item whose provenance was captured from the document | `source_document_id` column |
| `bid_provenance` | Subcontract bid rate whose provenance was captured from the document | `source_document_id` column |

Each reference includes the `id` of the referencing record and, where available, a `label` (the measurement label, package name, or work-item name).

### Behaviour notes

* Returns `404` if the estimate does not exist.
* Returns `200` with an empty `references` array when the estimate exists but nothing references the document.
* All four reference kinds resolve by `documentId` alone — no per-suggestion parameters are accepted.
* Bid-rate and work-item rows that were accepted before `source_document_id` was introduced carry `null` in that column and are not visible to this endpoint. A one-off backfill (migration `0013`) can populate `source_document_id` for any such legacy rows.

## OpenAPI document

```text theme={null}
GET /api/estimate/openapi.json
```

Returns the OpenAPI 3.1 document generated at service boot from the Estimate API's
route-descriptor registry.

* **Unauthenticated**: no bearer token is required.
* **Cached bytes**: the document is serialised once at boot and returned unchanged on every request. `Cache-Control: no-store` is set so intermediate caches do not hold stale copies.
* **Contract version**: `info.version` is the descriptor-contract version, not the
  deployed service version.

### Response bodies and phasing

Request schemas come from the same Zod objects used by handlers. Response coverage is
being added in phases:

* A route with `x-stralos-response-status: undocumented` in its response object does not yet document its success body. The response object is present and describes the record type; the body schema will be added in a later pass.
* List routes document the shared `ListEnvelope` (`{ items, totalCount, start, length }`) but leave the item element schema undocumented until row schemas are lifted.
* The shared `ErrorEnvelope` (`{ message, issues?, code? }`) is referenced by every error response.

### Security

The OpenAPI document marks protected operations with the document-level
`bearerAuth` requirement and a `401` response. The three public
subcontractor-portal routes (`GET /subcontract-portals/:token` and its
`/documents/:docId` variants) carry `security: []` and do not document a `401`.
Runtime authentication is mandatory on every protected operation.

Every authenticated descriptor operation documents and can return `403 Forbidden` when the caller
holds a platform or service token with no resolved organisation context. This includes reads: all
tenant reads run through the row-level security scope, which rejects a null-org token before any row
is read. The three public subcontractor-portal operations
(`GET /subcontract-portals/:token` and its `/documents/:docId` variants) are excluded; they carry no
bearer token and resolve the organisation from the capability token, so a null-org scenario is
structurally unreachable.

## Service version

```text theme={null}
GET /version
```

Returns the running Estimate API's configured version and deployed commit.

This endpoint is unauthenticated and returns `Cache-Control: no-store`. It reads `SERVICE_VERSION` for the version and `SERVICE_COMMIT` for the commit, both stamped by the deployment pipeline. When `SERVICE_VERSION` is absent it falls back to the package version; when `SERVICE_COMMIT` is absent it returns `"unknown"`.

**Response** — `200 OK`

```json theme={null}
{ "version": "1.2.3", "commit": "0123456789abcdef0123456789abcdef01234567" }
```

| Field | Type | Description |
| - | - | - |
| `version` | string | Deployed service version. |
| `commit` | string | Git commit SHA of the deployed artifact, or `"unknown"` when the deployment has not stamped `SERVICE_COMMIT`. |

The release coordinator reads this endpoint before mutation and compares it with the manifest's minimum version.

## Billing access

The Estimate API enforces subscription access on user write operations. When an organisation's subscription is not in full access mode, any write request from a user token returns:

```http theme={null}
HTTP/1.1 402 Payment Required
Content-Type: application/json

{ "code": "billing_read_only", "message": "..." }
```

Read operations are not restricted. Service tokens bypass the billing check.

## Project estimate

### Get or create a project's estimate

```text theme={null}
GET /projects/:projectId/estimate
```

Returns the project's primary estimate (see [Project estimates](#project-estimates) for the others). If no estimate exists yet, the server race-safely creates one on behalf of the caller's organisation (lazy creation). An existing estimate is always returned regardless of billing state.

**Error responses**

The server validates project access with the Global API before creating an estimate. The following typed responses apply when no estimate exists:

| HTTP | Code | Cause |
| - | - | - |
| `402` | `billing_read_only` | Lazy creation is a write; the organisation's subscription is not in full access mode. |
| `403` | `project_access_required` | The caller does not have access to the project. Previously returned `502`; now passes through as `403`. |
| `404` | `project_not_found` | The project does not exist or belongs to a different organisation. |
| `409` | `project_not_provisioned` | The project exists but its estimate has not been provisioned yet. The response includes `Retry-After: 5`. Returned only when the service is in a non-default provisioning mode; under normal operation the estimate is created lazily on first read. |
| `502` | — | The Global API was unreachable or returned an unexpected status. |

When a `409 project_not_provisioned` is returned, the estimate will be created shortly. Retry after the `Retry-After` interval. Do not treat this response as a permanent failure.

### Ensure a project's estimate exists

```text theme={null}
PUT /projects/:projectId/estimate
```

Idempotent endpoint that ensures a project's estimate exists. Creates the estimate if one does not yet exist, or returns the existing estimate if it does. Nothing is rewritten on a repeat call — the margin ladder and `hoursPerDay` stay frozen at their original values.

* `201 Created` — the estimate was created by this call.
* `200 OK` — an estimate already existed; it is returned unchanged.

This endpoint applies the same project-access validation as the `GET` above and is subject to the same `402`, `403`, `404`, and `502` responses. It is not affected by the lazy-create mode — it is always the authoritative create path.

**Authentication:** requires a valid bearer token with a resolved organisation context.

## Project estimates

A project can hold more than one tender estimate when its **Multiple estimates** setting is on (see [Project estimate settings](#project-estimate-settings)). One live tender estimate is always the primary — the estimate that opens on project load and that `GET /projects/:projectId/estimate` returns. The others are alternative pricing scenarios. A Delivery estimate is created at award, not through these routes.

The write routes run under the project's lifecycle lock, so they serialise with submit, revise and award.

### List a project's estimates

```text theme={null}
GET /projects/:projectId/estimates
```

Returns the project's live tender and Delivery estimates, primary first, then in creation order. Saved versions and deleted estimates are never listed. Hidden (not-awarded) estimates are excluded unless `includeHidden=true` is passed.

**Query parameters**

| Parameter | Type | Default | Description |
| - | - | - | - |
| `includeHidden` | `"true"` or `"false"` | `"false"` | When `"true"`, includes estimates hidden as not-awarded at project award (`hiddenAt` set). |

**Response — `200 OK`**

```json theme={null}
{
  "items": [
    {
      "id": "uuid",
      "name": "string",
      "kind": "tender",
      "status": "draft",
      "version": "0",
      "isPrimary": true,
      "outcome": null,
      "hiddenAt": null,
      "sourceEstimateId": null,
      "signedOffRevisionId": null,
      "quoteNumber": "Q-0012",
      "totalPrice": 0,
      "totalCost": 0,
      "updatedAt": "ISO 8601"
    }
  ],
  "multipleEstimatesEnabled": false
}
```

| Field | Type | Description |
| - | - | - |
| `items` | object\[] | The project's estimates (fields below). |
| `multipleEstimatesEnabled` | boolean | The project's **Multiple estimates** setting. When `false`, the primary is the project's only working estimate in the workspace. |

Each `items` entry:

| Field | Type | Description |
| - | - | - |
| `id` | uuid | Estimate ID. |
| `name` | string | Estimate name, unique within the project (case-insensitive). |
| `kind` | `"tender"` or `"delivery"` | Estimate role. |
| `status` | string | Current lifecycle status. |
| `version` | string | Latest tender revision number. `"0"` until first submitted. |
| `isPrimary` | boolean | `true` on the one tender estimate that opens on project load. |
| `outcome` | `"won"`, `"not_awarded"`, or `null` | Award result. `null` while the project is not yet awarded. |
| `hiddenAt` | ISO 8601 or `null` | When the estimate was hidden as not-awarded at project award. `null` while listed normally. |
| `sourceEstimateId` | uuid or `null` | The estimate this one was copied from (for a Delivery estimate, the won tender). `null` for a blank estimate. |
| `signedOffRevisionId` | uuid or `null` | A Delivery estimate's latest signed-off revision. `null` on every tender estimate. |
| `quoteNumber` | string or `null` | The estimate's own quote number. The project's original primary estimate carries the project's quote number; every other tender estimate has its own. A tender estimate without one is numbered on this read. `null` for a Delivery estimate. See [Quote numbers](#quote-numbers). |
| `totalPrice` | number | The stored `itemsTotal`, excluding contingency and GST. Falls back to the sum of top-level contract-line prices for an estimate with no stored totals yet. |
| `totalCost` | number | Sum of top-level work-item costs. |
| `itemsTotal`, `contingencyTotal`, `tenderTotal`, `unsectionedTotal` | number or `null` | The estimate's stored totals; see [Sections response envelope](#sections-response-envelope). `null` until first stored. |
| `originalItemsTotal`, `originalContingencyTotal`, `originalTenderTotal`, `originalUnsectionedTotal` | number or `null` | The same totals over the estimate's original rows, leaving out rows and sections added by approved variations. |
| `updatedAt` | ISO 8601 | Last modified timestamp. |

### Create an estimate

```text theme={null}
POST /projects/:projectId/estimates
```

Creates another tender estimate on the project. The new estimate is never the primary; call [`POST /estimates/:id/make-primary`](#make-an-estimate-primary) to change the primary afterwards.

**Request body**

```json theme={null}
{
  "name": "string",
  "from": { "estimateId": "uuid" }
}
```

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string, max 200 chars | Yes | Name for the new estimate. Must be unique among the project's live estimates (case-insensitive). |
| `from` | object | No | Source to copy from: exactly one of `{ "estimateId": "uuid" }` or `{ "versionId": "uuid" }`. Omit to create a blank estimate. |

The three creation modes:

* **Blank** (omit `from`) — an empty estimate that takes `hoursPerDay` and the margin ladder from the organisation defaults at creation time.
* **Duplicate** (`from.estimateId`) — a full copy of a live tender estimate on this project, including its work items, build-up, sections and project rates. The copy is created in `draft` status.
* **From version** (`from.versionId`) — a `draft` copy of a saved version of an estimate on this project.

A copy runs as a two-phase copy job within the request: the project rates are backfilled and copied in the Rates service first, then the estimate graph is copied in one transaction with a parity assertion. The copy rolls back with `500 copy_parity_failed` if the copied totals do not match the source to the cent.

**Response — `201 Created`**

The new estimate row.

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `404` | `estimate_not_found` | The project has no primary estimate, or the source estimate is not a live tender estimate on this project. |
| `404` | `estimate_version_not_found` | The source version does not exist on this project. |
| `409` | `multiple_estimates_disabled` | The project's **Multiple estimates** setting is off. Turn it on in project settings before adding another estimate. |
| `409` | `project_awarded` | The project has been awarded, so its tender is settled and another tender estimate cannot be added. |
| `409` | `version_read_only` | The version was saved before versions became full estimate copies. It can be viewed but not copied. |
| `409` | `rates_default_estimate_mismatch` | The Rates service records no default estimate for this project, so the rates could not be copied. Nothing was created. (A default that lags behind the current primary is expected and does not block a copy.) |
| `409` | — | Another live estimate on the project already uses that name (`Resource already exists.`). |
| `422` | `copy_from_revision_unsupported` | `from.revisionId` was supplied. Copying from a tender revision is not available. |
| `500` | `copy_parity_failed` | The copied graph did not reproduce the source totals. Nothing was created. |

### Rename an estimate

```text theme={null}
PATCH /estimates/:id
```

Renames an estimate. The new name must be unique among the project's live estimates (case-insensitive).

A rename is the one write a frozen estimate still accepts. It succeeds on a submitted, accepted, rejected, archived, or not-awarded tender and on a Delivery estimate in sign-off or signed off. See [Frozen estimate writes](#frozen-estimate-writes).

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string, max 200 chars | Yes | New name for the estimate. |

**Response — `200 OK`**

The updated estimate row.

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `404` | `estimate_not_found` | The estimate does not exist (or is a saved version). |
| `409` | `estimate_name_taken` | Another live estimate on the project already uses that name. |
| `409` | `deleted` | The estimate has been deleted. |

### Make an estimate primary

```text theme={null}
POST /estimates/:id/make-primary
```

Makes the tender estimate the project's primary. No body is required. Exactly one estimate is primary at a time: the previous primary is demoted in the same transaction, under the project lock. After the commit, the Rates service's default estimate for the project is updated to follow the primary; repeating the call on an estimate that is already primary returns it unchanged and re-sends that update.

**Response — `200 OK`**

The updated estimate row.

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `deleted` | The estimate has been deleted. |
| `409` | `estimate_not_tender` | Only a tender estimate can be primary. |
| `409` | `estimate_hidden` | A not-awarded (hidden) estimate cannot be primary. |

### Delete an estimate

```text theme={null}
DELETE /estimates/:id
```

Soft-deletes the estimate; a deleted estimate is no longer listed. Returns `204 No Content`. A repeat `DELETE` on an already-deleted estimate also returns `204` and changes nothing.

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `409` | `estimate_is_primary` | This is the project's primary estimate. Make another estimate primary first. |
| `409` | `estimate_is_delivery` | A Delivery estimate cannot be deleted. |
| `409` | `estimate_is_won` | The won estimate cannot be deleted. |
| `409` | `estimate_has_revision` | The estimate has been submitted as a tender (it has a committed revision) and is kept as a record. |

### Document suggestion idempotency

Accepting a document suggestion (`POST /estimates/:id/work-items/accept-suggestion`) is idempotent per estimate. Accepting the same suggestion into a different estimate of the same project is treated as a fresh accept in that estimate, not a replay. Each estimate tracks its own accepted suggestions independently.

The same rule applies to subcontract bid suggestions: idempotency is per subcontract package, so duplicating an estimate also duplicates its packages, and the same bid can be accepted into the new package.

## Project estimate settings

```text theme={null}
GET /projects/:projectId/estimate-settings
PUT /projects/:projectId/estimate-settings
```

Reads and writes the project-level estimate settings. The only setting is **Multiple estimates**.

### Get estimate settings

```text theme={null}
GET /projects/:projectId/estimate-settings
```

**Response — `200 OK`**

| Field | Type | Description |
| - | - | - |
| `multipleEstimatesEnabled` | boolean | Whether the project allows more than one tender estimate. `false` when the project has no settings row. |

### Update estimate settings

```text theme={null}
PUT /projects/:projectId/estimate-settings
```

Updates the project's estimate settings. Requires a caller with a resolved organisation context.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `multipleEstimatesEnabled` | boolean | Yes | `true` allows more than one tender estimate on the project. `false` restricts the workspace to the primary estimate and hides the estimates panel and estimate switcher; it does not delete any existing estimate. |

**Response — `200 OK`**

| Field | Type | Description |
| - | - | - |
| `multipleEstimatesEnabled` | boolean | The saved value. |

A body that fails validation returns `400`.

## Estimate commercial settings

| Field | Type | Default | Description |
| - | - | - | - |
| `defaultMarkupPct` | number (whole %, 3 dp) | `0` | The tender's markup, applied to every component that no more specific rung answers for. Range: −100 to 999.999. Active only when `pricingBasis` is `"markup"`. |
| `defaultMarginPct` | number (whole %, 3 dp) or `null` | `null` | Tender-default margin rung. The fourth rung in the margin ladder — used when no component override, category rung, or rate-group rung resolves. Range: 0 to 95. Active only when `pricingBasis` is `"margin"`. |
| `contingencyPct` | number (whole %, 3 dp) | `0` | Contingency applied once to the tender roll-up. Range: 0 to 100. |
| `hoursPerDay` | number (2 dp) | organisation default, or `8` | Default working-day length in hours. Used to convert a duration-driven line's charged quantity. Range: greater than 0, up to 24. |
| `pricingBasis` | `"markup"` or `"margin"` | copied from organisation at create | Server-owned and frozen at creation. Supplying this field on either POST or PUT returns `400 pricing_basis_server_owned`. |
| `sellRateRounding` | string | `"none"` | "Round sell rates up to" for every item without its own. Accepted on `PUT /estimates/:id` only; a new estimate starts at `"none"`. See [Sell rate rounding](#sell-rate-rounding). |

The editable fields above are accepted on `POST /estimates` and `PUT /estimates/:id`; `pricingBasis` is response-only. Changing `defaultMarkupPct`, `defaultMarginPct`, or `contingencyPct` through the estimate PUT recomputes work-item totals and section subtotals in the same transaction under the estimate-level advisory lock. `pricingBasis` is server-owned and cannot be changed after creation. On a frozen estimate, `PUT /estimates/:id` returns `409 estimate_frozen` for every field, including `gstRate`, `currency`, `quantitySource`, and `soqUnlocked`, not only the repricing fields. The one exception is a body that contains only `name`, which is treated as a rename and succeeds on a frozen estimate other than a saved version. A body that carries `name` alongside any other field is refused as a whole.

### Sell rate rounding

`sellRateRounding` on `PUT /estimates/:id` rounds every item's sell rate up to a step from `SELL_RATE_ROUNDING_OPTIONS` (see [Estimate rounding](https://help.stralos.app/internal/estimate-rounding.md)) and reprices every item that has no rounding of its own. Each rate is rounded up, stored to the cent, and the amount is quantity × rate. A token outside the list is `422 sell_rate_rounding_invalid`; a frozen estimate is `409 estimate_frozen`.

`sellRateRounding` on `PUT /work-items/:id` is the item's own setting: `null` inherits the estimate's and `"none"` keeps the item unrounded. The response carries `effectiveSellRateRounding` and `unitRateUnrounded` (the cent rate before rounding, `null` when rounding moved nothing). Provisional sums, rows with a quantity of zero or less, and rows in a Shared Costs section are never rounded.

### Working day inheritance

When `hoursPerDay` is absent from a `POST /estimates` request body, the server reads the organisation's `defaultHoursPerDay` from the Global API and stamps that value onto the new estimate. An explicit `hoursPerDay` value in the body always wins over the organisation default.

The same inheritance applies when an estimate is created lazily via `GET /projects/:projectId/estimate`: if no estimate yet exists, the server creates one using the organisation's working-day default.

When the Global API is unavailable or returns an unreadable value, the server falls back to `8` and logs a warning. The fallback never causes the request to fail.

> [!NOTE]
>
> A work item no longer carries a markup of its own. `markupPct` was removed from the work-item create and update bodies on 2026-09-18: a markup belongs to the activities and materials inside a work item, not to the item. A request that still sends the field is accepted and the field is ignored.

## Quantity entry policy

The `quantitySource` field controls how work-item quantities are entered for the project.

| Value | Meaning |
| - | - |
| `soq` | Quantities are imported from the schedule of quantities. An untouched imported value is read-only; only values the estimator has overridden are editable. |
| `plans` | The estimator measures quantities off drawings. Every quantity cell is editable. |

The default is `soq`. This field is accepted on `POST /estimates` and `PUT /estimates/:id`. Unlike the commercial settings above, changing `quantitySource` does not trigger a pricing recompute — it controls editability only.

### `soqUnlocked`

`soqUnlocked` is a boolean field on the estimate row. The database column defaults to `false`, but the `GET /projects/:projectId/estimate` lazy-create and `PUT /projects/:projectId/estimate` ensure paths write `true`. `POST /estimates` also defaults to `true` while allowing the caller to supply `false` explicitly. An existing estimate can be locked by accepting an SOQ import suggestion (which sets `soqUnlocked: false` under the estimate-totals lock) or by explicitly locking it via `PUT /estimates/:id`.

This field is accepted on `POST /estimates` and `PUT /estimates/:id`. It is in the same class as `quantitySource` — it controls editability, not pricing — but it does take the estimate-totals advisory lock on write because it determines which rows are SOQ-frozen and the work-item writers read it under that lock.

Setting `soqUnlocked: true` lifts:

* The quantity `422` on `PUT /work-items/:id` and on batch `setQuantity`.
* The move and renumber `422` on `PUT /work-items/:id`.
* The relative-order `422` on `POST /estimates/:estimateId/work-items/reorder`.
* The bulk-move `422` on `POST /estimates/:estimateId/work-items/batch`.
* The section move and renumber `422` on `PUT /sections/:id`.

`soqUnlocked` does not unfreeze automatic item numbering. An imported row carrying its own `itemNumber` keeps it through a reorder even after the estimate is unlocked. A person may still `PUT` a new `itemNumber` by hand on an unlocked estimate.

`soqUnlocked` is excluded from tender revision snapshots (it is an editing-mode flag, not a pricing input, and two revisions of one estimate must hash identically regardless of lock state). Estimate version snapshots include it and restore it verbatim.

### SOQ lock

A work item's quantity is read-only when all of the following are true:

* the estimate's `quantitySource` is `soq`;
* the estimate's `soqUnlocked` is `false`;
* the item's `quantityOrigin` is `import`;
* `sourceQuantity` is not `null`; and
* `quantity` exactly equals `sourceQuantity`.

Takeoff measurements (`quantityOrigin: "measurement"`) remain editable in `soq` mode. If an
estimator changed an imported quantity while the estimate was in `plans` mode, switching back to
`soq` does not restore or re-lock that changed value.

The lock is value-aware: submitting the stored quantity again succeeds, so unrelated edits such as
renaming, retagging, repricing, or changing markup are still allowed. A different quantity returns
`422 Unprocessable Entity`.

An SOQ-locked item's position is also fixed. These operations return `422` and write nothing when
they would move or renumber a locked item:

| Operation | Rejected change |
| - | - |
| `PUT /work-items/:id` | Changing `sectionId`, `sortOrder`, or `itemNumber`. |
| `POST /estimates/:estimateId/work-items/batch` with `move` | Moving a locked item to another section; the whole batch rolls back. |
| `POST /estimates/:estimateId/work-items/reorder` | Changing the relative position of a locked item with respect to another locked item. Manual items may move freely. |
| `PUT /sections/:id` | Changing `sectionNumber` or `sortOrder` for a section subtree containing a locked item. |

All of the above restrictions are lifted when `soqUnlocked` is `true`. See [`soqUnlocked`](#soqunlocked) above.

## Work item pricing

### Read all pricing rows

```text theme={null}
GET /estimates/:id/work-item-pricing
```

Returns one pricing row per live work item in the estimate. Each row includes a read-only `componentOverrides` array, synthesised from each live component's price-selection columns and active value overrides. It is a compatibility shape only: the stored JSON column was dropped (migration 0095), and the key cannot be written (see below).

Use `priceSelection` and `valueOverrides` on individual component reads (`GET /work-items/:id/components` and the `components` array on `GET /work-items/:id`) to read pricing state per-component without fetching the full estimate pricing.

### Save a pricing row

```text theme={null}
PUT /work-items/:id/pricing
```

Saves a work item's pricing row. The body accepts:

| Field | Type | Required | Description |
| - | - | - | - |
| `pricingSource` | string | No | `"internal"` or `"subcontract:<bidRateId>"`. Selects the cost source used in the tender roll-up. A bid takes effect only when the named bid rate belongs to this work item and the item has no published package; in all other cases the item prices from its internal build-up. |

A body that includes `componentOverrides` is refused with `422 component_overrides_retired`. Write a component's price selection through `PUT /work-item-components/:id/pricing-rule` and its hand-changed values through `PUT /work-item-components/:id/value-overrides`.

### Sparse pricing update

```text theme={null}
PATCH /work-items/:id/pricing
```

Updates the `pricingSource` on a work item's pricing row without replacing the full row.

The body accepts:

| Field | Type | Required | Description |
| - | - | - | - |
| `pricingSource` | string or `null` | No | `"internal"` or `"subcontract:<bidRateId>"`. Absent means leave the stored value unchanged. Explicit `null` clears it. A bid takes effect only when the named bid rate belongs to this work item and the item has no published package. |

A body that includes `componentOverrides` is refused with `422 component_overrides_retired`. Write a component's price selection through `PUT /work-item-components/:id/pricing-rule` and its hand-changed values through `PUT /work-item-components/:id/value-overrides`.

### Effective pricing source on read

`GET /work-items/:id` and `GET /estimates/:id/work-items` return a `pricingSource` that reflects the source the money recompute actually used, not the raw stored token. The effective source is `"internal"` when the stored token is absent, null, or empty; when the named bid does not belong to this work item; or when the item has a published package. In all other cases the stored `"subcontract:<bidRateId>"` token is returned unchanged.

### Frozen pricing rule selection

```text theme={null}
PUT /work-item-components/:id/pricing-rule
```

Sets or clears a frozen pricing-rule envelope on a work item component and recomputes the authoritative resolution. The request body is a discriminated union:

* **Set** — include `selection`, `envelope`, `resolvedBandKey`, and `chargedQuantity`.
* **Clear envelope only** — include `selection`, set `envelope`, `resolvedBandKey`, and `chargedQuantity` to `null`.
* **Clear entirely** — omit `selection` and set `envelope`, `resolvedBandKey`, and `chargedQuantity` to `null`.

The `selection` object accepts these fields:

| Field | Type | Required | Description |
| - | - | - | - |
| `rateOptionId` | UUID | Yes | Rate option this selection is priced from. |
| `baseUnitCost` | number | Yes | Unit cost at 2 decimal places. |
| `libraryUnitCost` | number or `null` | No | Audit snapshot of the library option's unit cost in the priced unit. Never used in pricing. |
| `pricedUnit` | string | Yes | Unit the selection is priced in. |
| `unit` | string | No | When present, atomically moves the component's own unit to this value. Must equal the canonical form of `pricedUnit`. See below. |
| `conversionFactor` | number or `null` | Yes | Conversion factor for the priced unit pair. Required even when no conversion applies (supply `null`). |
| `sourceName` | string or `null` | Yes | Display name of the rate source. |
| `locationName` | string or `null` | Yes | Source location for the selected option. |
| `provenance` | `"auto"` or `"pinned"` | Yes | How the selection was resolved. |
| `resolvedTier` | `"override"`, `"rule"`, `"project"`, `"office"`, `"default"`, or `null` | Yes | Pricing tier that produced the selection. |
| `resolvedLocationLabel` | string or `null` | Yes | Display label for the resolved location. |
| `resolvedRule` | object or `null` | Yes | The project pricing rule applied. Supply `null` when none applies. |
| `resolvedRuleOutcome` | `"applied"`, `"unsatisfiable"`, `"policy_unmet"`, or `null` | Yes | Outcome of rule evaluation. Supply `null` when there is no outcome. |
| `projectRateOverrideId` | UUID or `null` | Yes | Required when `resolvedTier` is `"override"`; must be `null` otherwise. |

**Moving the component's unit atomically (`unit` field)**

A plain `PUT /work-item-components/:id` can change the unit on a frozen selection, but it deliberately unfreezes that selection: `pricedUnit` and `conversionFactor` are removed while the existing rate option identity and numeric unit cost remain for the caller to correct. A row governed by a pricing-rule snapshot still refuses a plain unit change because the rule owns its unit.

Send `unit` through this pricing-rule endpoint when the caller is selecting another option priced in the target unit. The component's unit, pricing selection, and recomputed totals then move in one write without passing through an unfrozen intermediate state.

The `unit` value must equal the canonical form of `pricedUnit`. This restricts the move to same-unit selections by construction: an option that requires a unit conversion has a `pricedUnit` the component is not moving to, so it cannot satisfy the equality and the component's unit is left unchanged.

Do not set `unit` when a conversion applies. A row measured in m³ priced from a per-tonne option legitimately keeps m³ and carries a conversion factor. Sending `unit` on such a selection returns `422`.

**422 responses**

| Cause | Description |
| - | - |
| `unit` does not equal canonical `pricedUnit` | The supplied `unit` must be the same canonical unit as `pricedUnit`. A selection that requires a conversion cannot move the component's unit. |
| Component is a managed ticked companion | Managed fixed-charge companions cannot have their pricing rule changed directly. |

### Hand-changed value overrides

```text theme={null}
PUT /work-item-components/:id/value-overrides
```

Records hand-changed values on a work item component. The server diffs each supplied field against the line's current original and stores only what differs. Use this endpoint when an estimator manually edits a component's name, category, quantity, resource count, unit, or unit cost in the pricing panel.

A manual unit-cost edit also pins the component's price selection: the stored selection is updated to `provenance: "pinned"` with the edited unit cost as the scalar.

**Request body**

Send either `{ clear: true }` to revert every override, or one or more of the following fields:

| Field | Type | Required | Description |
| - | - | - | - |
| `clear` | `true` | — | When present, reverts all hand-changed overrides on this component. Cannot be combined with field values. |
| `name` | string | No | Override the component display name. |
| `category` | string or `null` | No | Override the component category. `null` clears it. |
| `quantity` | number | No | Override the component quantity. Must be `>= 0`. |
| `resourceCount` | number | No | Override the resource count. Must be `>= 1`. |
| `unit` | string | No | Override the component unit. |
| `unitCost` | number | No | Override the unit cost in dollars, with at most 2 decimal places. Must be `>= 0`. |

At least one field is required unless `clear: true` is sent.

**Response — 200 OK**

| Field | Type | Description |
| - | - | - |
| `componentId` | UUID | The component that was updated. |
| `valueOverrides` | object\[] | Active overrides after the write (see below). |

Each entry in `valueOverrides`:

| Field | Type | Description |
| - | - | - |
| `id` | UUID | Override record ID. |
| `field` | string | Which field is overridden: `name`, `category`, `quantity`, `resource_count`, `unit`, or `unit_cost`. |
| `value` | string, number, or `null` | The stored override value. |
| `original` | string, number, or `null` | The original value this override replaces. |
| `originalSource` | `"row"`, `"selection"`, `"library"`, or `"derived"` | Where the original value came from. |
| `overriddenBy` | UUID or `null` | User who set the override, if known. |
| `overriddenAt` | string | ISO 8601 timestamp when the override was recorded. |

**Error codes**

| HTTP | Cause |
| - | - |
| `400` | Request body fails validation (for example, `clear` combined with field values, or no fields supplied). |
| `404` | Component not found or belongs to a different organisation. |
| `409` | `estimate_frozen` — the estimate is submitted, accepted, rejected, or archived. |
| `422` | The component is a managed ticked companion; a `quantity` override targets an activity-instance child (its quantity is derived); or a `unit` or `unitCost` override targets a frozen selection (replace the selection through `PUT /work-item-components/:id/pricing-rule`). |

## Contract variations

## Work item delete behaviour

`DELETE /work-items/:id` runs in a single transaction under the estimate-level advisory lock. Deletions are **soft**: the row is stamped with `deleted_at` rather than removed. Every work item, its live sub-items, every live component on those items, and each item's programme header subtree share one `deleted_at` timestamp from the same delete call.

The operation performs the following steps:

* Nulls the `workItemId` on any linked takeoff measurements (bare-UUID reference, no foreign key).
* Removes the deleted item's ID from the `workItemIds` array on every subcontract package **within the same estimate**. Packages in other estimates are not modified.
* Stamps the work item, its live sub-items, all of their live components, and all of their programme header rows with a shared `deleted_at` timestamp.
* Recomputes section subtotals and estimate totals.

Callers receive `204 No Content` on success. A repeat `DELETE` on an already-deleted item also returns `204` and changes nothing. The item is not returned on list or aggregate reads after deletion, but `GET /work-items/:id` continues to return the row with its `deletedAt` field set.

Any write to a deleted work item (PUT, sub-item, duplicate, component creation) returns `409 { code: "deleted" }`. The caller must not assume the item was removed from the database; the row survives for audit purposes.

`404 Not Found` is returned only when the item does not exist or is outside the caller's organisation.

## Duplicating a work item

`POST /estimates/:estimateId/work-items/:itemId/duplicate` creates an atomic copy of a work item within its estimate:

* The copy is named `<original name> (copy)`, truncated to the name column limit.
* Provenance fields (`sourceDocumentId`, `sourcePageNumber`, `sourceExcerpt`, `sourceSuggestionId`) are cleared — the copy is not linked to any source document or suggestion.
* Package-owned mirror components (those with `type: "subcontract"`, a `subcontractBidRateId`, or a `subcontract:` pricing source) are not copied. Only internal build-up components are cloned.
* If the source item's pricing source is `subcontract:<bidRateId>`, both the work item column and canonical pricing row are reset to `internal` on the copy, because the clone is not a member of that package.
* Pricing overrides are remapped onto the new component IDs. An override referencing a component that was skipped (package-owned) is dropped from the copy rather than carried as an orphan.
* Calculator-generated component keys are rebound to the clone's component IDs. Rebound keys are canonicalised and any collisions are resolved deterministically, preserving a unique reconcile identity within the copied work item.
* The copy is recomputed immediately; totals and section subtotals reflect the new item before the response is returned.

The operation runs in one transaction under the estimate-level advisory lock. It returns `201 Created` with the new work item and its derived `tagCount` and `hasTakeoffLinks` fields. It returns `404 Not Found` if the source item does not exist or does not belong to the specified estimate.

## Splitting an SOQ line into sub-items

```text theme={null}
POST /estimates/:estimateId/work-items/:itemId/sub-items
```

Adds a sub-item beneath an SOQ-sourced work item. The parent retains its contractual identity — its item number, name, and quantity are unchanged. The sub-item has its own build-up and stored totals in the same section. Recompute rolls those totals into the parent, and aggregate consumers sum top-level rows only, so section and estimate totals include the sub-item exactly once.

### Placement rules

* The parent must be SOQ-sourced: its `quantityOrigin` must be `import`. A takeoff-measured item (`quantityOrigin: "measurement"`) does not qualify even though it carries a `sourceDocumentId`.
* The parent must not itself be a sub-item. Only one level of nesting is supported. The server checks this first and returns `422 sub_item_nesting_unsupported` when it fails.
* The sub-item always lives in the parent's section. There is no `sectionId` in the request body.

### Item numbering

When the parent has an item number and the generated value fits the 80-character ceiling, the sub-item receives a number of the form `<parent itemNumber><letter>` — for example, `2.3a` for the first sub-item under `2.3`, `2.3b` for the second, and so on. Suffixes walk a through z, then aa, ab, and continue indefinitely. The server allocates the suffix against all existing children of that parent. If the parent has no number or no candidate fits within the column limit, the sub-item's item number is `null`.

Sub-items numbered before this change keep their stored `<parent>.<n>` numbers. A parent that already holds a child numbered `2.3.1` gives its next sub-item the suffix `a` without colliding.

### Reorder behaviour

Sub-items are excluded from the section reorder membership set. When calling `POST /estimates/:estimateId/work-items/reorder`, send only top-level item IDs. Sub-items are re-ranked with their parent when the parent's position changes; the response returns the whole section, sub-items included.

### Move behaviour

A sub-item cannot be moved independently to a different section (`422 sub_item_section_bound`). Moving a parent is allowed and carries all its sub-items to the new section.

### Delete behaviour

Deletions are soft. Deleting a parent stamps the parent and all of its live sub-items with a shared `deleted_at` timestamp in one transaction. Deleting a sub-item individually stamps only that sub-item and recomputes totals. In both cases the rows survive; list and aggregate reads exclude them, and a repeat delete returns `204` without changing anything.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Display name for the sub-item. Maximum 500 characters. |
| `unit` | string | Yes | Unit for the sub-item. Normalised to catalogue codes on write. |
| `quantity` | number | No | Quantity for the sub-item. Must be ≥ 0. Defaults to `1`. |

### Response

**201 Created** — the new sub-item row, with the same derived fields (`tagCount`, `hasTakeoffLinks`) as every other work-item creator.

### Error responses

| HTTP | Code | Cause |
| - | - | - |
| `400` | — | The request body fails schema validation. |
| `404` | — | The parent work item is not found, or it does not belong to the specified estimate. |
| `422` | `sub_item_nesting_unsupported` | The parent is itself a sub-item; only one level of nesting is supported. |
| `422` | `sub_item_parent_not_soq` | The parent's `quantityOrigin` is not `import`. |

`parentWorkItemId` is not accepted in the generic work-item create or update bodies. This endpoint is its only writer.

## Copying a build-up into a work item

```text theme={null}
POST /estimates/:estimateId/work-items/:itemId/copy-build-up
```

This endpoint copies components from a source work item's build-up into the target work item identified by `:itemId`. It preserves the hierarchy of materialised activity instances — instance containers are copied with their instance fields intact, and their children are re-parented onto the copied container.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `sourceWorkItemId` | UUID | Yes | Work item whose build-up to copy from. Must belong to the same organisation; it may be from a different estimate. |
| `componentIds` | UUID\[] | No | Explicit subset to copy. Selecting a container includes its complete subtree. Omit the field to copy the entire build-up. An unknown ID, or an activity-instance child selected without its container, returns `422`. |
| `refreshRates` | boolean | No | When `true`, the server re-resolves each copied rate line against the current rate library before inserting it. Defaults to `false`, which carries the source build-up verbatim (original behaviour). |
| `exactSelection` | boolean | No | When `true`, `componentIds` is the exact set to copy: a selected container brings only its selected descendants, not its whole subtree. Linked companion charges still copy with their priced row. Requires a non-empty `componentIds`; without one the request returns `400`. Defaults to `false`. The **Reuse a past build-up** sheet sends it whenever the estimator unticks lines. |

### Rate refresh

When `refreshRates` is `true`, the server re-resolves each rate-library-bound component (those with a non-null `rateItemId`) against the current library before inserting into the target. Hand-entered lines without a rate-library link are never refreshed.

With the current Rates contract, the server has two effective match tiers in order, stopping at the first hit:

| Tier | Match condition |
| - | - |
| Rate item ID | The source component's `rateItemId` still resolves in the current library. |
| Name | The source component's name matches exactly one current item's name (trimmed, case-insensitive). |

The resolver retains a legacy code-match branch, but current rate-item responses carry no code, so that branch cannot select a candidate. A tier that finds more than one matching item is treated as ambiguous; no choice is made and the line retains its historical price. Only rate-library-bound lines refresh; the rate-refresh pass runs entirely before the write transaction so that no peer-service call holds the estimate advisory lock.

### Copy rules

* Package-owned mirror components (`type: "subcontract"`, `subcontractBidRateId` set, or a `subcontract:` pricing source) are excluded. They point at source-package state the target does not own.
* Calculator-generated rows (`generatedKey` set) are excluded. A generated row belongs to the calculator that produced it and does not transplant to a work item whose calculator does not own it.
* Day-rate lines (`type: "day_rate"`) are copied verbatim, including their `unit_cost` overrides. When the source is from a different project, the copied lines retain the source revision and are marked `inProjectSet: false` on read if the target project pins a different revision of that schedule.
* Instance containers are copied with their `allocatedQuantity`, `productionRate`, `productionOutputUnit`, `productionProfileId`, `scopeLabel`, `chainLabel`, and `activityCount` fields intact. Instance children are re-parented onto the copied container and retain their `quantityBasis` values.
* An instance container whose `productionOutputUnit` does not match the target work item's unit (normalised, case-insensitive) is silently dropped along with its children. The response includes a warning with the count of dropped instances and the target's unit.
* An unknown `componentIds` entry, or an activity-instance child selected without its container, returns `422 Unprocessable Entity`.
* A copied line does not arrive loose. A line copied without its activity (possible only for an older activity with no daily production) goes into one new activity, named after the line when there is only one and after the source work item otherwise. A source line that sits outside an activity goes into one new activity named `Activity` (or `Activity 2`, and so on, if the name is taken). Each line is charged so its cost is unchanged. New activities are listed first in `copied`. A line that has no equal inside an activity stays outside one.
* With `exactSelection: true`, any selected line inside an activity whose activity is not selected returns `422 orphan_component_selection`. An activity with no rate of its own that is selected with none of its lines returns `422 empty_activity_selection`. Both list the offending IDs in `invalidComponentIds`. An activity priced by its own rate item may be copied on its own.
* Estimate-owned pricing overrides are deep-copied with component IDs remapped. When `refreshRates` is `true`, a successfully refreshed ordinary rate line retires its historical override; unmatched, ambiguous, and no-price lines retain their frozen pricing inputs.

### Copy-build-up response

The endpoint returns `201 Created` with `{ copied, warnings, unitMismatchCount, rateRefresh }`.

| Field | Type | Description |
| - | - | - |
| `copied` | object\[] | All newly inserted component rows in the target work item's build-up. |
| `warnings` | string\[] | Aggregate warnings for unit-mismatched instance containers and for any rate lines that could not be refreshed. |
| `unitMismatchCount` | number | Number of instance containers dropped because their production output unit does not match the target work item's unit. |
| `rateRefresh` | object | Summary of the rate-refresh pass. Always present; `requested` is `false` when `refreshRates` was not set. |

The `rateRefresh` object contains:

| Field | Type | Description |
| - | - | - |
| `requested` | boolean | `true` when `refreshRates: true` was passed in the request. |
| `refreshed` | number | Count of rate-library-bound lines that were successfully re-priced against the current library. |
| `unmatched` | number | Count of lines kept at their historical price because no current rate item matched. |
| `ambiguous` | number | Count of lines kept at their historical price because more than one current rate item matched the same code or name. |
| `noPrice` | number | Count of lines kept at their historical price because the matched rate item has no current price. |

When `refreshRates` is `true`, each copied component row also carries a `rateRefreshStatus` field:

| Value | Meaning |
| - | - |
| `null` | Line was successfully re-priced against the current library. |
| `"unmatched"` | No current rate item matched; historical price retained. |
| `"ambiguous"` | More than one current rate item matched; historical price retained. |
| `"no_price"` | Matched rate item has no current price; historical price retained. |

The operation runs under the target estimate's advisory lock. It reads the source build-up without locking the source estimate. Returns `404 Not Found` if the target work item or source work item does not exist or is outside the caller's organisation. Returns `502 Bad Gateway` when `refreshRates: true` and a peer service (Rates API or Global API) read failed.

### Work item component `rateGroup`

`rateGroup` is an optional classification stamp on a component row. It records which cost category the component belongs to, and the pricing engine reads it to decide whether the labour multiplier applies.

**Accepted values**

| Value | Category |
| - | - |
| `labour` | Labour and operators |
| `plant_vehicles` | Plant and vehicles |
| `quarry_materials` | Bulk materials |
| `other_materials` | Other materials |
| `subcontractors` | Subcontractor lines |
| `disposal` | Disposal and tip charges |

**Behaviour on create and update**

* A valid token stamps the field. An invalid token returns `400 Bad Request`.
* An explicit `null` clears a previously stamped group.
* An omitted key leaves an existing stamp untouched (null-vs-absent contract).
* `null` and omitted are both valid on create; the field defaults to `null`.
* `POST /work-item-components` returns the fully-stamped component row in its `201 Created` response. The `rateGroup` value in the response body reflects the persisted state after stamping, not the pre-write input row.

**Relationship to `category`**

`category` is a free-text description field used by the legacy classifier. A recognised `rateGroup` is an authoritative snapshot that supersedes `category`: a `labour` token applies the labour multiplier regardless of the category text, and any other recognised token suppresses it. When `rateGroup` is `null`, absent, or unrecognised, the engine falls back to the category-based sniff. Rows with no recognised group therefore price identically to their pre-stamp behaviour.

**Propagation**

When an activity is materialised into a work item using `POST /work-items/:id/materialise-activity`, each expanded leaf row receives the `rateGroup` stored on the corresponding library component. A library component with no `rateGroup` produces a leaf with `rateGroup: null`.

When a work item is saved as an activity using `POST /work-items/:id/promote-to-activity`, the `rateGroup` on each component is included in the recipe key digest. A re-promote that changes a component's group mints a new recipe rather than replaying the existing one.

**Project-calculator rows**

Project-level calculators publish physical outputs through their assignment register. The destination component owns its rate item, rate option, unit cost, category, and rate group; these pricing fields do not live in the calculator configuration.

***

### Work item component calculator provenance fields

Every component read response — `GET /work-items/:id/components` and the `components` array on `GET /work-items/:id` — includes calculator provenance fields on every row. Hand-entered rows carry `null` for the identity fields and `false` for the flag.

| Field | Type | Description |
| - | - | - |
| `calculatorId` | UUID or `null` | ID of the project-level calculator that generated this row. `null` for hand-entered rows and for rows in the older `wi:` / `wic:` namespaces, whose calculator is the work item's or component's own configuration. |
| `calculatorName` | string or `null` | The calculator's display name, read live rather than stored on the row. `null` when `calculatorId` is `null`, or when the named calculator no longer exists. |
| `calculatorReusesExcavatedMaterial` | boolean | `true` when the source layer reuses excavated material from the dig (the layer's engine kind is `backfill`). The supply cost for such rows may be zero. `false` for all other rows, including hand-entered rows and measurement rows. |

`calculatorName` is not stored on the component row. It is read from the live calculator record so that a rename is immediately visible. `calculatorId` is the stable identity; `calculatorName` can change or be absent without affecting the link.

`calculatorReusesExcavatedMaterial` identifies rows produced by a calculator layer configured with `isBackfill: true`. Because the material is drawn from the site's own excavation, its supply quantity may be zero. Consumers can use this flag to distinguish reuse rows from bought-in fill during pricing review.

**Tender revision snapshots**

`rateGroup` is a pricing input. A snapshot that omits it cannot reproduce the labour-multiplier decision it recorded. The snapshot schema version was advanced to v9 when this field was introduced. Snapshots stored under v1–v8 remain valid as recorded because their `rateGroup` is `null`, which falls through to the `category`-based sniff — the same path those snapshots used at submission time.

***

### Work item component rate option snapshot fields

When a component is linked to a rate item and a rate option is selected, the server persists a snapshot of the selection context. These fields are stored on the component row itself and are returned on every component read.

| Field | Type | Description |
| - | - | - |
| `rateOptionId` | UUID or `null` | The pinned rate option for this component. Server-generated: `null` unless the component has a pinned price selection (`selection_provenance = 'pinned'`). Cannot be written directly. |
| `sourceName` | string or `null` | Display name of the pricing owner. Set for supplier-owned selections; always `null` for organisation-owned selections (see below). `null` when absent. |
| `locationName` | string or `null` | Location associated with the selected option. `null` when absent. |

`rateOptionId` is a generated read-only field. It is derived from the component's stored price selection and reflects the pinned rate option when `selection_provenance` is `pinned`, and `null` otherwise. To set or change a component's rate option, use `PUT /work-item-components/:id/pricing-rule`.

**Organisation-owned selections and `sourceName`**

When a selected rate option is owned by the organisation (its `scope` is `organisation`), `sourceName` is always stored as `null`. Display the organisation's current name from your shell context rather than from this field. Supplier-owned selections store the supplier's name as `sourceName` as normal.

**Name currency**

The stored `sourceName` and `locationName` on editable estimates are kept current automatically. When a supplier or location is renamed in the global API, the server updates these fields on every affected build-up line in every editable estimate of that organisation. Frozen estimates keep the names they held at submission.

**Write protocol for `sourceName` and `locationName`**

On `POST /work-item-components`, supplied values are stamped and omitted fields are stored as `null`.

On `PUT /work-item-components/:id`, a supplied value replaces the snapshot and an omitted key preserves it. Both fields accept an explicit `null` on update, which **clears** the stamp.

**Rate item re-link exception**

When a `PUT /work-item-components/:id` request changes `rateItemId`, the previous selection snapshot is stale. The server automatically clears any of `sourceName` and `locationName` that the same request does not replace. Explicitly supplied values, including `null`, are applied as sent.

**Pricing behaviour**

`rateOptionId`, `sourceName` and `locationName` are snapshot fields used for display and provenance. They do not affect pricing calculations.

### Work item component `priceSelection` and `valueOverrides` fields

Every component read response — `GET /work-items/:id/components` and the `components` array on `GET /work-items/:id` — includes two additional fields that expose the component's full pricing state without requiring a separate call to `GET /estimates/:id/work-item-pricing`.

**`priceSelection`** — `null` when no rate option is selected. When a selection exists, it includes:

| Field | Type | Description |
| - | - | - |
| `rateOptionId` | UUID | Selected rate option. |
| `provenance` | `"auto"` or `"pinned"` | How the option was resolved. |
| `unitCost` | number or `null` | Resolved unit cost. |
| `pricedUnit` | string or `null` | Unit the option is priced in. |
| `conversionFactor` | number or `null` | Conversion factor for the priced unit pair. |
| `libraryUnitCost` | number or `null` | Library-cost audit snapshot. Never used in pricing recomputation. |
| `resolvedTier` | `"override"`, `"rule"`, `"project"`, `"office"`, `"default"`, or `null` | Pricing tier that supplied the option. |
| `resolvedLocationLabel` | string or `null` | Human-readable label for the resolved location. |
| `resolvedRule` | object or `null` | Project pricing rule evaluated for the line. |
| `resolvedRuleOutcome` | `"applied"`, `"unsatisfiable"`, `"policy_unmet"`, or `null` | Outcome of rule evaluation. |
| `projectRateOverrideId` | UUID or `null` | Project rate override, when `resolvedTier` is `"override"`. |
| `resolvedAt` | ISO 8601 datetime or `null` | When the resolution was last computed. |
| `sourceName` | string or `null` | Display name of the pricing owner. `null` for organisation-owned selections. |
| `locationName` | string or `null` | Display name of the resolved location. |
| `optionScope` | `"organisation"` or `"supplier"` or `null` | Who owns the selected option. |

**`valueOverrides`** — an array of manual field-level overrides applied to this component. Each entry contains:

| Field | Type | Description |
| - | - | - |
| `id` | UUID | Override record ID. |
| `field` | string | Which field was overridden: `"name"`, `"category"`, `"quantity"`, `"resource_count"`, `"unit"`, or `"unit_cost"`. |
| `value` | string, number, or `null` | The override value currently in effect. |
| `original` | string, number, or `null` | The value that was in place before the override was applied. |
| `originalSource` | string | Where the original came from: `"row"`, `"selection"`, `"library"`, or `"derived"`. |
| `overriddenBy` | UUID or `null` | User who applied the override. |
| `overriddenAt` | ISO 8601 datetime | When the override was applied. |

> [!WARNING]
>
> `chargeQuantity` and `chargeUnit` are **retired**, and their columns were dropped from
> the component table, together with `selectedLocationId`. No route
> accepts them, no read returns them, and no pricing path reads them — the charge
> basis they encoded belonged to a rate offering, and a rate option is priced in one
> unit with an optional `conversionFactor` instead. The previous statement here — that
> the component cost divides the ordered quantity by `chargeQuantity` before applying
> the rate — is no longer true of any code path.
>
> `estimateQuantity` and `packageSellBasis` are retired too. The server always wrote
> `1` and `"markup"` and nothing read either value. They are no longer accepted on a
> component body or returned on a component read. A client that still sends either
> key has it stripped; the request is not refused. Their database columns were
> physically dropped by migration `0083`.

**Existing components**

Existing components retain their current pricing. The option snapshot fields are populated when a component is next explicitly selected or refreshed through the pricing panel. No automatic backfill occurs.

***

### Work item component `deliveryActivityComponentId`

`deliveryActivityComponentId` is an optional nullable UUID field on a work item component row. It records that a line-entered material row is associated with (delivered through) a canonical activity container on the same work item.

**Source eligibility (D12)**

Only a line-entered, price-bearing material row may carry an outgoing delivery link. The following rows are not eligible as a source:

* Calculator-generated rows (those with a non-null `generatedKey`).
* Materialised child rows (those with a non-null `parentComponentId`).
* Activity container rows.

Attempting to set a delivery link on an ineligible source returns `422 Unprocessable Entity`.

**Target predicate (D13)**

The target must be a canonical activity container on the same work item: a component whose `type` is `"activity"`, `parentComponentId` is `null`, `activityId` is non-null, and `rateItemId` is `null`. The target must belong to the same organisation.

**Write protocol (null-vs-absent triad)**

* A UUID value **sets** the link.
* An explicit `null` value **clears** the link.
* An omitted key **preserves** the existing link.

This applies on `PUT /work-item-components/:id` (update). A new line always arrives inside an activity, so `POST /work-item-components` refuses a link with `422 wrap_delivery_link_not_allowed`. Only an existing line outside an activity can carry one.

**Invariant enforcement**

The server validates the effective post-patch state on every write. An edit that would leave the link in an invalid state returns `422` without making changes — links are never silently cleared by a conflicting edit. To resolve an invariant conflict, clear the delivery link first, or delete the container (which triggers the delete sweep and nulls all incoming links on the same work item).

**Cost parity (D14)**

A delivery link is metadata only. It does not move cost between components or change work item totals. Totals are identical before and after setting or clearing a link.

**Copy and duplication behaviour**

* `POST /estimates/:estimateId/work-items/:itemId/duplicate` — the link is rebound to the equivalent container in the copied work item.
* `POST /estimates/:estimateId/work-items/:itemId/copy-build-up` — links within the copied component set are rebound to the copied containers. A material row copied without its linked container has its delivery link cleared to `null`.

**Delete sweep**

`DELETE /work-item-components/:id` on an activity container that is the target of one or more delivery links nulls those links in the same transaction. This is the only write path that nulls a delivery link without an explicit `null` from the caller.

**Tender snapshot**

`deliveryActivityComponentId` is included in the tender revision snapshot. It is present on each component node that carries a link.

***

### Work item component `generatedKey`

`generatedKey` identifies generated component rows. Current project calculators reconcile their own rows through the assignment register; legacy embedded keys remain compatible stored identities.

| Namespace | Format | Owner |
| - | - | - |
| Project calculator | `calc:<calculatorId>:<layerId>[:<ordinal>]` | Project-level calculator reconciliation |
| Legacy work-item calculator | `wi:<calcKey>` | Retained legacy identity; no embedded engine writes rows |
| Legacy component calculator | `wic:<componentId>:<calcKey>` | Retained legacy identity; no embedded engine writes rows |

The server mints project-calculator keys in lower case. The ordinal is omitted for the first row and included from the second row when a layer is partitioned across destinations in the same work item. See [Project calculators](#project-calculators) for the current routes and [Legacy embedded calculator columns removed](#legacy-embedded-calculator-columns-removed) for compatibility behaviour.

`POST /work-item-components` refuses a `generatedKey` with `422 generated_key_not_allowed`. On `PUT /work-item-components/:id`, the server lowercases the embedded UUID in a `wic:` key while preserving the case of `<calcKey>`. `wi:` keys and values outside the `wic:` scheme are stored unchanged.

When a work item is duplicated, each component-scoped key is rebound to the corresponding component in the copy. If more than one rebound key canonicalises to the same value, later occurrences receive a deterministic `~N` suffix, starting at `~2`, without taking a suffix already present in the copied build-up.

### Legacy embedded calculator columns removed

Migration `0058` guards against unconverted embedded configurations. Migration `0059` physically drops the `calculator` columns on work items and components. Their replacement is the `estimate_calculators` entity and its assignment register.

Stale clients that still send a `calculator` key on `POST /work-items`, `PUT /work-items/:id`, `POST /work-item-components`, or `PUT /work-item-components/:id` are not rejected. Zod strips undeclared keys, so the field is silently ignored and nothing is written.

New calculator work uses the project-level `estimate_calculators` entity and its assignment register. Project-level reconciliation owns the `calc:` generated-row namespace.

## Build-up line quantity links

A build-up line (work item component) can carry a `quantityLink` descriptor that links its quantity to the sum of one or more calculator readings or takeoff measurements. The descriptor uses `QuantityLinkV1` from `@stralos-co-nz/calculators`. See [Linked work-item quantity](https://help.stralos.app/internal/quantity-link.md) for the full descriptor schema. `rateItem` sources are not accepted on build-up lines.

Stralos re-resolves every linked build-up line at the head of each estimate recompute, so a calculator save, reconcile, or delete updates the line's quantity in the same request.

**Eligibility.** Only whole-item priced lines that are not calculator- or recipe-generated can carry a link. Lines priced per unit of the work item, lines belonging to activities, and calculator-generated lines are ineligible.

**Rounding.** `quantityLink.rounding` rounds the line's linked quantity up after the multiplier and offset. A line priced in whole stock lengths, rolls, or sheets already rounds up and takes none: `422 component_link_rounding_whole_stock`.

### Build-up line quantity-link candidates

```text theme={null}
GET /estimates/:estimateId/components/:componentId/quantity-link/candidates
```

Returns the candidate sources a build-up line can link to, filtered to the line's dimension.

| Field | Type | Description |
| - | - | - |
| `unit` | string | The line's stored unit. |
| `dimension` | string or null | The unit's dimension: `length`, `area`, `volume`, `mass`, or `count`. `null` for dimensionless units. |
| `eligible` | boolean | Whether this line can carry a quantity link. |
| `reason` | string | Present when `eligible` is `false`. One of: `generated` (calculator- or recipe-owned row), `per_unit`, `in_activity`, `recipe`, `container`. |
| `calculators` | array | Calculator readings in the line's dimension, in the same shape as work-item quantity-link candidates. Empty when `dimension` is `null`. |
| `takeoffs` | array | Takeoff measurements in the line's dimension, including multi-segment tag subtotals. Empty when `dimension` is `null`. |

**Error responses**

| HTTP | Cause |
| - | - |
| `404` | Component not found, or the component belongs to a different estimate. |

## Materialising an activity into a work item

### Preview an activity expansion

```text theme={null}
POST /work-items/:id/materialise-activity/preview
```

Returns a deterministic expansion plan for a library activity without writing anything to the estimate. Use this to show an estimator the recipe, optional add-ons, package sell pricing, and any unpriced lines before they confirm the insert.

The response includes a `planHash` (SHA-256 hex digest) that binds the expansion inputs and the reachable recipe, component, option, publication, and warning state observed at preview time. Pass this hash to `POST /work-items/:id/materialise-activity` as `planHash` to detect a relevant change before insert.

#### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `activityId` | UUID | Yes | Activity recipe to expand. |
| `quantity` | number (3 dp, ≥ 0) | No | Recipe multiplier. Defaults to `1`. Forbidden when `allocatedQuantity` is supplied. |
| `allocatedQuantity` | number (> 0, finite) | No | Signals instance mode. How much of the work item's quantity this crew handles. Requires `productionRate`. |
| `productionRate` | number (> 0, finite) | Conditional | Required when `allocatedQuantity` is supplied. |
| `selectedOptionalComponentIds` | UUID\[] | No | Optional component IDs to include in the plan. An ID that is not reachable in the recipe returns `422`. Maximum 500 entries. |

#### Response — 200 OK

| Field | Type | Description |
| - | - | - |
| `planHash` | string | SHA-256 hex digest of the expansion inputs and library state. Pass this to the insert endpoint to detect stale plans. |
| `container` | object | Activity container that would be written — `activityId`, `name`, `unit`, `quantity`. |
| `leaves` | object\[] | Price-bearing rows that would be inserted. Each carries `activityComponentId`, `name`, `unit`, `quantity`, `quantityBasis`, `unitCost`, `rateItemId`, `rateOptionId`, `rateOptionLabel`, `path`, and `addonSourceId`. When the library component carries a `quantityBasis` token, the planned leaf carries the same value. |
| `optional` | object\[] | Optional components in the recipe. Each carries `activityId`, `activityName`, `activityComponentId`, `name`, `type`, `optionGroup`, server-derived `optionGroupKey`, `addonCost`, `addonSellAmount`, `sellBasis`, `selected`, and `hasOwnOptionalLines`. Group choices by `optionGroupKey`; identical labels in different nested activities do not compete. |
| `packageSell` | object or `null` | Package sell pricing when the root activity is a published fixed-price package: `baseUnitPrice`, `margin`, `addonTotal`, and `effectiveUnitPrice`. `null` for non-package activities. |
| `warnings` | object\[] | Structured warnings. Each carries `code` (`unresolvable_pin`, `unpublished_fixed_price`, `optional_subtree_truncated`, or `depth_cap_reached`), `message`, and optionally `activityComponentId`. |
| `unpriced` | object\[] | Leaves skipped because they have no rate-library link — `activityComponentId`, `name`, `reason`. |

#### Error responses

| HTTP | Cause |
| - | - |
| `400` | Body fails schema validation. |
| `404` | Work item not found, or the root activity does not exist. |
| `422` | A selected optional component ID is not reachable in the recipe; selected choices conflict within an exclusivity group; `allocatedQuantity` is supplied without `productionRate`; `quantity` is supplied alongside `allocatedQuantity`; the activity is a published fixed-price package in instance mode; or the activity contains components with `whole_activity` or `per_count` quantity bases and `allocatedQuantity` was not supplied (`quantity_basis_requires_production`). |
| `502` | The Activities service is unreachable or returned an unexpected response. |

***

### Insert a materialised activity

```text theme={null}
POST /work-items/:id/materialise-activity
```

This endpoint expands a library activity into price-bearing rows in a work item's build-up. It first walks the recipe through the Activities service, outside the database transaction. One transaction then takes the estimate-level lock, writes a non-pricing activity container and its flattened child rows, and recomputes the estimate once.

### Request body — legacy mode

Omit `allocatedQuantity` to insert using the legacy (quantity-fold) mode.

| Field | Type | Required | Description |
| - | - | - | - |
| `activityId` | UUID | Yes | Activity recipe to expand. |
| `parentActivityComponentId` | UUID | No | Existing activity container on this work item. When supplied, the new activity is nested beneath it. |
| `quantity` | number (3 dp, ≥ 0) | No | Recipe multiplier to insert. Defaults to `1` and is folded into the child quantities; fractional values are accepted. |
| `category` | string | No | Category applied to the container. Maximum 120 characters. |
| `selectedOptionalComponentIds` | UUID\[] | No | Optional component IDs to include in the expansion. Must be a reachable optional in the recipe. Maximum 500 entries. |
| `planHash` | string | **Yes** | SHA-256 hex digest returned by the preview endpoint. Every write must echo a reviewed hash. If the activity library or expansion inputs differ, the server returns `409 stale_materialisation_plan` with the refreshed plan. |

### Commercial-rule resolution

The three commercial rules (`minChargeDuration`, `roundingIncrement`, `mobilisationAmount`) were removed as a feature on 2026-09-20. The instance columns on work item components were dropped by migration 0060. Activity production profiles no longer carry default values for these fields.

Any request body that still sends these field names is accepted and the fields are ignored. No response carries them back.

### Charged-duration calculation

For an instance container, the children's total cost is calculated from a duration derived from the allocation:

```text theme={null}
chargedDuration = allocatedQuantity ÷ productionRate
```

The `unpriced.value` figure in the response uses the charged duration.

### Request body — instance mode

Supply `allocatedQuantity` to create an activity instance. Hour- and day-based activities retain their base duration unit. A quantity-based activity is also valid when its unit matches the work-item unit; Stralos converts it to a day-based instance and scales its recipe by the supplied units-per-day rate.

| Field | Type | Required | Description |
| - | - | - | - |
| `activityId` | UUID | Yes | Activity recipe to expand. |
| `parentActivityComponentId` | UUID | No | Existing activity container on this work item. When supplied, the new activity is nested beneath it. |
| `allocatedQuantity` | number (> 0, finite) | Yes | How much of this work item the crew instance handles, measured in the instance's allocation unit. Signals instance mode; `quantity` is forbidden when this field is present. |
| `productionRate` | number (> 0, finite) | Conditional | Output per activity base duration in the allocation unit (for example, metres per day or each per day). Required unless a unit-equal production profile supplies it automatically. |
| `productionProfileId` | UUID | No | A production profile from the root activity. When the profile's output unit matches the work item's unit, its rate is used and `productionRate` may be omitted. |
| `scopeLabel` | string | No | Allocation-group label. Instances sharing the same non-empty label on one work item form a group; the UI warns when their allocations do not sum to the work item quantity. |
| `chainLabel` | string | No | Crew-chain label. Instances sharing the same non-empty label run sequentially for duration and programme purposes. Does not affect pricing. |
| `category` | string | No | Category applied to the container. Maximum 120 characters. |
| `selectedOptionalComponentIds` | UUID\[] | No | Optional components selected in the reviewed plan; maximum 500. |
| `planHash` | string | **Yes** | SHA-256 digest returned by preview for these instance inputs and optional selections. |

The materialisation request does not accept `productionOutputUnit`. Creation derives the allocation unit from the work item; a selected production profile can supply its rate automatically only when its output unit matches that work-item unit. To use a different allocation unit after creation, update the returned container through `PUT /work-item-components/:id` with `productionOutputUnit` and any corresponding allocation or production-rate adjustments; the editing contract below canonicalises and stores it.

In instance mode the container's quantity is fixed at `1`; the allocation drives the duration rather than a container quantity multiplier. Child quantities are stored per base unit of the activity (per day or per hour).

The commercial-rule fields (`minChargeDuration`, `roundingIncrement`, `mobilisationAmount`) are no longer accepted. Sending them is ignored — no `422` is returned — but they are not stored or applied.

### Materialisation response

The endpoint returns `201 Created` with `{ container, children, warnings, unpriced, planHash }`. The returned hash is the plan the server committed.

| Field | Type | Description |
| - | - | - |
| `container` | object | Inserted activity row. It has no `rateItemId` and does not price directly. In instance mode it also carries `allocatedQuantity`, `productionRate`, `productionOutputUnit`, `productionProfileId`, `scopeLabel`, `chainLabel`, and `activityCount`. When the activity is a published fixed-price package, it also carries `packageSellUnitPrice`, `packageSellMargin`, and `packageAddons` (see below). |
| `children` | object\[] | Inserted price-bearing leaves. In legacy mode, quantities include the requested recipe quantity and every nested quantity and resource-count multiplier. In instance mode, quantities are per base unit of the activity. A child that belongs to a selected optional add-on carries a non-null `packageAddonSourceId` identifying the optional component that contributed it. |
| `warnings` | string\[] | Advisory messages for truncated depth, missing nested activities, and lines that could not be priced. |
| `unpriced` | object | Machine-readable summary of skipped, unpriced leaves. It is always present. |

The `unpriced` object contains:

| Field | Type | Description |
| - | - | - |
| `count` | number | Number of leaves skipped because they have no rate-library link. |
| `value` | number | Money excluded from the estimate, rounded to cents. In legacy mode this is scaled by the work item's quantity; in instance mode it is scaled by the charged base-unit duration. |
| `names` | string\[] | Complete list of skipped line names. |

#### Package sell fields on the container

When the inserted activity is a published fixed-price package, the container row carries these additional fields:

| Field | Type | Description |
| - | - | - |
| `packageSellUnitPrice` | number or `null` | Published base unit sell price from the activity library, snapshotted at insert time. `null` for non-package containers. |
| `packageSellMargin` | number or `null` | Published sell margin (percent) snapshotted at insert time. `null` for non-package containers. `packageSellUnitPrice` and `packageSellMargin` are always set or cleared together. |
| `packageAddons` | object\[] or `null` | Snapshotted add-on ledger for selected optional components. Each entry carries `optionalComponentId`, `addonSellAmount`, and `sellBasis` (`"published"` or `"parent_margin"`). `null` when no optional components were selected or the container is not a package. |

#### Package add-on provenance on child rows

| Field | Type | Description |
| - | - | - |
| `packageAddonSourceId` | UUID or `null` | The optional component ID from the activity library whose selection contributed this child row. `null` for included (non-optional) leaf rows. |

Materialised children are detached snapshots: later library edits do not update them. Their server-owned `parentComponentId` attaches them to the container, and pricing treats them as recipe-derived rows. They are written with `generatedKey: null`, so calculator reconciliation cannot claim or remove them.

### Editing an activity instance container

After materialisation, the instance container can be updated using `PUT /work-item-components/:id`. The following fields apply only to instance containers:

| Field | Omitted | Explicit value | Explicit `null` |
| - | - | - | - |
| `allocatedQuantity` | Preserved | Updated; must be positive and finite | `422` — mode operands cannot be cleared |
| `productionOutputUnit` | Preserved | Updated; canonicalised and stored as the new allocation unit. A profile reselect in the same request takes precedence. | `422` — the allocation unit cannot be cleared |
| `productionRate` | Preserved | Updated; must be positive and finite | `422` — mode operands cannot be cleared |
| `productionProfileId` | Preserved | Reselected; rate and output-unit snapshots are re-derived | Cleared; the supplied production rate remains authoritative |
| `scopeLabel` | Preserved | Updated | Cleared |
| `chainLabel` | Preserved | Updated | Cleared |
| `activityCount` | Preserved | Updated; greater than 0 and less than 1 × 10¹⁰, with up to 3 decimal places | Cleared (a per-count child uses a multiplier of 1) |

A non-null `activityCount` must belong to an instance container. Explicit `null` is accepted as a harmless clear on other component rows.

### Child quantity basis

`quantityBasis` controls how a direct child of an instance container is charged:

| Value | Behaviour |
| - | - |
| `null` | Legacy per-base-unit charging: stored quantity × charged duration. |
| `"whole_activity"` | Stored quantity is charged once, without duration, work-item quantity, or activity-count multiplication. |
| `"per_count"` | Stored quantity is multiplied by the container's `activityCount`, defaulting to 1. |
| `"per_allocated"` | Stored quantity is charged per unit of the activity's allocated quantity. |

When a library activity component carries `whole_activity` or `per_count`, the materialisation planner propagates that basis to the leaf row. The insert endpoint applies the same token to the written component, so an activity designer's charge basis reaches the estimate without manual adjustment after materialisation. An activity whose recipe contains any non-null basis token requires instance mode (`allocatedQuantity`) — materialising it in legacy mode returns `422 quantity_basis_requires_production`.

On `PUT /work-item-components/:id`, omission preserves the stored basis, a token
sets it, and explicit `null` clears it. `POST
/work-item-components/:id/children` accepts the field when creating a direct
instance child. A non-null basis on any other row returns `422`; explicit
`null` is accepted. Invalid tokens fail schema validation with `400`.

Changing the basis recomputes estimate totals in the same transaction. During
`POST /work-item-components/:id/enable-daily-production`, children that already
carry a non-null basis are left unchanged rather than being rescaled as legacy
per-container quantities.

Both `activityCount` and `quantityBasis` are pricing inputs included in tender
revision snapshots. Their introduction advanced the snapshot schema to v19.

The container's `activityId`, `unit`, `quantity`, and `type` are fixed. Delete
and re-materialise it to use another activity or unit. A work item's unit cannot
be changed while it has an instance whose `productionOutputUnit` matches the
work item's unit (canonicalised, case-insensitive). An instance denominated in
a different unit does not block a work item unit change.

### Option-group exclusivity

When an activity recipe includes optional components under an `option_group`, the materialisation plan enforces at most one selected line per group and owning activity. Two nested recipes can use the same group name independently, so selecting one option from each is valid.

### Expansion and error behaviour

* The root activity is depth 1. Nested activities are expanded through depth 3; deeper content is skipped with a warning.
* A missing nested activity is skipped with a warning. A missing root activity returns `404 Not Found`.
* A recipe cycle returns `422 Unprocessable Entity`.
* A recipe requiring more than 200 activity fetches returns `422 Unprocessable Entity`.
* An unreachable Activities service, a non-success response other than a missing activity, or an unreadable response returns `502 Bad Gateway`.
* A missing work item, including one outside the caller's organisation, returns `404 Not Found`.
* A `parentActivityComponentId` that is missing, belongs to another work item, or is not an activity container returns `422 Unprocessable Entity`.
* A quantity-based activity whose unit does not match the work-item unit returns `422 Unprocessable Entity`.
* Supplying instance-mode fields without `allocatedQuantity` returns `422 Unprocessable Entity`.
* Supplying `quantity` alongside `allocatedQuantity` returns `422 Unprocessable Entity`.
* A `productionProfileId` that does not belong to the root activity returns `422 Unprocessable Entity`.
* A mismatched profile unit without an explicit `productionRate` returns `422 Unprocessable Entity`.
* A derived duration that underflows or exceeds bounds returns `422 Unprocessable Entity`.
* Omitting `planHash` returns `422 Unprocessable Entity` with code `plan_hash_required`, before activity fan-out or any database write. A present hash that is not 64 lowercase hexadecimal characters is a schema-level `400`.
* A `selectedOptionalComponentIds` entry that is not a reachable optional component in the plan returns `422 Unprocessable Entity`.
* Two entries in `selectedOptionalComponentIds` sharing the same activity-scoped exclusivity group returns `422 Unprocessable Entity`.
* A published fixed-price package nested beneath an existing production instance container returns `422 Unprocessable Entity` with code `published_package_instance_unsupported`.
* An activity that contains components with `whole_activity` or `per_count` quantity bases, materialised without `allocatedQuantity`, returns `422 Unprocessable Entity` with code `quantity_basis_requires_production`. Enabling daily production (instance mode) is required so the charging bases can be applied.
* When the reviewed hash differs from the fresh plan, the server returns `409 Conflict` with code `stale_materialisation_plan`. The response body includes an updated `plan` from which the client can display the changes and re-confirm.

## Saving a work item or activity subtree as an activity

```text theme={null}
POST /work-items/:id/promote-to-activity
```

This endpoint copies all or a selected part of the work item's authoritative server-side build-up into a reusable Activities-library recipe. Omit `componentIds` to copy the whole build-up. Otherwise, each supplied ID must identify either a top-level component or an activity container; selected activity containers include their complete descendant subtree. The current detail-pane flow supplies the selected activity container's ID to save that activity subtree. The operation does not modify the work item or attach the new activity to it. Library writes require the caller to be an organisation admin.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | New activity name. Maximum 200 characters. |
| `unit` | string | Yes | Measurement basis for the activity. Maximum 40 characters. Used as the saved activity's unit when the selection does not resolve to a single activity container. When the selection resolves to exactly one activity container, the server uses that container's stored unit instead, preserving its recipe input basis. |
| `componentIds` | UUID array | No | Components to copy. Each must be a top-level component or an activity container; selected activity containers include all descendants. Maximum 500 IDs. Omit the field to copy the whole build-up; an explicit empty array is rejected. |

`category` is not part of the activity record. If a stale client sends it, schema parsing strips it and the value is not saved.

### Copy rules

* Ordinary internal build-up rows are copied in their stable display order using their stored base unit costs. Subcontract mirror rows are excluded because the activity library cannot represent bid pricing.
* A materialised activity container is omitted and its expanded children are copied as ordinary flat components. This preserves the work item's snapshot and prevents the container and its children from pricing the same recipe twice.
* An activity container with no attached expansion is omitted with a warning because there is no recipe content to copy.
* Calculator identity, multiplier, working-day, and pricing-override data are retained in component `metadata` as provenance where present. These values are inert in the activity library; the saved recipe prices from base unit costs.
* A manual row without a rate-library link cannot be copied. The server refuses the entire save before calling the Activities service because that row would be skipped as unpriced when the saved activity was later materialised. Add each manual line to the rate library and link it before saving the activity.
* If no eligible components remain, the API saves an empty activity and returns a warning.
* An explicit empty `componentIds` array (present but zero-length) returns `422 Unprocessable Entity` with code `empty_component_selection`. An absent `componentIds` means copy the whole build-up; an empty array means nothing was selected and is rejected rather than treated as a full copy.

Before saving, the server performs a best-effort, case-insensitive check for an existing activity with the same name and unit. A match adds a warning but does not block the save.

The idempotency key is derived from the work item ID, requested name and unit, and mapped component content. Retrying the same unchanged save returns the existing library activity; changing content that would be written produces a new recipe.

### Save-as-activity response

The Estimate API always returns `201 Created`, including when the Activities API reports an idempotent replay.

| Field | Type | Description |
| - | - | - |
| `activity` | object | Created or replayed activity record. Its `idempotentReplay` field identifies a replay. |
| `componentCount` | number | Number of components written to the recipe. |
| `warnings` | string\[] | Exclusions, inert inputs, empty content, near-duplicate, and replay advisories. |

The endpoint returns `403 Forbidden` when the caller is not permitted to maintain the organisation's activity library, `404 Not Found` when the work item is unavailable, and `502 Bad Gateway` when the Activities service cannot complete the save.

| HTTP | Code | Cause |
| - | - | - |
| `422` | `empty_component_selection` | `componentIds` is present but empty. Select at least one component or omit the field to copy the whole build-up. |
| `422` | `invalid_component_selection` | One or more supplied IDs are missing from this work item or identify a nested non-activity component. The response body includes `invalidComponentIds`. |
| `422` | `manual_rates_not_in_library` | One or more lines that would be copied have no rate-library link. The response includes `manualComponentIds` and `manualComponentNames`; create or select a library rate for each line, then retry. Subcontract mirrors and containers omitted from the recipe do not trigger this refusal. |

## Work item similarity search

```text theme={null}
GET /work-items/similar
```

Returns work items from across the caller's organisation whose names are similar to a query name, together with their build-up components. Use this endpoint to surface reusable work items while an estimator is building up a new one.

This endpoint and the activity endpoints below are protected. A missing or invalid bearer token returns `401 auth_required`; an authenticated caller without organisation context returns `403`.

Results are ranked by name similarity. Items below the service's `0.2` similarity cutoff are excluded, and the remaining results are ordered by descending score.

**Only work items that have at least one build-up component are candidates.** Items with no components are excluded before the limit is applied. This means the response may return fewer results than `limit`, or an empty `items` array, when the organisation's past work items are largely unpriced imports.

### Query parameters

| Parameter | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Name to match against. |
| `excludeEstimateId` | UUID | No | Exclude work items from this estimate (use to filter out items from the estimate being edited). |
| `limit` | integer | No | Maximum number of results. Range: 1–10. Defaults to 5. |

### Response

**200 OK** — `{ items }` where each entry is a work item row extended with a `similarity` score and a `components` array containing that item's build-up components in display order. Every item in the response has at least one component.

## Manual activity

```text theme={null}
POST /work-items/:id/manual-activity
```

Creates a work-item-local daily-production activity container with no activity-library source. Use this when an estimator needs a daily-production build-up that is not derived from a library activity. After creation, append price-bearing rate rows beneath the container using `POST /work-item-components/:id/children`.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Display name for the activity container. |
| `allocatedQuantity` | number | Yes | How much of the work item's quantity this activity handles, in work-item units. Must be positive and finite. |
| `productionRate` | number | Yes | Output per day in work-item units. Must be positive and finite. |
| `parentActivityComponentId` | UUID | No | Nest the new container beneath an existing activity container on the same work item. The named component must be an activity container; otherwise the request returns `422`. |

The new container is inserted at the end of the work item's build-up (or at the end of the parent container's children when `parentActivityComponentId` is supplied).

**Response** — `201 Created` — the created activity container component row.

**Error responses**

| HTTP | Cause |
| - | - |
| `400` | The path or request body fails schema validation. |
| `404` | Work item not found. |
| `422` | `parentActivityComponentId` does not refer to an activity container on this work item, or the allocation/rate pair is invalid. |

## Wrapping a time-based rate in its own activity

```text theme={null}
POST /work-items/:id/time-resource-activity
```

Atomically creates an activity container and a nested rate child for a labour or plant/vehicle rate priced per hour, day, week, or month. Use this endpoint when an estimator adds such a rate at the top level of a work item: a standalone time-based rate has no production rate to anchor its quantity, so it must live inside an activity container. The endpoint creates both rows in one transaction.

The server re-derives the routing decision from the supplied rate fields. It will reject any rate that is not classified as `labour` or `plant_vehicles`, and any rate whose unit is not `hour`, `day`, `week`, or `month`. A caller cannot use this endpoint to wrap a material, a subcontractor row, or a compound productivity unit such as `m/day`.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Display name for the rate row. Maximum 200 characters. |
| `unit` | string | Yes | Time unit for the rate. Accepted values after normalisation: `hour`, `day`, `week`, `month`. |
| `duration` | number (3 dp, ≥ 0) | Yes | Total number of the rate's base time units. For example, `16` for 16 hours or `2` for 2 weeks. The server derives the activity container's production rate from this value. |
| `rateItemId` | UUID | No | Rate library item to link. |
| `rateOptionId` | UUID | No | Specific rate option selection. |
| `rateGroup` | `"labour"` or `"plant_vehicles"` | No | Cost category for the rate row. The server validates that the rate classifies as one of these two groups. |
| `category` | string | No | Free-text category. Maximum 120 characters. |
| `code` | string | No | Optional component code snapshot. Standard calculator and activity adapters read the legacy item-level `code`, so current rate-item selections normally leave it absent. The supplier's `sourceItemCode` is a separate option field and is not generally copied here. |
| `unitCost` | number | No | Override unit cost. |
| `sourceName` | string | No | Display name of the rate source. |
| `locationName` | string or `null` | No | Source location for the selected option. |
| `hoursPerDay` | number or `null` | No | Working-day override for this line. Omit to inherit the estimate default. |
| `resourceCount` | number (3 dp, ≥ 0) | No | Resource count multiplier. Defaults to `1`. |
| `multiplier` | number (4 dp, ≥ 0) | No | Additional multiplier applied to the line cost. Defaults to `1`. |

### How duration maps to the activity container

The server maps the entered duration into the container's base unit before deriving production:

* For an hourly rate, the container unit is `day`, `instanceDuration = duration / hoursPerDay`, and the nested child quantity is `hoursPerDay`. This preserves the entered total hours while expressing production in working days.
* For day-, week-, and month-based rates, the container keeps that unit, `instanceDuration = duration`, and the nested child quantity is `1`.
* When the mapped duration is greater than zero, `allocatedQuantity` is the work-item quantity when that value is positive, otherwise `1`; `productionRate = allocatedQuantity / instanceDuration`.
* When `duration` is zero, the container is structural (non-instance) and holds a zero-quantity child. The estimator can fill it in later.

Container identity, ancestry, quantity, and sort order are all server-owned.

### Response

**201 Created** — `{ container, child }`.

| Field | Type | Description |
| - | - | - |
| `container` | object | The inserted activity container component row. Carries `allocatedQuantity`, `productionRate`, and `productionOutputUnit` when `duration` is greater than zero. |
| `child` | object | The inserted rate row nested beneath the container. |

### Error responses

| HTTP | Cause |
| - | - |
| `400` | The request body fails schema validation. |
| `404` | Work item not found or belongs to a different organisation. |
| `422` | The rate is not classified as `labour` or `plant_vehicles`, the unit is not a time unit (`hour`, `day`, `week`, `month`), or the derived allocation/rate pair is invalid. |

## Appending a child row to an activity container

```text theme={null}
POST /work-item-components/:id/children
```

Appends a price-bearing rate row as a direct child of an activity container. Use this alongside the manual activity endpoint to build up daily-production activities line by line, or to add lines to any existing activity container.

The target component identified by `:id` must be an activity container (a component with `type: "activity"`). A non-container target returns `422`.

### Request body

The body accepts the same fields as `POST /work-item-components`, except that `workItemId`, `type`, `activityId`, `subcontractBidRateId`, `generatedKey`, `calculator`, `deliveryActivityComponentId`, and all activity-instance fields are omitted. The `type` defaults to `rate_item`.

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | Yes | Display name for the row. |
| `quantity` | number | No | Quantity per day (for daily-production containers) or absolute quantity. Defaults to `1`. |
| `unit` | string | Yes | Unit for this row. |
| `unitCost` | number | No | Override unit cost. |
| `rateItemId` | UUID | No | Rate library item to link. |
| `rateOptionId` | UUID | No | Specific rate option selection. (Was `ratePriceId`; a rate price is no longer an entity.) |
| `rateGroup` | string or `null` | No | Cost category: `labour`, `plant_vehicles`, `quarry_materials`, `other_materials`, `subcontractors`, or `disposal`; explicit `null` clears it. |
| `code` | string | No | Optional component code snapshot. Standard adapters read the legacy item-level `code`, so current rate-item selections normally leave it absent. The option's `sourceItemCode` is separate. |
| `category` | string | No | Free-text category. |

**Response** — `201 Created` — the created child component row. Estimate totals are recomputed.

**Error responses**

| HTTP | Cause |
| - | - |
| `400` | The path or request body fails schema validation. |
| `404` | Activity container not found. |
| `422` | Target is not an activity container. |

## Converting an activity to daily production

```text theme={null}
POST /work-item-components/:id/enable-daily-production
```

Converts a legacy materialised activity container into the daily-production (instance) model in place. Use this when an activity was materialised without allocation and production rate fields and the estimator now wants to manage it as a daily-production crew.

The target container must be an activity container, must not already have daily production enabled (`allocatedQuantity` and `productionRate` must both be absent), and its quantity must be greater than zero. The container's unit must match the parent work item's unit.

The server converts the container's existing legacy-basis child quantities from absolute (scaled by the container quantity) to per-day-output-unit quantities using the supplied production rate, then sets `allocatedQuantity`, `productionRate`, and `productionOutputUnit` on the container atomically. A child with a non-null `quantityBasis` is preserved and not rescaled.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `allocatedQuantity` | number | Yes | How much of the work item's quantity this activity handles, in work-item units. Must be positive and finite. |
| `productionRate` | number | Yes | Output per day in work-item units. Must be positive and finite. |

**Response** — `200 OK` — the updated activity container component row. Estimate totals are recomputed.

**Error responses**

| HTTP | Cause |
| - | - |
| `400` | The path or request body fails schema validation. |
| `404` | Activity container not found. |
| `422` | Target is not an activity container, daily production is already enabled, the container quantity is zero, the container and work item units do not match, or a child quantity would become invalid after conversion. |

## Project work item import

These two endpoints together implement a two-phase import of work items from one project's estimate into another: the whole schedule, or chosen sections and lines. Both routes are organisation-scoped and require a valid bearer token.

### Preview

```text theme={null}
POST /estimates/:estimateId/work-items/import-project/preview
```

Reads the source project's estimate and returns a summary of what would be imported, together with a `previewToken` that must be supplied when confirming the import.

#### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `sourceProjectId` | UUID | Yes | The project whose estimate will be read. Must belong to the same organisation and use the same currency as the destination estimate. |

#### Response

**200 OK**

| Field | Type | Description |
| - | - | - |
| `previewToken` | string | 64-character hex digest that captures the current state of the source and destination. Pass this unchanged to the confirm endpoint. It expires when either estimate changes. |
| `sourceEstimateId` | UUID | Estimate ID of the source project. |
| `currency` | string | Currency code of the source estimate. |
| `workItems` | integer | Number of work items in the source. |
| `sections` | integer | Number of sections in the source. |
| `calculators` | integer | Number of project calculators in the source. |
| `currentWorkItems` | integer | Number of work items currently in the destination estimate. |
| `overrides` | array | Pricing overrides in the source that the caller must review. Each entry has `id`, `kind` (`manual` or `project_rate`), `workItem`, `name`, and `source`. Pass chosen IDs as `keepOverrideIds` to retain them. |
| `tree` | object | The source schedule for choosing sections and lines: `sections` (each with `id`, `sectionNumber`, `title`, `isSpreadable`, and `matchingSectionId`, the destination section it clashes with by title at the same level, or `null`), `workItems` (each with `id`, `sectionId`, `parentWorkItemId`, `itemNumber`, `name`, `quantity`, `unit`, `unitRate`, `totalPrice`, `itemKind`, and `calculatorIds`), `calculators` (`id`, `name`, `type`), and `destinationSections` (`id`, `sectionNumber`, `title`). |
| `warnings` | string\[] | Informational notices about what is not carried across (document links, subcontract quotes, margin settings). |

#### Error responses

| HTTP | Cause |
| - | - |
| `400` | Invalid estimate ID or request body. |
| `401` | Missing or invalid bearer token. |
| `403` | Caller is not authorised for the destination estimate. |
| `404` | Destination estimate not found, or the source project has no estimate. |
| `422` | The source and destination estimates use different currencies. The source and destination are the same project. |

### Confirm import

```text theme={null}
POST /estimates/:estimateId/work-items/import-project
```

Applies the import. The write is atomic and idempotent: sending the same `requestId` a second time returns the previously recorded result without writing again.

#### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `sourceProjectId` | UUID | Yes | Source project. Must match the value used when obtaining the preview. |
| `requestId` | UUID | Yes | Caller-generated idempotency key. |
| `previewToken` | string | Yes | The 64-character token from the preview response. |
| `mode` | `append` \| `replace` | Yes | `append` — add source sections after any existing ones. `replace` — delete every existing work item and section from the destination before writing. |
| `includeDetails` | boolean | Yes | `true` — copy build-up components. `false` — import sections and work items without any build-up. |
| `calculators` | `copy` \| `rates_only` | Yes | `copy` — copy the source project calculators and remap their assignments to the new item IDs. `rates_only` — omit calculators; imported build-up rows carry frozen quantities derived from the source. Only effective when `includeDetails` is `true`. Bulk-dig calculator rows (`pit-excavation` and `vol-excavation` layers) and their layer-scoped readings are always excluded, in both modes. |
| `pricing` | `latest` \| `source` | Yes | `latest` — re-resolve each eligible component against the destination project's current rate context. `source` — pin prices as they stood in the source estimate. Only effective when `includeDetails` is `true`. Day-rate lines are always copied verbatim regardless of this setting. |
| `keepOverrideIds` | string\[] | Yes | IDs from the preview `overrides` array that the caller wants to retain. Overrides not listed here are discarded and their components fall back to library or project-rate pricing. Pass an empty array to discard all overrides. Maximum 20,000 entries. |
| `selection` | object | No | Import only chosen sections and lines. Omit for the whole schedule. Only allowed with `mode: "append"`. See [Import selection](#import-selection). |

#### Import selection

| Field | Type | Required | Description |
| - | - | - | - |
| `sectionIds` | UUID\[] | Yes | Source sections to bring. Each brings its work items and every section numbered under it. Maximum 2,000. |
| `workItemIds` | UUID\[] | Yes | Single source lines to bring, each with its sub-items. A chosen sub-item whose parent stays behind arrives as an ordinary line. Maximum 2,000. At least one of `sectionIds` and `workItemIds` must be non-empty. |
| `linesTarget` | object | When needed | Where lines whose section is not brought go: `{ kind: "existing", sectionId }` for a destination section, or `{ kind: "new", title }` for a new top-level section. |
| `sectionPlacements` | object\[] | No | Answers for brought sections whose title a destination section at the same level already has: `{ sectionId, kind: "existing", targetSectionId }` adds the section's items to that destination section; `{ sectionId, kind: "new", title? }` creates a new section, optionally under another title. |

Calculators feeding a brought line are copied only when `calculators` is `copy`.

Whole-schedule append also checks section titles. It numbers the arriving sections first: a top-level section keeps its number when the destination has nothing at or under it, otherwise it takes the next free whole number, and its subsections follow it. If any arriving section then shares a title with a destination section at the same level, the import is refused with `422 project_import_section_title_taken` and nothing is written.

#### Response

**201 Created** (first delivery) or **200 OK** (idempotent replay).

| Field | Type | Description |
| - | - | - |
| `workItems` | integer | Number of source work items written. |
| `sections` | integer | Number of new sections created. |
| `calculators` | integer | Number of calculators copied. |
| `removedWorkItems` | integer | Number of destination work items deleted (non-zero only in `replace` mode). |
| `refreshedRates` | integer | Number of components whose price was re-resolved to the destination project's current rate. |
| `warnings` | string\[] | Informational notices, such as companion charges that were removed or a rate that could not be refreshed. |

#### Error responses

| HTTP | Code | Cause |
| - | - | - |
| `400` | — | Invalid estimate ID or request body. |
| `401` | — | Missing or invalid bearer token. |
| `403` | — | Caller is not authorised for the destination estimate. |
| `404` | — | Destination estimate not found, or source project has no estimate. |
| `409` | `conflict` | The preview token no longer matches — the source or destination changed between preview and confirm. Obtain a fresh preview and retry. Also returned when the same `requestId` was already used with different body fields. |
| `409` | `project_import_section_exists` | A brought section's title matches a destination section at the same level and `sectionPlacements` has no answer for it. `sectionIds` lists the sections to ask about. |
| `422` | — | A `keepOverrideIds` entry is not in the preview's offered override list. A rate override cannot be discarded because no unambiguous current price is available — keep the override or resolve the library rate first. The source contains a broken work-item hierarchy. |
| `422` | `project_import_section_title_taken` | Whole-schedule append: one or more arriving sections share a title with a destination section at the same level, and the message names every clashing title. With a `selection`: a section's chosen title, or the new `linesTarget` title, is already used at that level. `sectionIds` lists the clashing source sections. |
| `422` | `project_import_selection_replace` | `selection` was sent with `mode: "replace"`. |
| `422` | `project_import_selection_empty` | `selection` names no sections and no lines. |
| `422` | `project_import_selection_unknown` | A chosen section, line, or placement is no longer in the source project. Preview again. |
| `422` | `project_import_target_required` | Chosen lines have no brought section and `linesTarget` is missing. |
| `422` | `project_import_target_missing` | A `linesTarget` or `sectionPlacements` destination section is no longer in the estimate. |

## Work item batch mutations

`POST /estimates/:estimateId/work-items/batch` applies multiple work-item mutations to a single estimate in one atomic transaction with a single trailing pricing recompute.

### Request body

All operation groups are optional, but at least one must be present.

| Field | Type | Description |
| - | - | - |
| `reviewedBidPackage` | `{ packageId: string, revision: string }` | Optional. Binds the batch to a whole-return review snapshot before applying selected bid rates. See [Reviewed bid selection](#reviewed-bid-selection). |
| `delete` | `string[]` | IDs of work items to delete. |
| `move` | `{ ids: string[], sectionId: string \| null }` | Move the listed items into `sectionId`. Pass `null` to remove them from any section. SOQ-locked items cannot be moved; if any listed item is locked the whole batch returns `422`. |
| `reprice` | `{ id: string, pricingSource: string }[]` | Set the pricing source for each item. Use `"internal"` or the canonical subcontract token `"subcontract:{bidRateId}"`. |
| `setQuantity` | `{ id: string, quantity: number }[]` | Set the quantity for each item (3 dp, ≥ 0, \< 1 × 10¹⁰). |
| `duplicate` | `string[]` | IDs of items to deep-copy within the same estimate. |

### Envelope invariants

The request is fully validated before any write. The entire batch is rejected if:

* No operation contains any work-item IDs (400).
* Any ID appears in more than one operation group, or appears twice within one group (409 — the response includes a `conflictingIds` array).
* Any ID does not belong to the path estimate (404).
* `reviewedBidPackage` is present alongside `delete`, `move`, `setQuantity`, or `duplicate` (422 — a reviewed bid selection may only be combined with `reprice`).

### Execution order

When multiple operations are present, they run in this fixed order: `setQuantity` → `reprice` → `move` → `duplicate` → `delete`. A single recompute covers all writes.

### Reviewed bid selection

When `reviewedBidPackage` is supplied, the server validates the whole-return review snapshot before applying the `reprice` entries. This prevents rates from being applied to work items whose quantities, units, or package membership have changed since the review was opened.

The `reviewedBidPackage` object requires:

| Field | Type | Description |
| - | - | - |
| `packageId` | UUID | The subcontract package whose review snapshot to validate against. |
| `revision` | string | The SHA-256 hex digest returned by `GET /subcontract-packages/:id/review`. The server recomputes the current review under the estimate lock and rejects the batch if the digest has changed. |

When `reviewedBidPackage` is present, all `reprice` entries must use a selectable bid rate from that package. The batch validates that every named `pricingSource` of the form `"subcontract:{bidRateId}"` identifies a bid rate that was selectable in the snapshot — correct package, matching unit and quantity, no warnings that disqualify selection, and no duplicate rates for that work item.

**If the snapshot has changed** (for example, a bid rate was edited, a work item quantity changed, or the package membership changed since the review was loaded), the server returns `409` with a message prompting the caller to refresh the review before selecting rates. Nothing is written.

**If a selected rate is not selectable** (missing, wrong package, basis mismatch, or a disqualifying warning), the server returns `422`. Nothing is written.

**Error responses specific to reviewed bid selection**

| HTTP | Cause |
| - | - |
| `409` | The review snapshot has changed. Reload `GET /subcontract-packages/:id/review` and confirm the selections again before retrying. |
| `422` | A `reprice` entry names a bid rate that is not selectable in this package and review snapshot. Check the `selectable` flag and `warnings` on the bid rate before retrying. |
| `422` | `reviewedBidPackage` is combined with `delete`, `move`, `setQuantity`, or `duplicate`. A reviewed bid selection may only be combined with `reprice`. |

### Batch response

HTTP 200 on success.

| Field | Type | Description |
| - | - | - |
| `deleted` | `string[]` | IDs of deleted items. |
| `moved` | `string[]` | IDs of moved items. |
| `repriced` | `string[]` | IDs of repriced items. |
| `quantitySet` | `string[]` | IDs of items whose quantity was updated. |
| `duplicated` | `{ sourceId: string, item: WorkItem }[]` | Each duplicated clone with the source ID and the full derived work-item response. |

A failure in any operation rolls back the entire batch.

## Reorder work items within a section

```text theme={null}
POST /estimates/:estimateId/work-items/reorder
```

Replaces the display order for one complete section group. Pass `sectionId: null` for unsectioned items.

| Request field | Type | Description |
| - | - | - |
| `sectionId` | UUID or `null` | Target section, or the unsectioned group. |
| `orderedIds` | UUID\[] | Every work-item ID currently in the group, in the required order. |

The service takes the estimate lock and compares the supplied IDs with current membership before writing. It rejects partial, duplicated, stale, or filtered sets with `409 work_item_membership_drift`; the response includes `currentIds` so the client can refresh. A missing or cross-organisation section uses the same non-leaking `409` response, while a visible section from another estimate returns `422`.

If the requested order would change an SOQ-locked item's relative position, the service returns `422 Unprocessable Entity` with code `soq_order_violation` and writes no rows. The response body includes `offendingIds` — the IDs of the locked items that form at least one inverted pair. See [SOQ lock](#soq-lock) for the locking conditions.

The SOQ check compares the relative order of locked items, not their absolute positions. Manual (non-locked) items may be placed anywhere in the list, including between two locked rows. The invariant that must hold is that all locked items appear in `orderedIds` in the same sequence they currently occupy — this is a subsequence check. A reorder that does not move any locked item past another always succeeds, regardless of what the stored `sortOrder` values happen to be.

On success the service updates both work-item `sortOrder` values and their linked top-level programme headers. An empty list is a valid no-op for an empty group. The response is `200 OK` with the reordered work items.

After writing the new sort order, the service re-derives `itemNumber` for every row in the section. See [Work item numbering](#work-item-numbering) for how numbers are assigned.

## Work item numbering

Stralos assigns and maintains `itemNumber` automatically on every server write that affects a section's membership or order. Clients normally omit the field; the server derives one from the section's own number and the row's position. Import integrations may supply a contractual number explicitly, which the service stores verbatim.

### Section prefix

A section that has a `sectionNumber` uses that value as the prefix. A section with no number uses its 1-based sort position instead (the first section in an estimate is `1`, the second is `2`, and so on). Unsectioned items have no prefix and are never numbered automatically.

### Unfrozen sections

A section is unfrozen when none of its existing rows are SOQ-locked. In an unfrozen section every row receives a dense sequence: `{prefix}.1`, `{prefix}.2`, and so on in display order. When a row is inserted, moved, or reordered, the whole section is renumbered to close any gaps and maintain the sequence.

### Frozen sections (SOQ-sourced rows)

A section is frozen when it contains at least one SOQ-locked row whose number Stralos must not rewrite. In a frozen section:

* Existing rows are never renumbered. Their `itemNumber` values stay exactly as imported.
* A newly inserted row without a number is allocated a number from the row above it. When the new row is appended at the end of the section (nothing follows it), Stralos uses the next sibling — for example, `1.3` after `1.2`. Segment width is preserved, so `1.009` advances to `1.010` rather than `1.10`. When the new row is inserted between two existing rows, Stralos nests under the row above (`{above}.1`) to avoid claiming a reference a subsequent SOQ row may carry. If the candidate is already taken, the suffix increments until an unused one is found.
* A reorder in a frozen section moves `sortOrder` values but leaves every `itemNumber` unchanged.

### Inserting a row with an existing number

An imported row that arrives carrying its own `itemNumber` keeps it exactly and is treated as a frozen contractual reference, regardless of the estimate's `quantitySource`. Setting `quantitySource` to `plans` controls whether quantities are editable; it does not unfreeze item numbers on rows that carried their own reference at import time. The section that receives such a row becomes frozen; subsequent inserts nest or use the next sibling as described above.

### Column length limit

`itemNumber` is `varchar(80)`. When the server cannot build a number that fits within 80 characters it leaves the row unnumbered rather than truncating. The row remains visible and the write that created or moved it still succeeds; numbering is a side effect and never causes a write to fail.

### Programme header mirror

When `itemNumber` changes, the corresponding top-level programme header is updated in the same transaction.

## Server-owned estimate fields

The `status`, `version`, and `workingVersion` fields on an estimate are server-owned. Clients cannot set or change them through `POST /estimates` or `PUT /estimates/:id`.

| Field | Initial value | Ownership |
| - | - | - |
| `status` | `draft` | Changes only via `POST /estimates/:id/transition`. |
| `version` | `"0"` | Advances to the latest committed revision number when a `submit` transition commits. |
| `workingVersion` | `0` | Advances each time a workspace version is saved or branched via the versions endpoints. |

Both explicit `POST /estimates` creation and lazy creation through `GET /projects/:projectId/estimate` use these initial values.

### Error responses

Sending a server-owned field in a create or update body returns `400 Bad Request`:

| Attempted field | Error code | Message |
| - | - | - |
| `status` | `status_via_transition` | Estimate status is server-owned; it changes only via a lifecycle transition. |
| `version` | `version_server_owned` | Estimate version is server-owned; it changes only via a tender revision. |
| `workingVersion` | `working_version_server_owned` | The current estimate workspace version is server-owned; use the versions endpoint. |

The API checks the raw request body before schema parsing, so it returns the error even if the supplied value matches the current value. An update that omits all server-owned fields, such as renaming an estimate, is unaffected.

## Provisional sums and item kinds

### Item kinds

The `itemKind` field classifies how a work item is priced. Accepted values are:

| Value | Meaning |
| - | - |
| `measured` | Priced from its build-up (default). |
| `provisional_quantity` | Priced from its build-up with a provisional quantity. |
| `provisional_sum` | Priced from an allowance. No build-up, margin, or sub-items. |
| `lump_sum` | **Deprecated.** Accepted on writes and stored as `measured`. |

`lump_sum` is retired. Any write that sends `itemKind: "lump_sum"` stores `measured` instead — the API never refuses the value, so older clients and integrations keep working. A stored `lump_sum` row becomes `measured` on its next save. Lump sums are priced from their build-up like any other measured item.

### Provisional sum rules

A provisional sum is a work item whose price is its allowance and nothing else. No build-up, no margin, no sub-items.

* **Allowance only.** The allowance entered on the item is both its cost and its price. Build-up components, activities, and sub-items cannot be added to a provisional sum. Any of these writes returns `422` with code `provisional_sum_no_build_up`.
* **Quantity always 1.** A provisional sum's quantity is coerced to `1` on every write. Sending a different quantity is never refused — it is silently replaced.
* **Unit PS and `itemKind` move together.** The unit `provisional_sum` (canonicalised from `ps`) and `itemKind: "provisional_sum"` are the same fact. Changing either sets the other. A write that changes both to contradictory values returns `422` with code `provisional_sum_unit_mismatch`.
* **`allowanceAmount` belongs to provisional sums.** An `allowanceAmount` sent for any other item kind is silently cleared, not refused.

### Converting an item to a provisional sum

When a `PUT /work-items/:id` write changes an item's kind or unit to `provisional_sum`:

* The item's entire build-up is soft-deleted (components and sub-items).
* Any quantity link is cleared.
* Any calculator destinations, rate attachments, and pricing selections are removed.
* The allowance is set to the `allowanceAmount` in the request body, or to the item's current total price when none is sent.
* The quantity is set to `1`.

The response body includes a `provisionalSumConversion` object:

| Field | Type | Description |
| - | - | - |
| `removedComponentIds` | UUID\[] | IDs of the soft-deleted build-up components. |
| `removedSubItemIds` | UUID\[] | IDs of the soft-deleted sub-items. |
| `quantityLinkCleared` | boolean | `true` when a quantity link was cleared as part of the conversion. |

### Converting a provisional sum back to a measured item

Change the unit away from `provisional_sum` (or send `itemKind: "measured"` with the new unit). The allowance is cleared. The quantity remains at `1` until you update it. The build-up is empty; add components manually.

## Work item unit normalisation

The `unit` field on work items, takeoff measurements, and subcontract bid rates is normalised to a catalogue code at every write. The API maps common document abbreviations to their canonical equivalents before storing the value.

Examples of recognised spellings and their canonical codes:

| Submitted value | Stored as |
| - | - |
| `cu.m`, `cu m`, `m3`, `m³` | `cubic_metre` |
| `sq.m`, `sq m`, `m2`, `m²` | `square_metre` |
| `ls`, `l.s`, `lump sum` | `lump_sum` |
| `ps`, `provisional sum` | `provisional_sum` |
| `m`, `metre`, `metres`, `lm`, `lin m`, `lineal metre` | `metre` |
| `ea`, `ea.` | `each` |
| `t`, `ton`, `tons`, `tonnes` | `tonne` |
| `hr`, `hrs`, `hour`, `hours` | `hour` |
| `day`, `days` | `day` |
| `wk`, `wks`, `week`, `weeks` | `week` |
| `mth`, `month`, `months` | `month` |

The normalisation is case-insensitive and tolerates leading or trailing whitespace. `ML` (megalitre) is the one case-sensitive exception. An unrecognised spelling is retained after trimming and collapsing repeated whitespace; no other spelling is guessed.

The four time-unit canonical codes (`hour`, `day`, `week`, `month`) are also the accepted units for `POST /work-items/:id/time-resource-activity`. A rate whose unit does not normalise to one of these four returns `422` from that endpoint.

Subcontract bid rates with no unit (unit-less bids) produce a `lump_sum` mirror component. A unit-less bid represents a lump-sum amount rather than a unit rate.

## Update behaviour

PUT update schemas omit owner/parent foreign-key fields for records that should not be re-parented. Omitted fields are left untouched rather than reset to create-time defaults.

### Clearing work-item notes and clause references

`notes` and `clauseRef` use a three-state update contract on `PUT
/work-items/:id`: omission preserves the stored value, an empty string is
coerced to omission and is therefore a no-op, and explicit `null` clears the
field. Neither field affects pricing totals.

## Estimate lifecycle

Estimate status moves through a fixed set of states. Status changes only via `POST /estimates/:id/transition`; direct writes to `status` on the create or update endpoints return `400`.

### Statuses

| Status | Meaning |
| - | - |
| `draft` | Estimate is being prepared. |
| `review` | Sent for internal review before submission. |
| `submitted` | Tender submitted; a revision snapshot is committed. Pricing is frozen. |
| `accepted` | Tender awarded; the contract baseline is bound to the accepted revision. Pricing is frozen. |
| `rejected` | Tender not awarded or cancelled. Pricing is frozen. |
| `archived` | Estimate closed and retained for reference. Pricing is frozen. |

### Pricing freeze

A tender estimate is **editable** only when its status is `draft` or `review` and it has not been hidden as not awarded. Every user write (work items, components, sub-items, calculator links, margin-ladder rungs, pricing rows, subcontract packages and bid rates, reorder operations, matrix inputs, band overrides, companion charges, sections, rate-exception decisions and acceptances, and every field on `PUT /estimates/:id`) returns `409 estimate_frozen` for any other status. The error body includes the current `status`. Renaming is the one exception; see [Frozen estimate writes](#frozen-estimate-writes).

Reads, previews, re-resolve dry runs, lifecycle transitions, variation writes, and version creates are not blocked.

To change pricing after submission, use **Revise tender** (`action: "revise"`) to return the estimate to `draft`, make the changes, then submit again.

### Transition endpoint

```
POST /estimates/:id/transition
POST /projects/:id/estimate/transition
```

Both routes accept the same request body and run the same handler. The project-based route is used by the global lifecycle orchestrator, which only holds a project ID. A `404 { code: "estimate_not_found" }` response from that route signals no estimate exists for the project. Global decides the outcome by action: loss, archive, unarchive, and non-award admin overrides can continue with no estimate; award actions fail with `baseline_required`; and `reopen` preserves the `estimate_not_found` failure.

These protected endpoints require a valid EdDSA bearer token.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `action` | enum | Yes | The transition action (see tables below). |
| `expectedStatus` | enum | Conditional | Required for user-facing actions. The current status the caller expects the estimate to be in; the server rejects the transition with `409 stale_status` if the actual status differs. |
| `correlationId` | UUID | Yes | Caller-generated UUID used for idempotency. A second request with the same `correlationId` and identical fingerprint returns the recorded outcome; a reused ID for a different request returns `409 correlation_conflict`. |
| `reason` | string | No | Optional human-readable reason recorded in the status history (max 2,000 characters). |
| `versionName` | string | No | `revise` and `reopen` only. The name for the version the submitted tender is saved as (trimmed, max 200 characters). Blank or omitted uses the default `Tender revision N (submitted D MMM YYYY)`. A version already saved for that revision keeps its name. |

**User-facing actions** — require a user token (not a service token):

| Action | Allowed from | To | Notes |
| - | - | - | - |
| `send_to_review` | `draft` | `review` | Standalone; completes in one transaction. |
| `return_to_draft` | `review` | `draft` | Standalone; completes in one transaction. |
| `submit` | `draft`, `review` | `submitted` | Orchestrated — calls `submit_tender` on api-global only when the project's submitted estimate count goes from 0 to 1 (the first submit). Subsequent submits on the same project commit locally. Commits a tender revision snapshot under `REPEATABLE READ`. |
| `revise` | `submitted` | `draft` | Orchestrated — calls `revise_tender` on api-global only when this is the last submitted estimate on the project (count goes from 1 to 0). If other submitted estimates remain, commits locally. |

**System-to-system (S2S) actions** — require a service token with the `lifecycle:transition` scope plus a forwarded actor:

| Action | Allowed from | To | Notes |
| - | - | - | - |
| `accept` | `submitted` | `accepted` | Runs the award guard: creates or binds a contract baseline from the accepted revision. The named estimate becomes the primary (`isPrimary: true`, `outcome: 'won'`). Every other live tender estimate on the project is hidden (`hidden_at` stamped) and marked `outcome: 'not_awarded'`. |
| `reject` | `draft`, `review`, `submitted` | `rejected` | When invoked via the project route, fans out to all active (non-hidden) tender estimates in those statuses under one correlation ID. |
| `reopen` | `rejected` | `draft` | When invoked via the project route, applies only to the estimates that the most recent `reject` fan-out moved, so estimates that were individually rejected are not revived. |
| `archive` | Any non-archived | `archived` | Stores the pre-archive status for later restoration. When invoked via the project route, fans out to all active non-archived estimates. |
| `unarchive` | `archived` | Prior status | Restores the status stored before archiving; falls back to `draft` if absent. When invoked via the project route, applies only to estimates moved by the most recent `archive` fan-out. |
| `admin_set_status` | Any | Mapped from `targetStatus` | Normalises the estimate to match the project status. Requires `targetStatus` (a project status value). When `targetStatus` maps to `awarded`, runs the same award guard as `accept` and hides other tender estimates. |

**Response** — the updated estimate object on success.

**Error codes**

| HTTP | Code | Meaning |
| - | - | - |
| `400` | `expected_status_required` | User-facing action called without `expectedStatus`. |
| `400` | `target_status_required` | `admin_set_status` called without `targetStatus`. |
| `401` | `auth_required` | No authentication token present. |
| `401` | `actor_required` | S2S call missing a forwarded user or replay marker. |
| `403` | `forbidden` | Caller lacks the required role or scope. |
| `404` | `estimate_not_found` | Estimate (or project's estimate) does not exist. |
| `409` | `stale_status` | `expectedStatus` no longer matches the actual status. |
| `409` | `correlation_conflict` | `correlationId` was used for a different request. |
| `409` | `transition_in_flight` | Another transition is already in progress for this estimate. |
| `409` | `transition_conflict` | A concurrent submit or revise committed after this transaction's snapshot was taken. Safe to retry. |
| `409` | `illegal_transition` | The action is not valid from the given `expectedStatus`. |
| `409` | `estimate_not_transitionable` | The estimate is a saved version's copy or a non-tender kind and cannot be transitioned. |
| `409` | `estimate_not_awardable` | The named estimate is not a live, non-hidden tender and cannot receive the `accept` action. |
| `409` | `estimate_already_won` | Another estimate on the same project already has `outcome: 'won'`; an admin must reverse the prior award first. |
| `409` | `baseline_required` | `admin_set_status` with `targetStatus: awarded` has no committed revision to bind. The guided `accept` path can create revision 1 for a qualifying legacy estimate. |
| `409` | `baseline_conflict` | Active baseline is not bound to the accepted revision and variations reference it; an org admin must supersede it explicitly. |
| `409` | `delivery_signed_off` | Admin award reversal refused because the project's Delivery estimate has been signed off. |
| `409` | `estimate_id_required` | The project has several tender estimates and the award action requires an estimate-scoped route (not the project route). |
| `502` | `transition_pending` | The orchestrated peer call result was ambiguous; the transition is in progress and will be reconciled by the background sweeper. |

### One-in-flight guarantee

Only one transition may be in progress for a given estimate at a time. A `submit` or `revise` action places a `pending` reservation in the status history until the peer call completes. Any concurrent transition attempt returns `409 transition_in_flight`.

An additional per-project guard prevents two estimates from both reading a zero submitted count and both calling `submit_tender`: any estimate of the project that holds a pending transition blocks a new orchestrated submit or revise on the same project. If another estimate of the project holds a pending transition, the attempt returns `409 transition_in_flight` on the project route.

If the peer call result is ambiguous (network timeout, 5xx), the pending reservation is left in place and the background sweeper replays the correlation ID against the project service until it receives a definitive outcome.

## Tender revisions

A tender revision snapshot is committed each time a `submit` transition completes. The snapshot captures the full priced work-item graph at the moment of submission under a `REPEATABLE READ` transaction, with a SHA-256 hash for integrity verification.

The current snapshot schema version is **v31**. New submissions produce v31 snapshots. Stored v1–v30 snapshots remain valid and are read-tolerated.

The estimate's `version` field is set to the latest committed revision number (as a string) when each `submit` transition commits. An `accept` of a qualifying legacy estimate can also create revision 1 retroactively and set the version to match it.

### Quote numbers

Each tender estimate, saved version and revised tender carries its own quote number, allocated by api-global's [`POST /projects/:id/quote-numbers`](https://help.stralos.app/api/global#allocate-a-quote-number). The project's original primary estimate uses the project's own quote number. Other estimates are numbered when they are created or copied, a saved version when it is saved or first listed, and the working estimate again when **Revise tender** or **Reopen** returns it to Draft. Delivery estimates have none.

`GET /estimates/:id` and `GET /projects/:projectId/estimate` number a tender estimate that has no quote number yet, and submit and accept do the same before they run. If api-global cannot allocate a number, the request returns `502` and can be retried: the allocation is keyed by a stable identity, so a retry reuses any number already allocated.

Tender downloads, the submission PDF and work item spreadsheet exports print the estimate's quote number.

### List revisions

```
GET /estimates/:id/revisions
```

Returns committed revisions in descending revision order, without the full snapshot payload. Each revision's `quoteNumber` is the quote number the estimate carried when that revision was submitted, or `null` for a revision submitted before quote numbers were recorded on revisions.

**Response** — `200 OK`

```json theme={null}
{
  "items": [
    {
      "id": "uuid",
      "revision": 2,
      "quoteNumber": "Q-0012",
      "submittedAt": "2026-07-11T10:00:00.000Z",
      "submittedBy": "uuid",
      "snapshotHash": "sha256hex"
    }
  ]
}
```

`GET /estimates/:id/revisions/:revision`, which returned one revision with its full snapshot, is retired and returns `404`. The sign-off and comparison views read through their own endpoints.

## Margin ladder

The pricing ladder resolves a margin or markup percentage for each component according to the estimate's frozen `pricingBasis`. It uses the same five-rung order on both bases:

1. **Component override** — a per-rate override set directly on the component row (`margin_pct_override`).
2. **Item category** — the component's rate category.
3. **Rate group** — the component's rate group (labour, plant & vehicles, bulk materials, materials, subcontractors, or disposal).
4. **Tender default** — `defaultMarginPct` or `defaultMarkupPct`, according to the frozen basis.
5. **Organisation default** — the organisation's global percentage, frozen onto the estimate at creation.

The ladder is per-component, not per-work-item. A work item whose build-up mixes rate groups can have different effective margins on different lines.

### Component override (`marginPctOverride`)

`marginPctOverride` is a writable field on a component row. It is accepted by four write endpoints: `POST /work-item-components`, `PUT /work-item-components/:id`, `POST /work-item-components/:id/children`, and `POST /work-items/:id/time-resource-activity`.

The body schema accepts the union of the two basis bands (whole percent, 3 dp, −100 to 999.999) because the schema is parsed before any estimate is read. The estimate's frozen `pricingBasis` is checked inside the write transaction. If the supplied value is outside the band that applies to this estimate, the server returns `400 component_margin_override_out_of_band` naming the basis and the allowed range.

| Estimate `pricingBasis` | Allowed range for `marginPctOverride` |
| - | - |
| `"margin"` | 0 to 95 |
| `"markup"` | −100 to 999.999 |

`null` clears the override so the component inherits from the next rung. Omitting the key preserves the stored value on update.

At pricing time, a stored override that is outside the band for the estimate's basis is dropped and the next rung resolves instead. This means a value copied from a markup tender into a margin one cannot cause a recompute error.

The organisation default and per-category and per-rate-group percentages are **frozen onto the estimate at creation** (copied from the organisation's Global Settings). Pricing reads only from that frozen copy — it does not call the Global API at recompute time. The frozen values appear on the GET response as `organisationPercent` on each rung alongside whatever the tender itself has set.

These routes require organisation context and access to the estimate in that organisation. An unknown or foreign-organisation estimate returns `404`; organisation-admin status is not required.

### Get the margin ladder

```text theme={null}
GET /estimates/:id/margin-ladder
```

Returns the effective percentage at each rung, who set it (`"organisation"` or `"tender"`), and the frozen organisation value underneath — enough information to display the pricing-basis panel and offer a **Reset to organisation** action without a second read.

**Response — 200 OK**

| Field | Type | Description |
| - | - | - |
| `pricingBasis` | `"margin"` or `"markup"` | The estimate's frozen pricing basis. |
| `tenderDefaultPct` | number or `null` | The tender's default rung: `defaultMarginPct` on margin estimates or `defaultMarkupPct` on markup estimates. Edited via `PUT /estimates/:id`. |
| `organisationDefaultPct` | number or `null` | The organisation's default, frozen at creation. |
| `rungs` | object\[] | Resolved category and rate-group rungs for either pricing basis. |

Each `rungs` entry:

| Field | Type | Description |
| - | - | - |
| `scopeKind` | `"rate_group"` or `"category"` | Whether the rung applies to a rate group or an item category. |
| `scopeRef` | string | The rate-group token (e.g. `"labour"`) or rate-category UUID the rung applies to. |
| `marginPercent` | number | The effective percentage, interpreted using `pricingBasis` — the tender rung if one is set; otherwise the frozen organisation value. |
| `source` | `"organisation"` or `"tender"` | Who set the effective value. |
| `organisationPercent` | number or `null` | The frozen organisation value at this rung. `null` when the organisation set none. Allows a **Reset to X%** offer without a second read. |

### Set a tender rung

```text theme={null}
PUT /estimates/:id/margin-ladder
```

Upserts one tender-owned rung on the pricing ladder for this estimate. The write takes the estimate-totals lock and recomputes totals before returning — the same contract as the estimate PUT's `affectsTotals` gate.

The organisation default rung (`scopeKind: "organisation"`) cannot be set here. To change the tender's own default, use `defaultMarginPct` or `defaultMarkupPct` on `PUT /estimates/:id`, according to the estimate's basis.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `scopeKind` | `"rate_group"` or `"category"` | Yes | Which rung to set. |
| `scopeRef` | string | Yes | Intended rate-group token or category UUID. The schema checks trimmed non-empty text of at most 64 characters; it does not validate a referenced category or group token. |
| `marginPercent` | number | Yes | Whole percent, up to 3 decimal places. Allowed range is 0–95 for margin or −100–999.999 for markup. |

**Response — 200 OK** — the full updated ladder in the same shape as the GET response.

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `400` | `margin_ladder_percent_out_of_basis_band` | The percentage is outside the estimate's frozen basis band. |
| `400` | `margin_ladder_organisation_rung_not_editable` | `scopeKind` was `"organisation"`. Use `defaultMarginPct` for a margin estimate or `defaultMarkupPct` for a markup estimate on the estimate PUT. |
| `409` | `estimate_frozen` | The estimate is not in `draft` or `review` status. |

### Reset a tender rung

```text theme={null}
DELETE /estimates/:id/margin-ladder/:scopeKind/:scopeRef
```

Removes the tender's override on one rung so the frozen organisation value takes effect again. The write takes the estimate-totals lock and recomputes totals. Deleting a rung the tender never set is idempotent — it returns `204` because the requested end state already holds.

`:scopeKind` must be `"rate_group"` or `"category"`. Attempting to delete the `"organisation"` rung returns `400 margin_ladder_organisation_rung_not_editable`.

**Response — 204 No Content**

**Error codes**

| HTTP | Code | Cause |
| - | - | - |
| `400` | `margin_ladder_organisation_rung_not_editable` | `scopeKind` was `"organisation"`. |
| `409` | `estimate_frozen` | The estimate is not in `draft` or `review` status. |

***

## Estimate workspace versions

Workspace versions let you save immutable snapshots of an estimate's editable workspace at any point during estimating. Each saved version captures the full priced work-item graph including sections, components, pricing, takeoff measurements, subcontract packages, programme items, and tender clauses. Versions are independent of the tender revision flow: a workspace version can be saved at any lifecycle status, and saving one does not change the estimate's status or tender revision history.

All workspace-version endpoints are protected. A missing or invalid bearer token returns `401 auth_required`; an authenticated caller without organisation context returns `403`.

The `workingVersion` field on the estimate tracks the current version number. It is server-owned and advances to the next integer each time a version is saved or branched.

### List workspace versions

```text theme={null}
GET /estimates/:id/versions
```

Returns the saved workspace versions for an estimate in descending version order.

**Response** — `200 OK`

```json theme={null}
{
  "currentVersion": 3,
  "items": [
    {
      "id": "uuid",
      "estimateId": "uuid",
      "version": 3,
      "name": "After earthworks pricing update",
      "sourceVersionId": null,
      "snapshotHash": "sha256hex",
      "createdAt": "2026-08-10T09:00:00.000Z",
      "createdBy": "uuid",
      "isCurrent": true,
      "restorable": true,
      "versionEstimateId": "uuid",
      "quoteNumber": "Q-0015",
      "summary": {
        "currency": "NZD",
        "workItemCount": 24,
        "totalCost": 184200.50,
        "tenderPrice": 196791.53
      }
    }
  ]
}
```

`isCurrent` is `true` for the version whose number matches the estimate's `workingVersion`. `sourceVersionId` is non-null when the version was branched from a historical one.

`versionEstimateId` is the ID of the read-only saved-version estimate that holds this version's rows; the estimate screen uses it to open a past version read-only. `restorable` is `true` when `versionEstimateId` is set. Versions saved in the older format have `versionEstimateId: null` and `restorable: false`: they can be viewed but not restored or duplicated.

Each version object includes a `summary` field with frozen money read directly from the saved snapshot. These values are never repriced or recalculated after saving.

| Field | Type | Description |
| - | - | - |
| `restorable` | boolean | `false` for a version saved in the older format, which can be viewed but not restored. |
| `versionEstimateId` | UUID or `null` | The saved-version estimate that holds this version's rows. `null` for a version saved in the older format. |
| `quoteNumber` | string | The version's own quote number. A version listed without one is numbered on this read. See [Quote numbers](#quote-numbers). |
| `summary.currency` | string or `null` | Three-letter currency code frozen at save time. `null` when the snapshot does not carry a recognised currency. |
| `summary.workItemCount` | integer or `null` | Number of work items in the snapshot. Includes both top-level items and sub-items. `null` when the snapshot structure cannot be read. |
| `summary.totalCost` | number or `null` | Frozen total cost across all top-level contract lines at save time, in the snapshot's currency. `null` when the cost cannot be determined from the snapshot (for example, when the snapshot schema pre-dates the money columns). |
| `summary.tenderPrice` | number or `null` | Frozen tender price at save time: the sum of the top-level contract line sell prices after applying the saved contingency percentage, excluding GST. `null` when the tender price cannot be determined from the snapshot. |

**Error responses**

| HTTP | Cause |
| - | - |
| `400` | The estimate ID is invalid. |
| `404` | Estimate not found or belongs to a different organisation. |

### Save a workspace version

```text theme={null}
POST /estimates/:id/versions
```

Saves the current editable estimate workspace as a new immutable version. The server recomputes totals immediately before snapshotting, so the stored amounts always reflect the current pricing state. The estimate's `workingVersion` advances to the new version number.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | No | Optional label for the version (max 200 characters). |

**Response** — `201 Created` — the saved version summary object, including the `summary` field with frozen money.

**Error responses**

| HTTP | Cause |
| - | - |
| `400` | The estimate ID or request body is invalid. |
| `404` | Estimate not found or belongs to a different organisation. |
| `409` | The version could not be saved because the current estimate state conflicted. |

### Branch a version

```text theme={null}
POST /estimates/:id/versions/:version/create
```

Restores a historical saved version into the editable workspace and saves the result as the new current version. The source version remains immutable. The estimate's `workingVersion` advances to the new version number.

Use this to return to an earlier pricing state without losing the history of intermediate versions.

`:version` is the integer version number (1-based) to branch from.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | No | Optional label for the branched version (max 200 characters). |

**Response** — `201 Created` — the branched version summary object, including the `sourceVersionId` pointing at the historical version it was created from, and the `summary` field with frozen money read from the restored snapshot.

**Error responses**

| HTTP | Code | Cause |
| - | - | - |
| `400` | — | The estimate ID, source version, or request body is invalid. |
| `404` | — | Estimate or source version not found. |
| `409` | `estimate_frozen` | The estimate is submitted, accepted, rejected, or archived. Branching a saved version into a frozen estimate's working copy is not permitted. Capturing a new version (`POST /estimates/:id/versions`) remains available. |
| `409` | `version_read_only` | The source version was saved before the graph-based version system. It can be viewed but not restored. |
| `409` | — | The version could not be created because the current estimate state conflicted for another reason. |

## Baseline supersede

```
POST /estimates/:id/baseline/supersede
```

An org admin can use this endpoint to deactivate the current baseline and create a new one bound to a specific committed revision. This is the resolution path when an `accept` transition returns `409 baseline_conflict` because the existing baseline is referenced by variations.

Requires the `admin` role. Authentication is always enforced.

Existing variations retain their original baseline. New variations bind to the new active baseline.

**Request body**

| Field | Type | Required | Description |
| - | - | - | - |
| `fromRevisionId` | UUID | Yes | The ID of the committed revision to project the new baseline from. |
| `reason` | string | Yes | Reason for the supersession; recorded in the status history (1–2,000 characters). |

**Response** — `201 Created` — the newly created baseline and its items (same shape as `POST /estimates/:id/baseline`).

**Error codes**

| HTTP | Code | Meaning |
| - | - | - |
| `401` | `auth_required` | No authentication token. |
| `403` | `forbidden_transition` | Caller does not hold the `admin` role. |
| `404` | — | Estimate or revision not found. |
| `422` | `revision_not_committed` | The specified revision is not in a committed state. |

## Linked work-item quantity

A linked quantity is a work-item quantity that Stralos derives automatically from one or more sources and re-resolves whenever those sources change. Sources can be calculator readings (a specific layer and output from a project calculator), all whole-item build-up rows of a rate item on that work item, or a takeoff measurement. The resolved value is `Σ sources × multiplier + offset`, rounded up when the link carries a `rounding`.

### Quantity link descriptor

The `quantityLink` field on `PUT /work-items/:id` accepts a descriptor or `null`:

**Linking or replacing a link**

| Field | Type | Required | Description |
| - | - | - | - |
| `version` | `1` | Yes | Schema version. Always `1`. |
| `sources` | object\[] | Yes | 1–20 source entries (see below). Each source must share the work item's unit dimension. No duplicates allowed. |
| `multiplier` | number | No | Scales the sum. Range: `(0, 1000]` at up to 4 decimal places. Absent means `1`. |
| `offset` | number | No | Added to the scaled sum (the **Allowance** on screen). Range: `[0, 10^10)` at up to 3 decimal places; a negative offset is refused. Absent means `0`. |
| `rounding` | string | No | "Round up to": rounds the result up to a step from `QUANTITY_ROUNDING_OPTIONS` after the multiplier and offset. Absent or `"none"` means no rounding. The stored `quantityLinkResult` then carries `unrounded`. A token outside the list is `422 quantity_link_invalid`. |

Each entry in `sources` is one of three shapes:

**Calculator source**

| Field | Type | Description |
| - | - | - |
| `kind` | `"calculator"` | Source is a project calculator reading. |
| `calculatorId` | UUID | The calculator. |
| `layerId` | string (1–64 chars) | A measurement id (e.g. `m:o:lengthM`) or a real layer id, matching `estimate_calculator_links.layer_id`. |
| `outputKey` | string | One of `inSituM3`, `looseM3`, `tonnes`, `areaM2`, `lengthM`, `count`. |

**Rate-item source**

| Field | Type | Description |
| - | - | - |
| `kind` | `"rateItem"` | Source is all whole-item build-up rows for a rate item on this work item. |
| `rateItemId` | UUID | The rate item. |

**Takeoff source**

| Field | Type | Description |
| - | - | - |
| `kind` | `"takeoff"` | Source is a takeoff measurement's quantity. |
| `measurementId` | UUID | The measurement. |

**Unlinking**

Send `quantityLink: null` to unlink. The work item keeps its current quantity as a `manual` origin value (or stays `import` for an SOQ line).

### Quantity origin

A linked work item has `quantityOrigin: "derived"`. An SOQ-locked item (`quantityOrigin: "import"`) that is linked stays `"import"` — its measured-alongside value is what moves. Unlinking restores `"manual"` (or leaves `"import"` on an SOQ line).

### Write rules

A typed quantity that differs from the stored value on a linked item is refused `422 quantity_linked`. An equal resend (a full-row echo) passes without error. Unlink the item before typing a new quantity.

A new or replaced link is checked before anything is written. The server refuses:

| Code | Cause |
| - | - |
| `quantity_link_invalid` | The descriptor shape is invalid or contains duplicate sources. |
| `quantity_link_tag_unsupported` | A takeoff source specified a non-empty `tag` (reserved, not yet supported). |
| `quantity_link_unit` | At least one source is in a different unit dimension than the work item. |
| `quantity_link_source_unresolvable` | A source does not exist or does not currently emit a readable value. |
| `quantity_link_negative` | The link resolves to a negative quantity. |

The batch `setQuantity` operation applies the same `quantity_linked` guard: a linked item's quantity cannot be set via the fill-handle batch. The entire batch rolls back if any item in it is linked and being given a different quantity.

### Activity followers on quantity change

When a work item's quantity changes — whether through a typed edit, a link resolution, or a batch set — Stralos also updates any top-level activity container that was allocated exactly the previous quantity in the same unit. These followers are reported in the response:

```json theme={null}
{
  "quantityFollowers": {
    "moved": [
      { "componentId": "uuid", "name": "Excavation crew", "allocation": 150.0 }
    ],
    "skipped": [
      { "componentId": "uuid", "name": "Drain layer", "reason": "quantity_zero" }
    ]
  }
}
```

| Field | Type | Description |
| - | - | - |
| `moved` | object\[] | Followers whose allocation was updated. Each entry has `componentId`, `name`, and the new `allocation`. |
| `skipped` | object\[] | Followers the server could not move. Each entry has `componentId`, `name`, and `reason`. |

Skipped reasons:

| Reason | Meaning |
| - | - |
| `quantity_zero` | The new quantity is zero; an activity instance cannot be allocated zero. The quantity still saved. |
| `allocation_invalid` | The allocation would create an invalid instance pair. |
| `no_base` | The activity container has no base duration to scale against. |

### Linked quantity re-resolution

Every write that can move a source — a calculator save, a calculator `PUT /calculators/:id/links`, a takeoff measurement update, or a `POST /estimates/:id/recompute-totals` — re-resolves linked quantities for the affected estimate as a pre-pass before pricing. Frozen estimates are skipped (D14).

When a re-resolution moves a linked item's quantity (a derived move), the write response includes:

```json theme={null}
{
  "workItemsChanged": [
    {
      "workItemId": "uuid",
      "quantity": 245.5,
      "quantityFollowers": { "moved": [], "skipped": [] }
    }
  ]
}
```

This field appears on `POST /work-item-components`, `PUT /work-item-components/:id`, and calculator write routes when at least one linked item moved.

### Delete guards

Deleting or modifying a source that a linked item uses is refused `409` until confirmed.

**Takeoff measurement delete** — `DELETE /takeoff-measurements/:id`

Returns `409 measurement_in_use` with a `usage` array when at least one live work item's link names that measurement:

```json theme={null}
{
  "code": "measurement_in_use",
  "message": "...",
  "usage": [
    {
      "measurementId": "uuid",
      "measurementLabel": "Trench run A",
      "workItems": [
        {
          "workItemId": "uuid",
          "itemNumber": "1.2",
          "name": "Pipe installation",
          "quantity": 245.5,
          "unit": "m",
          "otherSources": 0,
          "soqLocked": false
        }
      ]
    }
  ]
}
```

Add `?unlink=true` to confirm. The server unlinks each using work item entirely (not just the deleted source), keeping its last quantity as a manual value. The response includes `unlinked` (each item and its retained quantity) and `workItemsChanged` (the derived moves the re-resolution triggered).

**Map sheet delete** — `DELETE /takeoff-map-sheets/:id`

Applies the same `measurement_in_use` guard over all measurements on the sheet. `?unlink=true` unlinks and removes the sheet in one step.

**Calculator delete** — `DELETE /calculators/:id`

Returns `409 calculator_in_use` with a `usage` array listing each work item that links to any layer of the calculator. Add `?unlink=true` to confirm; the server unlinks whole items and reports them in `unlinked`.

**Calculator save (PUT)** — `PUT /calculators/:id` or `PUT /calculators/:id/links`

If the save would stop the calculator emitting a layer that a link names, the server returns `409 calculator_source_removed` before writing anything:

```json theme={null}
{
  "code": "calculator_source_removed",
  "message": "...",
  "usage": [
    {
      "calculatorId": "uuid",
      "calculatorName": "Trench A",
      "removed": [
        { "layerId": "layer-uuid", "label": "Existing formation" }
      ],
      "workItems": [ ... ]
    }
  ]
}
```

Add `?unlink=true` to confirm. The calculator is saved and the affected items are unlinked in the same transaction. Both calculator saves and deletes trigger a full re-resolution pass unconditionally afterwards.

### Get quantity link candidates

```text theme={null}
GET /estimates/:estimateId/work-items/:itemId/quantity-link/candidates
```

Returns the sources a work item's quantity can be linked to, filtered by the item's unit dimension. Use this to populate the link-source picker.

**Authentication:** requires a valid bearer token with a resolved organisation context.

**Response — 200 OK**

| Field | Type | Description |
| - | - | - |
| `unit` | string | The work item's current unit. |
| `dimension` | `"length"` \| `"area"` \| `"volume"` \| `"mass"` \| `"count"` \| `null` | Dimension of the work item's unit. `null` when the unit has no recognised dimension; no candidates are returned in that case. |
| `calculators` | object\[] | Project calculators with at least one reading in the item's dimension (see below). |
| `rateItems` | object\[] | Rate items that have at least one build-up row on this work item (see below). |
| `takeoffs` | object\[] | Live takeoff measurements in the item's dimension (see below). |

Each `calculators` entry:

| Field | Type | Description |
| - | - | - |
| `calculatorId` | UUID | Calculator ID. |
| `name` | string | Calculator display name. |
| `type` | string | Calculator type (`trench`, `pit`, `volume`). |
| `readings` | object\[] | One entry per (layer, output) pair in the item's dimension (see below). |

Each `readings` entry:

| Field | Type | Description |
| - | - | - |
| `layerId` | string | Layer id or measurement id for this reading. |
| `label` | string | Display label for this reading. |
| `outputKey` | string | The output key (`inSituM3`, `looseM3`, `tonnes`, `areaM2`, `lengthM`, `count`). |
| `value` | number \| `null` | The calculator's current value for this reading. `null` means the reading is unstated (offered as disabled). |
| `reason` | string | Explanation when `value` is `null` (e.g. the layer is split across destinations). Omitted when `value` is present. |

Each `rateItems` entry:

| Field | Type | Description |
| - | - | - |
| `rateItemId` | UUID | Rate item ID. |
| `name` | string | Rate item display name. |
| `eligible` | object\[] | Build-up rows that can be summed. Each entry has `componentId`, `name`, `quantity`, and `unit`. |
| `excluded` | object\[] | Rows that exist on the item but cannot be a source. Each entry has `componentId`, `name`, `quantity`, `unit`, and `reason`. |

Excluded row reasons:

| Reason | Meaning |
| - | - |
| `per_unit` | Row is charged per work-item unit, not as a whole-item quantity. |
| `in_activity` | Row sits inside an activity instance container. |
| `recipe` | Row is a recipe-derived child. |
| `container` | Row is an activity container itself. |
| `unit` | Row's unit is in a different dimension from the work item. |

Each `takeoffs` entry:

| Field | Type | Description |
| - | - | - |
| `measurementId` | UUID | Measurement ID. |
| `label` | string | Measurement label. |
| `type` | string | Measurement type (`line`, `area`, `count`). |
| `quantity` | number | Current measured quantity. |
| `unit` | string | Measurement unit. |
| `documentId` | UUID \| `null` | Source drawing document, or `null` for a map measurement. |
| `mapSheetId` | UUID \| `null` | Source map sheet, or `null` for a drawing measurement. |

**Error responses**

| HTTP | Cause |
| - | - |
| `401` | Missing or invalid bearer token. |
| `403` | Caller token has no resolved organisation context. |
| `404` | Work item not found or belongs to a different organisation. |

### Creating a work item from a measurement

`POST /estimates/:id/work-items/from-measurement` now creates the new work item with a takeoff link by default. When the estimator accepts the measurement's quantity as-is and the measurement's unit shares a dimension with the item's unit, Stralos sets `quantityOrigin: "derived"` and stores a `quantityLink` pointing at the measurement. The item's quantity then tracks the measurement automatically.

If the estimator overrides the quantity (types a different value) or uses a unit in a different dimension, the item is created as a manual item with no link.

The measurement's `work_item_id` back-pointer continues to record the "created from" relationship and is not used to resolve linked quantities.

## Project calculators

Project-level calculators feed physical quantities into one or more work items. Pricing belongs to each consuming work-item line. Every route is organisation scoped, and each mutation holds the estimate totals lock while it reconciles rows and recomputes pricing.

### List calculators

```text theme={null}
GET /estimates/:id/calculators
```

Returns `{ items }`, sorted by calculator name. Each calculator includes its configuration, assignment links, default destination, distinct **Used by** destinations, and `linkedUseCount`.

`linkedUseCount` is the number of live work items and build-up lines whose `quantityLink` names this calculator. A work item or line that reads several of the calculator's readings counts once. This is the count behind the "ƒ n uses" indicator in the Calculations list.

### Create a calculator

```text theme={null}
POST /estimates/:id/calculators
```

| Field | Type | Required | Description |
| - | - | - | - |
| `type` | `trench \| pit \| volume` | Yes | Calculator engine arm. |
| `name` | string | No | Display name. When omitted, the API allocates the next `Calculator n` name for the estimate. |
| `config` | object | Yes | Geometry and physical inputs. Pricing fields are refused. |
| `defaultDestination` | object or null | No | `{ workItemId, componentId? }`. When supplied, the API seeds inheriting links for available layers. |

Returns `201 { calculator, warnings, engineWarnings }`. Pricing data in the configuration, a malformed or misplaced `backfillFrom`, or an ineligible destination returns `422`. A fresh reinstatement selection whose referenced stratum has `canUseForReinstatement: false` returns `400 calculator_config_backfill_from_ineligible`.

### Copy a calculator

```text theme={null}
POST /calculators/:id/copy
```

Copies the geometry and default destination under a new identity and allocated name. Explicit assignment links do not copy. Returns `201 { calculator, warnings, engineWarnings }`.

### Update or rename a calculator

```text theme={null}
PUT /calculators/:id
```

All fields are optional and omitted fields are preserved:

| Field | Type | Description |
| - | - | - |
| `name` | string | New display name. |
| `config` | object | New geometry and physical inputs. |
| `defaultDestination` | object or null | New default target; explicit `null` clears it. |
| `inputLinks` | object or null | Takeoff measurement sources for calculator input fields (trench length, volume length, or volume area). Resolves the linked totals into `config` and reconciles rows. `null` or `{v:1, fields:{}}` removes all input links without changing the stored values. |
| `unlinkedRowChoices` | object\[] | Resolution decisions for rows previously unlinked from this calculator. Required when the server returns `409 calculator_unlinked_rows`. See [Unlinked row resolution](#unlinked-row-resolution). |

A body containing only `name` renames the calculator without running the engine, rewriting links, reconciling rows, or repricing. Supplying `config`, `defaultDestination`, or `inputLinks` performs a full save. Inheriting links follow a changed default; explicit links remain on their selected targets.

When a `config` or `inputLinks` save would reconnect a row that an estimator previously unlinked, the server returns `409 calculator_unlinked_rows` instead of saving. The caller must supply `unlinkedRowChoices` to resolve each pending row before the save proceeds.

Returns `200 { calculator, warnings, engineWarnings }`.

#### Unlinked row resolution

When `PUT /calculators/:id` carries `config` or `inputLinks` and the calculator has rows that were previously unlinked (via `POST /work-item-components/:id/unlink-calculator`), the server checks whether the updated engine would re-create a row at the same destination. If it would, the server returns `409` with code `calculator_unlinked_rows` and a list of pending rows.

A measurement edit that re-resolves a linked input field does not ask. When a takeoff measurement changes and a linked calculator input is updated as a follow-on, the server reconciles without triggering the 409: the unlinked row is left as-is and the conflict check is deferred to the next explicit save.

The error body contains:

```json theme={null}
{
  "code": "calculator_unlinked_rows",
  "rows": [
    {
      "rowId": "<uuid>",
      "name": "Base reinstatement",
      "workItemName": "Trench — Main St",
      "quantity": 12.5,
      "unit": "m3",
      "updatedAt": "<iso8601>"
    }
  ]
}
```

Retry the `PUT` with `unlinkedRowChoices` to confirm how each row should be handled:

| Field | Type | Required | Description |
| - | - | - | - |
| `rowId` | UUID | Yes | ID of the unlinked row from the error body. |
| `updatedAt` | string | Yes | `updatedAt` from the error body. The server returns `409` again if the row changed since you read it. |
| `action` | `override` \| `add` | Yes | `override` — reconnect the existing row to the calculator and overwrite its current values on the next reconcile. `add` — leave the existing row as manual data and insert a new calculator-linked row alongside it. |

Up to 500 entries are accepted. Once the choices are applied, the unlinked row record is cleared and subsequent saves proceed without the conflict check.

### Replace assignment links

```text theme={null}
PUT /calculators/:id/links
```

Send `{ links: [...] }` with at most 500 entries:

| Field | Type | Required | Description |
| - | - | - | - |
| `layerId` | string | Yes | Engine layer identity. |
| `workItemId` | UUID | Yes | Destination work item. |
| `componentId` | UUID or null | No | Destination activity component within the work item. |
| `outputKey` | string | Yes | Quantity output exposed by the selected layer. |
| `share` | number or null | No | Excavation share greater than 0 and at most 1. Null means the whole layer. |
| `inheritsDefault` | boolean | No | Whether the link follows future default-destination changes. |

The replacement is atomic. Fill layers must have one whole-layer destination. Excavated-stratum shares may total less than one, which returns a warning, but cannot exceed one. The API reconciles every destination reached by the calculator and returns `200 { calculator, warnings, engineWarnings }`.

### Delete a calculator

```text theme={null}
DELETE /calculators/:id?keepRows=true|false
```

`keepRows` is required. `true` detaches generated rows so they become ordinary editable lines; `false` deletes them. The response reports how many rows were detached or deleted.

The delete is soft. The calculator is stamped `deletedAt`, and with `keepRows=false` the rows it generated share that one stamp. Its links are kept as history, but every write route refuses a deleted calculator (`409 deleted`), so nothing regenerates rows from them. A repeat `DELETE` returns `200` with both counts `0`.

**The calculator's own activity.** A calculator whose destination is a work item itself puts its lines in a manual activity named after it. The delete soft-deletes that activity too, with the same stamp, when all of these hold: the calculator names it as its default destination or in a link; it is a live top-level manual activity (no library activity, rate item, generated key, or bid mirror); it still carries the calculator's current name and has no live value override; it was created no earlier than the calculator; and it is empty once the rows are gone (no line, companion, delivery link, or other live calculator sending lines to it). With `keepRows=true` the kept rows sit inside it, so it stays.

**Quantity-link usage check.** If any live work item's or build-up line's `quantityLink` names this calculator, the delete returns `409 calculator_in_use` with a `usage` array listing the affected items. Add `?unlink=true` to confirm: Stralos removes each quantity link and keeps the last resolved value as a manual quantity. The response then includes:

* `unlinked` — work items whose quantity link was removed, each with their kept `quantity`.
* `unlinkedComponents` — build-up lines whose quantity link was removed, each with `componentId`, `quantity`, and a `label` of the form `<item number item name> · <line name>`.

### Unlink a calculator row

```text theme={null}
POST /work-item-components/:id/unlink-calculator
```

Removes the calculator assignment from one generated component row, keeping its current quantity, rate selection, and pricing intact as an ordinary editable line. The row's `generatedKey` is cleared, and the calculator no longer manages it.

The calculator records the unlinked row. When the same calculator is later saved with a `config` change that would re-create a row at that destination, the server returns `409 calculator_unlinked_rows` until the caller resolves the conflict via `unlinkedRowChoices` on `PUT /calculators/:id`. See [Unlinked row resolution](#unlinked-row-resolution).

**Response — 200 OK**

```json theme={null}
{ "componentId": "<uuid>" }
```

**Error responses**

| HTTP | Cause |
| - | - |
| `404` | Component not found, or the component is not linked to a calculator. |
| `422` | The component has no calculator link, or the calculator belongs to a different estimate. |

### Engine warnings (`engineWarnings`)

`engineWarnings` is an array of advisory objects the engine emits alongside the calculator response. Each object has a `kind` string and optional numeric fields. Clients must tolerate kinds they do not recognise — new advisories are added by a pin bump alone.

| `kind` | Extra fields | Meaning |
| - | - | - |
| `excavation-partition-under` | `unallocatedInSituM3` | Excavation shares total less than one; some in-situ volume is unallocated. |
| `reuse-exceeds-excavation` | `total`, `shortfallInSituM3` | More material was drawn from the dig as backfill than the excavation contains. The engine clamps the draw; the shortfall must be sourced as bought fill. |
| `trench-width-below-pipe-clearance` | `pipeDiameterMm`, `clearancePerSideMm`, `requiredWidthMm`, `enteredWidthMm` | The trench width is narrower than the minimum pipe clearance. Rows are unaffected; this is informational only. |
| `fill-layer-stratum-unspecified` | `layerId` | A from-the-dig layer names no specific stratum in a calculator with two or more strata. The engine uses a generic label; use `backfillFrom` on the layer to resolve it. |

### Common errors

| HTTP | Meaning |
| - | - |
| `400` | Invalid ID, query, or request shape. |
| `403` | The caller has no organisation context. |
| `404` | Estimate, calculator, or destination not found in the caller's organisation. |
| `422` | Invalid calculator configuration, assignment, allocation, or destination. |


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