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

# Pricing module

> Domain contract for shared work-item pricing calculations in stralos-domain

The `stralos-domain` pricing module is the single authoritative implementation of work-item cost and price totals. Both the estimate service (write path) and the frontend (live preview) import from it; no money arithmetic happens outside these helpers.

## Purpose

Extracting pricing into a shared module means the server and client always produce identical totals for the same input data. The module is a faithful extraction of the existing `usePricingControls` behaviour in the estimate feature — it replicates, not redesigns.

## Money arithmetic

All money is computed in **scaled integers** (bigint), never floating-point. The approach eliminates float rounding errors such as `Math.round(1.005 * 100) === 100`.

Every numeric input is:

1. Converted to an integer at its declared decimal scale by string parsing.
2. Multiplied or divided using BigInt — intermediate products can exceed 2^53.
3. Rounded **once** (half-up) at the point the client rounds, producing exact integer cents.

Declared input scales:

| Field type | Scale (decimal places) |
| - | - |
| Quantities | 3 |
| Labour multiplier | 4 |
| Percentages (whole percent at DB) | 3 |
| Unit costs, rates, \$/hr | 2 |

`resourceCount` and `activityCount` are parsed at their **natural decimal scale** (`toScaledExact`) rather than a fixed 3 dp, so fractional resource equivalents (for example 2.5 machines) are preserved exactly through to the cents boundary. Their defaults differ:

* `resourceCount`: absent or non-finite → 1; zero or negative → 0 (`getResourceCount`, see [Component cost](#component-cost)).
* `activityCount`: absent or degenerate (zero, negative, NaN, ±Infinity) → treated as absent, so the per-count quantity is not multiplied (`activityCountScaled`).

All computed money values are integer cents. The maximum representable value is 10^12 cents (numeric(14,2)); values exceeding this throw rather than silently lose precision.

## Percent and fraction convention

Markup values travel as **decimal fractions** throughout the domain — `0.125` means 12.5%, not 12.5. The API and DB store whole-percent values (e.g. `12.5`); the estimate service converts once at its boundary.

Margin-on-sell inputs (`marginPercent`, `defaultMarginPercent`) are the exception
inside the domain contract. They are **whole percents** (19.9 means 19.9%) and
are not divided by 100 before the pricing functions receive them.

**Float noise in derived fractions.** When the estimate service computes `markupFraction = percent / 100`, binary float division can produce values like `0.006999999999999999` for `0.7 / 100`. The pricing module accepts this: it parses the float at its exact natural scale then rescales half-up to five decimal places before the integer multiplication. Strict scale validation of the user-entered percentage belongs at the API boundary, not inside `applyMarkupCents`.

## Snapshot contract

The pricing functions price only from **persisted estimate data**. Several inputs (final rate, density, and material pricing unit) were previously read live from the rates service at render time. Those values must be **materialised** onto component or pricing-override rows at edit time. A legacy component row without its materialised fields prices at base cost only.

Only the frontend replays the `locationMaterialisationContract.json` fixture now.
stralos-feature-estimate runs each case through `resolveMaterialisedFields`
(`src/locationMaterialisationContract.test.ts`) and pins the fixture's content
hash, so the materialised fields (the unit cost and the cube-to-tonne material
conversion) cannot drift without a test failing. It began as a cross-repository
parity fixture, but stralos-api-estimate has deleted its mirror
(`materialiseOverrideFields`) and its copy of the fixture, and
`resolver-parity.yml` no longer compares location materialisation. The parity
script's other contract, `rateAuthorityContract.json`, does not carry the
location materialisation cases. The fixture
pins the cases it lists; it does not claim to cover every heuristic.

### Calculator quantities and pricing

Current calculator configurations contain geometry and physical material snapshots only. Their engine rows expose named quantities such as in-situ volume, loose volume, tonnes, area, and length. Project and library assignment registers bind one of those outputs to a destination line, where the rate item, option, unit cost, rate group, and margin are stored.

This separation keeps calculator recomputation from replacing an estimator's pricing choice. Persisted component and pricing-override fields remain the authoritative money inputs described below.

The calculator no longer chooses or locks a rate option on generated lines. Automatic price selection picks the cheapest eligible option at first resolve, but the estimator owns the choice: any pinned option on a calculator-generated line is respected and can be changed through the pricing panel. The durable-pin guard that previously refused a different option on these lines now applies only to fixed-charge companion lines, whose pin is required by a database constraint.

## Labour multiplier resolution

The labour multiplier is applied to a component's base cost when the component is classified as labour. The shared domain predicate `isLabourComponent` resolves the classification in two steps:

1. **Recognised `rateGroup` (authoritative)** — a `rateGroup` of `labour` applies the multiplier; any of the other five recognised tokens (`plant_vehicles`, `quarry_materials`, `other_materials`, `subcontractors`, `disposal`) suppresses it.
2. **`category` (legacy fallback)** — if `rateGroup` is `null`, absent, or unrecognised, the predicate sniffs the free-text `category` field using the existing keyword heuristic. Rows written before `rateGroup` was introduced therefore price identically to before.

A `rateGroup` token that is not one of the six accepted values is treated as `null` at the pricing-input boundary, so the fallback sniff applies. This guards against an out-of-band value reaching the runtime without silently suppressing a legitimate multiplier.

## Component cost

`computeComponentEffectiveCost(component, override, context)` returns integer cents for a single component. Base cost is its only term:

```
orderedQuantity = quantity × resourceCount
effectiveCost   = roundHalfUpToCents(orderedQuantity × unitCost × labourMultiplier)
```

Every factor is carried as an exact scaled integer and there is **one** rounding, half-up, at the cents boundary — the product is never rounded in stages.

The labour multiplier is resolved as described above; all non-labour components use 1. `context` is retained in the signature even though nothing currently reads it, so the shape and every call site stay stable.

The `quantity` passed to this function must already be **flattened** (multiplied by `workItem.quantity`). `computeWorkItemTotals` does this flattening internally.

**Resource count minimum.** `getResourceCount` resolves a stored `resourceCount` to `max(0, resourceCount)` — not `max(1, ...)`. A fractional count such as 0.2 (one-fifth of a crew) is preserved as-is in pricing. An absent or non-finite count defaults to 1. Both the frontend preview (`src/estimateShared.tsx`) and the shared `@stralos-co-nz/pricing` package apply the same rule, so the browser preview and the server recompute agree for fractional inputs.

> [!WARNING]
>
> **The `chargeQuantity` divisor is gone**, and so is the `materialPricingUnit` /
> `density` tonne conversion that used to be documented here. Both belonged to the
> retired rate-offering model. A [rate option](https://help.stralos.app/api/rates.md#rate-options) is priced in
> one unit, and conversion into a consuming line's explicit target unit uses the
> option's own `conversionFactor` where the option is normalised — not inside the
> component cost. A reader implementing the old formula would divide by a field no
> row carries.

### Tonne-gate drift diagnostics

The vitest suite `src/tonneGateDiagnostic.test.ts` checks the frontend and stored tonne-ordering predicates in `scripts/tonne-gate.mjs` against their expected cases. `scripts/parity-check.mjs` scans eligible pricing rows, compares the two decisions, and fails when drift is found. The scan uses the selected price, excludes mirror rows, and skips rows whose pricing inputs are ambiguous rather than guessing.

Run these diagnostics before changing tonne-ordering eligibility or migrating stored pricing inputs. Keep the frontend and stored predicates aligned until rate items carry the explicit material-pricing fields needed to replace the current heuristic safely.

**Component exclusion rule.** Two and only two shapes are excluded from the internal build-up and from `pricedComponentCount`:

* **§6.3 subcontract mirror** — a component with `subcontractBidRateId` set. Mirrors are display metadata; the bid is priced through its own channel.
* **Activity container** — a component where `type === "activity"` and `rateItemId` is absent or null. A container is priced through its children, never on its own `unitCost`.

Every other row contributes its own `unitCost`, including a manually entered rate row (`type: "rate_item"`, `pricing_source: "manual_override"`). The earlier `rateItemId == null` check caught manually entered rows as well as containers and caused them to contribute \$0.

The exported predicate `isActivityContainerComponent({ type, rateItemId })` encodes the container half of this rule. Use it rather than reimplementing the check, so all callers share one definition.

The two exclusions operate at different points in `computeWorkItemTotals`. A mirror is filtered before the component-cost fold — it contributes nothing at all. A container is filtered after the component-cost fold — it prices through its children only. Folding the two into a single predicate at either site would misplace one of them and move money.

**Caller responsibility — `PricingComponentInput.type`.** `PricingComponentInput` now includes an optional `type?: string | null` field. The domain cannot otherwise distinguish a rate-less container from a rate-less manually entered row. Absent means "not a container", so the row prices — the safe default for a row a person entered. A caller that omits `type` on a genuine activity container turns that container into a priced row worth its own `unitCost`. Both production callers (`api-estimate` `recompute.toPricingComponentInput` and `feature-estimate` `toDomainComponentInput`) thread this field.

## Activity instance pricing

`@stralos-co-nz/domain` `0.13.0` added instance-derived pricing for components that belong to a promoted activity instance.

### Instance mode and validation

A component is an instance container when both `allocatedQuantity` and
`productionRate` are non-null. The exported
`isInstanceContainerComponent(component)` predicate checks the presence of this
pair, not whether its values are valid.

This distinction is deliberate. A non-null pair containing zero, a negative
number, or a non-finite number remains an instance and fails loudly during
pricing; it is not silently reclassified as a legacy container. Call
`validateInstancePair(allocatedQuantity, productionRate)` before persistence to
validate the pair and derive its scaled base duration:

```ts theme={null}
type InstancePairValidation =
  | { ok: true; durationBaseScaled: Scaled }
  | { ok: false; reason: string };
```

Validation rejects:

* an operand that is not a positive finite number;
* a derived duration that rounds to zero at `DURATION_SCALE` (six decimal
  places); and
* a derived duration above the representable `10^10` base-unit bound.

Operand size alone is not a rejection cause. The duration uses the raw
floating-point quotient and only the result is bounded and rounded half-up:

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

A container with either field absent or null remains on the legacy pricing
path.

### Instance child quantity

A component is an instance child when its `parentComponentId` points to an
instance-mode container. Its `quantityBasis` selects how the stored `quantity`
is charged:

| `quantityBasis` | Priced quantity |
| - | - |
| `null` or absent | `quantity × durationBase` (legacy per-base-unit behaviour) |
| `"whole_activity"` | `quantity` |
| `"per_count"` | `quantity × (activityCount ?? 1)` |

An unknown token narrows to `null`, retaining the legacy basis. In that legacy
arm, the stored quantity is per base unit of the activity (for example an hour,
day, week, or month):

```text theme={null}
pricedQuantity = quantity × durationBase
```

None of these arms multiplies by `workItem.quantity`; the container's allocation
already records how much of the work item the instance handles. `resourceCount`
still applies downstream, and stored component-quantity overrides do not replace
an instance child's resolved quantity. `durationDays` remains the container's
calendar duration and is not multiplied by the count. A line with no instance
parent, or whose parent is a legacy container, ignores `quantityBasis` and
follows the normal non-instance rules, including quantity overrides and
`quantityIsTotal` where applicable.

If an instance child carries `quantityIsTotal: true`, that flag takes precedence over `quantityBasis` and all instance-child arms. The row's stored quantity is used once without any duration or count multiplication. See [Standalone total-quantity rows](#standalone-total-quantity-rows) for full rules and the defect history.

`activityCount` is an optional positive tally on the instance container, such
as the number of manholes produced. Its only pricing consumer is a direct child
using `"per_count"`. A missing or degenerate count (zero, negative, non-finite)
is treated as absent, so the multiplier is 1. Conversion and bounds checking
occur only when a `"per_count"` child consumes the value.

### Shared quantity basis

`resolveComponentQuantityBasis(components, workItem, pricingRow, settings)`
returns a `Map<string, ComponentQuantityBasis>` keyed by component ID. Each
entry contains:

* `quantityScaled` — the exact scaled-integer quantity used for pricing; and
* `durationDays` — the calendar duration the line carries, when it is
  duration-driven. It is a display and scheduling output; nothing in this module
  prices from it.

`computeWorkItemTotals` consumes this resolver for both legacy and instance
lines. Frontend pricing previews should use the same resolver rather than
reimplementing the flattening rules.

#### Top-level whole-item charge

On the non-instance path, a parentless non-container row is charged **once for the whole work item** by default. The estimate service maps this to `quantityIsTotal: true` in the domain pricing input via `isWholeItemPricedRow`. The entered `quantity` is the job total and is never multiplied by `workItem.quantity`.

The opt-out is `quantityBasis: 'per_unit'` on the row. A row with this value treats its quantity as a per-work-item-unit figure expanded by `workItem.quantity`.

Two row shapes are excluded from the whole-item default even when `quantityBasis` is `null`:

* **Recipe-derived rows** — a parentless row that carries both `rateItemId` and `activityId` is a copied recipe child whose quantity is already per-unit-scaled; flipping it would under-price by the recipe multiplier.
* **Container rows** (`type === "activity"` and `rateItemId == null`) — containers price through their children and contribute no cost themselves.

A third exclusion, **duration-derived rows** (an hour or day line whose quantity came from the work item's units/day), went with `estimate_work_items.productivity` on 2026-09-18: a work item carries no production rate, so no row's quantity derives from one.

`isWholeItemPricedRow` and `wholeItemBasisContext` in `pricing/wholeItemBasis.ts` are the single source of truth for this decision. Both the authoritative recompute and the counterfactual measured-quantity pass (re-resolve and rate exceptions) read from that module so neither can drift.

#### Standalone total-quantity rows

A component with `quantityIsTotal: true` carries a quantity that is already at whole-work-item altitude. A quantity override is checked first. Otherwise the stored quantity is used once and is never multiplied by `workItem.quantity`, an instance container duration, or `activityCount`.

The `quantityIsTotal` check runs before the instance-child arms. This matters for frozen pricing selections, which can be instance children while already carrying a measured total. The row still retains the container calendar duration for display and scheduling.

A top-level row with `quantityBasis: "per_unit"` opts out of the whole-item default and is expanded by `workItem.quantity`. Hours and days on a total-quantity row remain absolute entered quantities; production-driven duration belongs inside an activity. The optional field defaults to `false` for legacy rows.

### Duration summary

`instanceDurationSummary(components, hoursPerDay)` returns the per-instance
calendar durations, grouped crew chains, and item duration:

```ts theme={null}
type InstanceDurationSummary = {
  itemDurationDays: number | null;
  chains: InstanceDurationChain[];
  instances: InstanceDurationInstance[];
};
```

Chain labels are trimmed and grouped case-insensitively; the first-seen
capitalisation is retained for display. Instances with the same non-empty label
run sequentially, so their days are summed. Each unlabelled instance forms its
own chain. The item's duration is the maximum chain duration because different
chains run concurrently.

For an hour-based activity (`hr` or `hour`), the summary converts base hours to
calendar days:

```text theme={null}
calendarDays = durationBase ÷ effectiveHoursPerDay
```

`effectiveHoursPerDay` resolves from the container override, then the estimate
setting supplied to the summary, then the default of `8`. Calendar days are
rounded to `DURATION_SCALE` and are used for display only; pricing continues to
use the base hours.

### Commercial rules (REMOVED)

The three commercial-rule fields — `minChargeDuration`, `roundingIncrement`, and `mobilisationAmount` — were removed as a feature on 2026-09-20 (api-estimate migration 0060). They no longer exist on `PricingComponentInput` or on stored work item component rows. `validateInstanceCommercialRules` is retired; only `validateInstancePair` is called on the write path.

The charged duration is now always the raw `allocatedQuantity ÷ productionRate` quotient — no minimum-charge floor and no rounding increment are applied. There is no mobilisation amount.

`InstanceDurationInstance.durationDays` is the calendar duration derived from the raw quotient. `calculatedDurationDays` was removed alongside the commercial rules.

### Instance pricing fields

| Field | Type | Purpose |
| - | - | - |
| `parentComponentId` | `string \| null` | Points an instance child to its container. |
| `allocatedQuantity` | `number \| null` | Instance allocation measured in `productionOutputUnit`; it need not use the work-item unit. |
| `productionRate` | `number \| null` | Quantity in `productionOutputUnit` produced per base cost unit. |
| `productionOutputUnit` | `string \| null` | Allocation-unit snapshot. Creation derives the work-item unit, and a later component `PUT` can replace it explicitly (for example, `each` on a `metre` work item). It cannot be cleared while the row remains an instance. |
| `productionProfileId` | `string \| null` | Provenance of the production profile used to auto-derive the rate. Optional within instance mode; an instance with a manually entered rate carries `null`. |
| `scopeLabel` | `string \| null` | Allocation-group label. Instances sharing the same non-empty label on one work item form a group; used by the UI to warn when allocations do not sum to the work item quantity. Not used in pricing. |
| `chainLabel` | `string \| null` | Crew-chain label for duration grouping and display. Instances sharing the same non-empty label run sequentially; the item duration is the maximum chain duration. Not used in pricing. |
| `activityCount` | `number \| null` | Optional tally of what the instance produces. Used only by a direct child with `quantityBasis: "per_count"`; does not affect duration or time-based pricing. |
| `quantityBasis` | `ChildQuantityBasis \| null` | Instance-child charging basis: legacy per-base-unit when absent, once for `"whole_activity"`, or multiplied by the container count for `"per_count"`. For top-level non-container rows, `"per_unit"` opts out of the whole-item default; `null` means whole-item. |

## Duration-driven quantities

Duration-driven pricing was introduced in `@stralos-co-nz/domain` `0.7.0` and remains part of the current contract. Productivity is an input on the work item:

```text theme={null}
durationDays = workItem.quantity / unitsPerDay
```

`workItemDurationDays` returns `null` when either input is non-finite, quantity is negative, or `unitsPerDay` is missing, zero, or negative. A zero work-item quantity is valid and produces a zero-day duration. The result is not rounded; presentation code decides how to display it.

Only genuine hour- and day-duration units are derived. Units containing `/` or the word `per` are rate units and classify as `other` before the module tests for `hr` / `hour` and then `day`. Other units keep their entered quantity.

For a duration-driven component, the pricing path substitutes a derived **per-work-item-unit, per-resource** quantity for the entered component quantity:

| Component unit | Quantity used by pricing |
| - | - |
| Hours | `effectiveHoursPerDay / unitsPerDay` |
| Days | `1 / unitsPerDay` |
| Other | Entered component quantity |

`effectiveHoursPerDay` resolves from the component override, then the estimate default, then `8`. The derived per-unit quantity is consumed live by pricing; it is not persisted into `component.quantity`. Existing downstream multiplication by work-item quantity and resource count reconstructs the whole-job charge without applying either factor twice.

For display and programme calculations, the corresponding whole-job quantities are `durationDays × effectiveHoursPerDay` for hour lines and `durationDays` for day lines, both per resource. These whole-job values must not be fed back into pricing.

Rows marked `quantityFromRecipe` retain their authoritative generated quantity instead of being duration-derived. Current producers include activity recipes.

A manual component-quantity override supersedes duration derivation for that line. The effective display and domain calculation omit the derived duration; there is no persisted `durationDays` field in the quantity override itself.

### Derived quantity scale boundary

A duration-driven row or activity-instance child carries a derived quantity with no natural decimal scale of its own. The domain resolver is strict: it rejects any quantity that exceeds 3 decimal places. A derived quantity can have up to 12 decimal places (the domain's internal cap), so `getPricingQuantity` in the estimate frontend snaps the ordered quantity to 3dp (half-up) before passing it to the resolver. This snap mirrors the one `api-estimate`'s `recompute.ts` applies at its own pricing boundary (`quantityScale.ts`, audit F2), so both sides produce the same resolver input.

Without this snap, a derived quantity such as `0.21323988` (eight decimal places) caused the resolver to throw, and the error boundary took down the entire pricing panel — all components on all rows — not just the affected row.

## Build-up display groups

`BUILD_UP_GROUPS` defines the shared display order for the nine estimate build-up sub-headings:

| Token | Default display label | Notes |
| - | - | - |
| `activity` | Activity | Structural container; cost lives in child components. |
| `labour` | Labour | Organisation-customisable display name. |
| `plant_vehicles` | Plant & Vehicles | Organisation-customisable display name. |
| `quarry_materials` | Bulk Materials | Organisation-customisable display name; split from the legacy single material heading. |
| `other_materials` | Materials | Organisation-customisable display name; split from the legacy single material heading. |
| `subcontractors` | Subcontractors | Organisation-customisable display name; reached only by authoritative data. |
| `day_rates` | Day rates | Structural group for lines picked from a day-rate schedule (`type: "day_rate"`). Always placed directly after Subcontractors. Never takes the labour multiplier. |
| `disposal` | Disposal | Organisation-customisable display name; reached only by authoritative data. |
| `other` | Other | Catch-all for unmatched rows. Never hidden or merged. |

The six rate-group headings resolve through the organisation's `rate_group_labels` setting list. A missing, empty, or failed load falls back to the domain default; `activity` and `other` remain structural defaults. Labels are display-only and never enter grouping, totals, or persistence.

`classifyBuildUpGroup(component)` classifies a component into one of these nine keys. It follows an enum-first, legacy-fallthrough precedence:

1. `type === "activity"` wins unconditionally — the component is a container.
2. `type === "day_rate"` wins unconditionally — the component is a day-rate line and files under `day_rates`, regardless of its `rateGroup` or `category`.
3. A `rateGroup` that is one of the six rate-group wire tokens maps 1:1 to its heading.
4. Anything else (NULL, absent, or an unrecognised value) runs the legacy substring sniff over `category`, `name`, and `unit` fields.

The legacy sniff preserves the classification every pre-PRD row already had. The only visible change for NULL-group rows is that the former single "material" heading splits into `quarry_materials` (when the "quarry" term appears) and `other_materials` (everything else in the material vocabulary).

The estimate work-items UI iterates `BUILD_UP_GROUPS` from `stralos-domain` and resolves each rate-group label for the current organisation. Exports retain the wire tokens. Consumers must not hard-code the group list or order locally.

The three structural groups that are not rate groups have fixed headings in `STRUCTURAL_BUILD_UP_GROUP_LABELS` (exported from `@stralos-co-nz/base`): `activity` → `"Activities"`, `day_rates` → `"Day rates"`, `other` → `"Other"`. These headings are not customisable and do not come from the organisation's `rate_group_labels` setting. Every surface that renders build-up headings must spread this map alongside the rate-group labels rather than hard-coding the strings.

### Labour predicate

`isLabourComponent(component)` is the single shared predicate that determines whether a component carries the labour multiplier. It uses the same enum-first precedence as the classifier:

* `type === "day_rate"` → never labour, regardless of `rateGroup` or `category`. A day-rate line is a SELL rate that already includes overheads and margin, so the labour multiplier cannot reach it.
* An authoritative `rateGroup` of `"labour"` → labour (multiplier applies).
* Any other recognised rate-group token → not labour (the group overrides the free text).
* NULL, absent, or an unrecognised group → falls through to the legacy `category` contains `"labour"` check.

This equivalence invariant (§7.1) means no pre-PRD row's money changes at cutover. `getLabourMultiplier` in the pricing module consumes `isLabourComponent`; the four estimate-UI call sites adopt it in M6.

## Work-item totals

`computeWorkItemTotals(workItem, components, pricingRow, bidRates, settings)` returns:

```ts theme={null}
{
  totalCostCents: number;      // selected source cost
  totalPriceCents: number;     // cost + markup or margin (with day-rate split applied)
  dayRateCostCents: number;    // the portion of totalCostCents that is day-rate lines
  pricingSource: PricingSource; // resolved source actually used
  internalCostCents: number;   // internal build-up (diagnostic)
}
```

Per-kind formula (§5.4):

| Item kind | Cost | Price |
| - | - | - |
| `measured` | Selected source cost | Day-rate markup split applied (see below); margin sell or markup on the non-day-rate portion, then day-rate cost added after. |
| `provisional_quantity` | Selected source cost | Same as measured. |
| `lump_sum` (legacy compatibility) | Selected source cost | Priced as measured; new application writes use `measured` with a lump-sum unit. |
| `provisional_sum` | Allowance | Allowance (margin-neutral). `dayRateCostCents` is zero. |

**Day-rate markup split.** For `measured`, `provisional_quantity`, and `lump_sum` items, the markup or margin applies only to the cost that is not day-rate lines. The day-rate cost is added after it, unmarked, because a day rate already includes overheads and margin:

```
price = markup_or_margin(totalCost − dayRateCost) + dayRateCost
```

For example, \$1,000 in marked-up cost at 15% plus \$2,000 of day-rate cost gives \$1,150 + \$2,000 = \$3,150. With no day-rate lines, `dayRateCostCents` is zero and every existing item prices exactly as before.

When a subcontract bid replaces the build-up, `dayRateCostCents` is zero — the bid is one cost line marked up as a whole.

**Margin and markup are alternative bases.** For `measured`, `provisional_quantity`, and `lump_sum` items, the engine resolves `marginPercent` via `workItem.marginPercent ?? settings.defaultMarginPercent ?? null`. When that resolves to a non-null value, `computeMarginSell` is called on the non-day-rate portion and markup is not applied. When both rungs are absent or null, the markup arm runs byte-for-byte as before — the field is inert for every estimate that predates the pricing ladder.

Only `provisional_sum` items take neither basis: cost = price = allowance, margin-neutral by definition.

### Estimate API component margin ladder

The Estimate API resolves margin separately for each component using `src/pricing/marginLadder.ts`. The order is component `marginPctOverride`, category, rate group, tender `defaultMarginPct`, then frozen organisation default. At a category or group, a tender row replaces the organisation row at that same scope. A frozen category therefore outranks a tender group or tender default.

The first non-null value wins. Explicit 0% means sell at cost; when nothing resolves, the component prices at cost and its applied margin/source remain null. Published package subtrees and `provisional_sum` items bypass the ladder.

The API sums each component's margin sell. For example, \$100 cost at 10% margin and \$100 at 20% margin yield \$111.11 + \$125.00 = \$236.11. This is separate from the flat domain input described below.

`pricingBasis` is server-owned and frozen on estimate creation. Organisation settings, group margins, and category margins are fetched only during creation, with a five-second timeout per request. Unavailable or invalid organisation settings fall back to markup without a ladder. Failed group/category fetches retain the margin basis and freeze the available rungs. Recompute reads the estimate's stored ladder without calling Global.

The [margin-ladder API](https://help.stralos.app/api/estimate.md#margin-ladder) edits tender group/category rungs under the estimate-totals lock and recomputes before returning. The tender default uses `PUT /estimates/:id`; component overrides use `marginPctOverride`. There is no work-item rung: `estimate_work_items.markup_pct` was removed on 2026-09-18, so the fourth rung is the tender default alone on both bases.

#### Category stamp on rate lines

The category rung resolves against `work_item_components.category_id`. The server stamps this column from the rates service — never from the client — on two occasions:

* **New rate line**: any component write that names a `rateItemId` and carries no `categoryId` (quick-add, build-up add, activity child, relink) stamps the category before the write commits. The lookup is made to api-rates using the work item's project scope so project-only rate items resolve correctly. The stamp runs outside the write transaction; only the UPDATE runs under the estimate lock.
* **Ladder mutation**: a tender rung PUT or DELETE heals every rate line in the estimate that still carries `NULL` in `category_id`, then recomputes. This ensures estimates built before the stamp was introduced price the category rung correctly the first time a rung is set or reset.

A category a client sent is never overwritten, and a row that already carries a category is never restamped. The column is a frozen snapshot: a category retired or renamed in the rates library must not shift a submitted tender.

The stamp is fail-soft. If api-rates is unavailable or answers a shape the service cannot parse, the row keeps `NULL` and the next rung (rate group) applies. Refusing the write in that case would block estimating because a margin classification was unavailable.

**Component override validation (`marginPctOverride`).** The body schema on the four write endpoints that carry this field (`POST /work-item-components`, `PUT /work-item-components/:id`, `POST /work-item-components/:id/children`, and `POST /work-items/:id/time-resource-activity`) accepts the union of the two basis bands (−100 to 999.999) because the schema is parsed before the estimate is read. The actual basis-band check runs inside the write transaction using `assertComponentOverrideInBasisBand`, which reads the estimate's frozen `pricing_basis` and returns a `400` if the value is outside that band. The valid bands are defined in `LADDER_BANDS` (margin: 0–95; markup: −100–999.999) and are the single authoritative definition shared by the schema, the tender-rung routes, and the DB CHECK.

**Resolve-time drop.** `resolveComponentMargin` drops a stored override that is outside the band for the estimate's basis rather than pricing it. This mirrors the behaviour of `organisationLadderRows` on the other four rungs and makes an out-of-band override impossible by construction regardless of how the row arrived — including via `copy-build-up` from a markup tender into a margin one. A dropped override falls through to the next rung; it does not cause a recompute error.

### Shared Costs and sell

Recompute sums the Shared Costs pool's cost and already-calculated sell independently, then apportions each total in cents using largest remainder. Both allocations use eligible recipients' non-fixed cost. Fixed package portions and provisional sums contribute no eligible base, and only top-level recipient rows receive shares. When there is no positive eligible base, the pool remains an ordinary section.

The pool retains its own commercial result; the recipient's margin is not reapplied to its allocated share. A zero-margin pool need not increase the tender price.

### Frontend pricing boundaries

**Tender Submission → Margins** saves the field for the estimate's frozen basis. The legacy estimate Settings percentage and work-item inline percentage still write markup fields even when labelled for margin. The **View** rate-margin dialog uses local preview state and does not persist component overrides.

The priced schedule's money uses server totals. Its percentage summary uses component calculations without allocated spread shares and receives the live organisation default as a fallback for the default-percentage rung. That preview is not proof that the server's frozen ladder or saved totals changed.

#### Pricing basis wiring (stralos-feature-estimate)

`EstimatePage` publishes the estimate's pricing basis through `PricingBasisContext`. Every panel that names a percentage reads the basis from that context via `usePricingBasis`.

The basis is resolved by `normalisePricingBasis(estimate?.pricingBasis)`. An absent or unrecognised token reads as `'markup'` — the constant a stored percentage has always meant, and the only value that cannot change under a live tender. The pre-load window (while `estimate` is null) therefore also presents `'markup'`, which is unobservable because the page's loading gate returns an empty state above every reader of the basis.

`useOrganisationPricingBasis` returns only the organisation's default percentage (`organisationDefault: number | null`). It no longer returns a basis token. Reading the organisation's basis to decide how an estimate's numbers are labelled was a bug: a stored 20.000 is \$120,000 on \$100,000 of cost as markup and \$125,000 as margin, so an organisation switching basis must not silently reinterpret a tender already quoted. `api-estimate` enforces the same invariant from the server side: `pricingBasis` is refused on the estimate PUT with `pricing_basis_server_owned`.

The `PricingBasisProvider` wraps the estimate workspace and is the only permitted source of the basis word for any child component. Do not pass the organisation's basis into this context or use `useOrganisationPricingBasis` to derive it.

### Margin input fields

Two optional fields on `WorkItemTotalsInput` and `PricingSettings` carry the margin basis:

| Field | Type | Convention | Notes |
| - | - | - | - |
| `WorkItemTotalsInput.marginPercent` | optional `number \| null` | Whole percent (19.9 = 19.9%) | Per-item margin. Null or absent falls back to `settings.defaultMarginPercent`. |
| `PricingSettings.defaultMarginPercent` | optional `number` | Whole percent (19.9 = 19.9%) | Estimate default. Absent means the estimate has no margin basis. |

These fields are additive and optional. Callers that supply neither field continue
through the existing markup calculation unchanged.

**Package sell and allowance kinds.** A frozen package sell (a subcontractor package container's `packageSellUnitPrice`) is added to the total only for `measured`, `provisional_quantity`, and `lump_sum` items, where the price derives from the internal build-up. For `provisional_sum` items, the package sell is excluded from the price calculation because those items are margin-neutral and priced by their allowance alone. A package container on a provisional sum is informational only and does not add to the price.

## Pair-aware frozen conversion factors

The rates library stamps bulk-material density into `conversionFactor` on every rate option, including options priced in the same unit the estimate row is measured in. Applying that factor without considering the unit pair would overcharge: a row measured in m³ and priced in m³ at \$28/m³ with a stored density of 1.72 t/m³ would be priced as 1.72 m³, producing \$48.16 instead of \$28.

`pairAwareConversionFactor` in `src/pricing/unitPairConversion.ts` determines the effective factor from the measured-unit / priced-unit pair before either resolver arm receives it. The stored factor is validated as written; what the resolvers consume is the pair-aware effective value.

The rule (identical on the frontend's `pricingEngine.getPricingQuantity`):

| Pair | Effective factor |
| - | - |
| Same canonical unit (e.g. m³ → m³) | `null` — no conversion. The stored density is data, not a command. |
| m³ measured → tonne priced | `f` — ordered = measured × f. |
| tonne measured → m³ priced | `1 / f` — ordered = measured ÷ f. Matches the library's own per-tonne face of the rate. |
| Any other cross-unit pair | `f` unchanged — legacy ×f behaviour. |
| Null factor (any pair) | `null` — no conversion, as before. |

`resolveFrozenRulesForWorkItem` passes the pair-aware effective factor into both the simple and ruled resolver arms. The live option path (`resolveLineAmount` → `normalisedCostRate`) is also pair-aware, so preview, apply, and the authoritative recompute agree on a same-unit quarry pick.

No schema change and no data migration are required. Options that are priced in the same unit as the estimate row continue to carry the density value in the database; the factor simply has no effect when the pair rule suppresses it.

## First-render location resolution

When a pricing panel is opened for the first time against a component that has no saved override entry, Stralos automatically selects a default [rate option](https://help.stralos.app/api/rates.md#rate-options) using a priority ladder. This runs once per component per estimate — later panel loads are no-ops, and any component that already has a persisted snapshot, a session selection, or a manual override is left untouched.

### Resolution order

`resolveDefaultRateOption` first considers a project-scoped override. It then walks the project's shared ordered list of rate rules and the pricing location, sorted by `position`. The first entry that covers the line and yields an eligible option wins. Position overrides specificity: a rate-group rule can beat a later item rule, and the pricing location can beat a later rule. An entry with no eligible option is skipped. If the list yields none, the resolver checks the project's own office location and finally the distance-aware default tier, described under **Default tier (Search distance)** below.

An explicit pin is excluded by construction: first-render resolution runs only when the component has no saved override entry.

**Project-option scope guard.** An option whose `projectId` does not match the estimate's project is silently excluded from the candidate list. The Rates API never serves a foreign-project option to a correctly scoped caller; the guard is defence in depth.

**`projectRateOverrideId` for project prices.** When a project-scoped override wins, the persisted snapshot records the winning project option's id. Converted prices retained the old override id, so existing snapshots remain valid. The resolver no longer accepts a scalar `overrideFinalRate` or a candidate `projectRateOverrideId` input.

**Scope guard.** A rate option whose `projectId` is set to a project other than the current estimate's project is excluded from the candidate set before the resolution order runs. Only Rates Library options (no project) and this project's own options are eligible candidates.

S6b removes the legacy override-list read and transition tripwire. Pricing resolves directly from project-scoped options.

> [!WARNING]
>
> **This ladder was rebuilt and both `resolveDefaultRatePrice` and
> `normalizeLocationLabel` are gone.** Tiers 3 and 4 used to compare *normalised
> location label strings* — the project's `geoTag.region` and
> `organisation_settings.default_location_label` against the selected price row's
> free-text location label. They now compare **location ids**: a project declares its rate
> context as a real company location (see
> [Rates API — project rate context](https://help.stralos.app/api/rates.md#project-rate-context-and-rules)),
> and the region string-match and the quarry short-circuit were both retired. The
> `rule` tier between them is new. The organisation-wide `default_location_id` column
> (`organisation_settings`) is retired and its database column
> dropped (migration `0082`); the `office` tier now resolves from each
> project's office location. A user's default office can initialise a project; it is not the estimate pricing fallback.

A rule that covers the line but matches no candidate does not fall through silently: the decision records `resolvedRuleOutcome` as `unsatisfiable` (a source target with no matching option) or `policy_unmet` (a *cheapest within N km* rule with nothing in radius), and resolution continues to the next tier carrying that outcome.

**Within a tier the winner is the candidate with the lowest resolved line amount at the line's quantity**, tie-broken by option id ascending — *not* the lowest display rate. Where pricing rules apply the two differ: a supplier whose representative rate looks cheapest can cost more once its bands, matrix, minimums and loadings resolve at the quantity being priced.

**Line-unit pricing pivot.** Every candidate's cost is normalised into the **line's own unit** before comparison (`resolveLineAmount` in `pricing/ruleResolver.ts`). The pivot is the component row's unit, the value persisted on `estimate_work_item_components.unit`, because rate items no longer carry a unit. Both repos read it through `resolverLineUnit` (`pricing/repriceLines.ts` in `stralos-api-estimate`, `pricingEngine.tsx` in `stralos-feature-estimate`), which returns the stored unit except on a project calculator row (`generatedKey` starting `calc:`). A calculator row stores the calculator's short spelling (`m3`, `t`, `m2`, `m`) while rate options are priced in catalogue codes (`cubic_metre`, `tonne`), so `resolverLineUnit` passes `canonicaliseUnit(unit)` for those rows only. Without it every option was excluded and a calculator line could take no default. A candidate that cannot convert to the line unit is excluded with `conversion_missing`. An absent or blank line unit is also excluded rather than allowing a mixed-denomination comparison.

**Legacy `itemUnit` tolerance.** `ResolverLine.itemUnit` and `ResolvableOption.itemUnit` remain optional and nullable for compatibility. Their absence is valid and does not make `fetchResolverRateItem` return HTTP 502.

**Companion orientation fields.** `conversionNumeratorUnit` and `conversionDenominatorUnit` are optional and nullable on the companion option block (`rateOptionCompanions`). Both present fields define the conversion direction; both absent fields use rate-group and unit-pair inference.

**Component unit stored verbatim.** The unit canonicalisation alias table (`unitCanonical.ts`) explicitly excludes `estimate_work_item_components.unit`. Component units are stored as supplied — `ea` is not rewritten to `each`, `hr` to `hour`, and so on. The pivot comparison is exact, so canonicalising the stored unit would merge rows that resolve to different amounts. The `calc:` read in `resolverLineUnit` is the only exception, and it never rewrites the stored column.

**`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 options where `locationId` is null — prices that genuinely apply everywhere. It never matches one of the owner's place-specific prices, even a cheaper one. If the owner has no all-locations price for the item, the rule is unsatisfiable and the next entry in the ordered list tries.

A `source` rule with a specific `locationId` (site X) matches that site's own prices **and** the supplier's all-locations price, because an all-locations price is sold at every site of the supplier. The supplier is the rule's `partyId`, or else the supplier whose `siteRoutes` include X. The cheapest line amount wins; `lowestAtPlace` breaks an exact tie in favour of the site's own price. `ruleSiteLabel` records X as `resolvedLocationLabel` when the all-locations price wins.

**Default tier (Search distance).** When no project price, preference or office option wins, `defaultCandidate` picks the default:

1. Only **measured** candidates compete: a current route, or one stale by age alone (`staleReason: "age"`). The `cheapest_within_km` policy accepts current routes only, and a route whose project or site has moved never counts.
2. The radius is the project's saved search distance (`ResolverLine.defaultRadiusKm`, from api-global `searchRadiusKm`). With none saved, the resolver steps through `DEFAULT_SITE_RADIUS_STEPS_KM` (`[50, 100, 150]`) and uses the first radius that holds a measured candidate. The cheapest candidate within that radius wins.
3. If no measured candidate is within any radius, the nearest measured candidate wins.
4. If no candidate is measured at all, the cheapest candidate wins.

An all-locations supplier option carries `siteRoutes`, one route per active site of the supplier. `siteDistance` measures it at its nearest usable site, and the winning site's name is recorded in `resolvedLocationLabel` (no schema change). A site with no address, and a location-less price with no sites, is never the default while any candidate is measured. `distanceAwareDefault(line)` scopes the tier and is true for every line. The server never measures routes: api-estimate reads cached routes from api-global (`GET /api/global/projects/:id/location-routes` with `staleReason=true` and `partyIds`), while the browser measures them when a project is set up and an estimate opens.

The resolver module is a **byte-for-byte transcription** between `stralos-feature-estimate` and `stralos-api-estimate`, backed by a cross-repo fixture. A tier that lands on one side only is a divergence that makes re-resolution reprice lines nobody touched.

Resolution is gated on the current estimate's pricing overrides and rate data loading successfully. The project office can be null; it must not delay resolution. A stale success state from a previous estimate cannot authorise resolution mid-switch.

### Provenance snapshots

Every automatically resolved component is stamped with a provenance snapshot:

* `provenance: "auto"` — written by first-render resolution or the explicit re-resolve action.
* `provenance: "pinned"` — written when the user explicitly picks an alternative or edits the component.
* Absent (legacy rows written before this feature) — treated as pinned; re-resolution does not move them unless the user explicitly includes them through `convertToAuto`.

The snapshot also stores `resolvedTier` and `resolvedLocationLabel` (the display label captured for `"project"` and `"office"` tiers; `null` for `"override"`, `"rule"`, and `"default"`). These fields are carried verbatim on every subsequent materialise — a re-render of an already-persisted component reproduces its snapshot byte-for-byte so autosave diffs are no-ops.

`libraryUnitCost` is a presence-based audit field containing the library option's unit cost at the moment represented by the snapshot, in the priced unit. The pricing-rule endpoint can capture it for a pinned selection. Re-resolve preserves an absent key, but when an auto selection already carries the key it refreshes the value to the newly resolved library cost. The broad work-item pricing route persists the incoming number, `null`, or absence and can therefore add, change, or remove it regardless of provenance. The recompute engine never prices from this field.

### Atomic unit move via pricing-rule selection (`pricingRuleSelectionSchema.unit`)

`pricingRuleSelectionSchema` carries an optional `unit` field. When present, `PUT /work-item-components/:id/pricing-rule` writes the component's own `unit`, the pricing selection, and the recomputed `unitCost` denominated in it in a single statement followed by one recompute.

This field lets the UI move a frozen row directly to another option priced in the target unit. A plain component `PUT` can also change the unit, but it unfreezes the selection and leaves the numeric rate for the estimator to correct. The atomic selection path changes the option, unit, and recomputed money together instead.

**Safety invariant.** `unit` must equal the canonical `pricedUnit`. The route enforces this with a `422`. The equality restricts the move to same-unit selections by construction: an option that requires a conversion has a `pricedUnit` the component is not moving to and cannot satisfy the check, so conversion-based rows are unaffected.

**Not implied by `pricedUnit`.** A row measured in m³ priced from a per-tonne option legitimately keeps m³ and carries a conversion factor. Copying `pricedUnit` onto every selection would silently re-unit those rows. The `unit` key is therefore opt-in and must be sent explicitly by the caller.

### Re-resolve pricing review

The re-resolve action (`POST /api/estimate/estimates/:id/re-resolve-prices`) re-runs the resolution order for every component whose provenance is `"auto"`. It is exposed in the frontend as the `ReResolvePricingDialog` component, accessible from the Project Rates panel.

**Dry-run preview (`dryRun: true`)** — returns a `ReResolvePricingPreview` without writing anything. The preview includes:

* `items` — each component that would change, with current and resolved option labels, rates, and amounts.
* `managedTickRemovals` — managed fixed-charge companion components that the parent option change will remove.
* `companionReviewParentIds` — parent component IDs whose fixed charges need a companion review after the apply step.
* `summary` — total current and resolved amounts, delta, count by tier, and total changed count.
* `pinned` / `legacyPinned` — components with `provenance: "pinned"` or absent provenance that are excluded by default. The caller may pass their IDs in `convertToAuto` to include them.
* `skippedChanged` — components omitted because the resolver could not represent their money safely (shown as a warning).
* `previewHash` / `resolutionInstant` — authority tokens that must be echoed back in the confirm call.

**Confirm (`dryRun: false`)** — applies the preview. Must include `previewHash` and `resolutionInstant`. If the estimate's pricing changed since the preview was computed, the server returns HTTP 409 `stale_re_resolve_preview` with a fresh preview embedded in the error body; the dialog parses this and prompts the user to review and confirm again.

After a successful apply, the frontend navigates to the work-items panel with a `reviewCompanions` query parameter when `companionReviewParentIds` is non-empty. The `WorkItemsPricingPanel` detects this handoff on mount and opens the `CompanionReviewModal` automatically.

**Mirror companion rule.** When a re-resolve moves a line onto the mirror of its current option — meaning the new winner is a project option that mirrors the library option already stored, verified by `mirrorsOptionId` or by matching source, yard, and condition — the move is not treated as a parent change. Ticked fixed-charge companions are preserved. A move to a different supplier's mirror is a parent change and ticked companions are removed as normal.

### Ticked-companion removal rule

A ticked companion is a fixed-charge component owned by its parent's rate option — it is desired state that Stralos manages, not a line the estimator entered. The same rule runs in both the re-resolve path and the automatic re-price path so the two writers can never answer the question "does this companion still belong to its parent?" differently.

A ticked companion is removed when the writer moves its parent onto a different option and **either** of the following applies:

* **Rule 1 (identity change):** the parent moves onto a different option that is not the line's own mirror option. Decided by id comparison with no network read. This covers a move onto another supplier's option as well as a clear of the current selection.
* **Rule 2 (in-place supplier change):** the parent's new option is a project price and the companion's own option is no longer among the companions that new option serves now. Decided by reading `/rate-options/<newOptionId>/companions?projectId=` through the rates API. This preserves the membership check when a project option keeps its id but its served companions change.

Rule 2 applies only when the parent's new option is a project price. A library option cannot be re-pointed in this way, so a library-only estimate never triggers a companion read.

**`companionReadsNeeded` and `tickedCompanionRemovals`** implement the two passes. Pass 1 names the distinct option ids that need a companions read: only moved parents that (a) survive rule 1, (b) have at least one ticked companion, and (c) move onto a project price. Pass 2 applies both rules to the answers. The two functions are `pure`; the caller performs the reads and the writes.

**Companion read failure (`companions_unavailable`).** If a read needed by pass 2 is absent from the answers — either because the fetch failed or because the estimate's lines moved between the pre-lock plan and the locked plan — the whole operation fails closed with HTTP 502 `companions_unavailable` and writes nothing. The error message names the work item number, work item name, and the line.

* **Re-price path (a `/project-rates` write in api-rates):** api-rates keeps the outbox row in `pending` state and re-delivers. The legacy override routes are removed in S6b; write through `/project-rates`. The re-price is computed from current state each time, so retrying is safe. Pinning the affected line does not help because the re-price refreshes pins. The remedy is to delete the stale ticked companion charge from the build-up.
* **Re-resolve path (`POST /estimates/:id/re-resolve-prices`):** the preview and confirm steps both compute before any transaction, so an unread answer never reaches a write. The remedy is to delete the stale ticked companion charge, or pin the line so re-resolve leaves it.

### Import rate refresh fan-out key

`importRateRefresh` resolves rate options for every priced component row when new option pricing is imported. Components with the same `(rateItemId, code, name, rateGroup, quantity, unit)` identity resolve identically, so the fan-out de-duplicates on a composite key (`rateLineKey`) and issues one upstream lookup per distinct identity.

`unit` is part of the key. Two rows with the same rate-item identity but different units may resolve to different scalars — one may use a cross-unit conversion while the other prices direct — so they must not share one resolution. The key encodes `line.unit` verbatim (not normalised), because the pivot comparison is exact.

### Rate exceptions

`RateExceptionsPanel` (`/api/estimate/estimates/:id/rate-exceptions`) computes and displays lines whose current pricing selection or frozen pricing-rule outcome needs review. Each `RateExceptionRow` carries:

* `kind`: one of the registered exception tokens. api-estimate registers thirteen: `pin_beats_rule`, `override_beats_rule`, `rule_unsatisfiable`, `policy_unmet`, `rule_changed`, `pin_unresolvable`, `no_eligible_option`, `band_override`, `matrix_secondary_unresolved`, `condition_token_miss`, `minimum_over_override`, `step_not_picked`, and `step_retired`. feature-estimate's `REGISTERED_RATE_EXCEPTION_KINDS` lists the first eleven, and its panel labels also cover the two step kinds (**Step needed** and **Step no longer offered**). `rate_table_row_not_chosen` is an error code, not an exception kind: an unchosen rate table is reported as `matrix_secondary_unresolved`.
* `sentence` — a plain-language description rendered in the panel.
* `money` — delta amount and optional per-unit delta. `null` when no price difference is computable.
* `actions` — one or more action buttons the user can invoke (`keep_pin`, `apply_rule`, `accept`, `widen_policy`, `choose_option`, `follow_rule`, `clear_band_override`, `supply_input`).
* `fingerprint` — a SHA-256 content hash of the exception's inputs. If the exception changes between load and action, the server returns HTTP 409 and the panel reloads.

The count endpoint (`/rate-exceptions/count`) returns a lightweight `{ count, stale, computedAt }` used to badge the Project Rates panel header without loading the full list.

#### Grouped dismiss (`similarExceptionGroups`)

`similarExceptionGroups(rows)` in `src/RateExceptionsPanel.tsx` identifies exception cards that share the same `kind`, `action.token` (a dismissing token only — never a fixing action), and `lineRef.componentName`. When two or more cards match on all three keys, the panel renders a grouped row above the individual cards. The grouped row shows the count, component name, and kind, and its button applies the common dismissal action to every card in the group in a single `keepAllSimilar` call.

A `DISMISSING_TOKENS` set defines which action tokens qualify: these are the tokens whose action resolves without opening a picker or sheet. Fixing tokens (`choose_option`, `apply_rule`, `follow_rule`, `clear_band_override`, `supply_input`) are never grouped because each fixing action requires individual review.

### Provenance captions

`formatResolutionCaption` was removed from `stralos-feature-estimate` because nothing rendered it, so the pricing panel shows no "resolved when priced" caption. The persisted snapshot (`provenance`, `resolvedTier`, `resolvedLocationLabel`, the frozen rule) still records the conditions that applied when the price was resolved, and is never recomputed from current settings.

What the estimator sees comes from `WorkItemDetailPane.tsx`: an option whose `resolution.provenance` is `auto` appears in the picker as **Default — *option label***, and a pinned line names its option. A line flagged for a price choice shows **Choose a price…** instead.

### Location label normalisation — retired

`normalizeLocationLabel` and its content-hash-pinned
`locationNormalizationContract.json` fixture **no longer exist** in either repo.

They existed because the `project` and `office` tiers matched a selected price row's
free-text `locationLabel` against a project region string, so the two sides had to
agree on a normal form down to Unicode composition. Those tiers now compare
**location ids** — a project's rate context is a declared company location, not a
name — so there is nothing left to normalise and no way for the two sides to
disagree about casing. A resolved location's label still travels with the decision
(`resolvedLocationLabel`), but only as display provenance; it is never a match key.

## Pricing rule resolution exceptions

`resolvePricingRule` returns a `RuleResolution` whose `exceptions` array carries zero or more `PricingRuleResolutionException` entries. Each entry has a discriminant `kind` field. Consumers should surface these to the user as a review prompt, not as a hard error. The resolution still produces a money amount, except for `step_not_picked` and `matrix_secondary_unresolved`, which come back unpriced (see [Stepped base rules](#stepped-base-rules) and [Rate table must-pick](#rate-table-must-pick)).

### `condition_token_miss`

Fires when a non-`standard` condition token is charged against a schedule scope that holds at least one loading rule, but none of those loading rules is keyed to the charged token (D12).

```ts theme={null}
{
  kind: "condition_token_miss";
  chargedToken: string;   // the token the line was charged under
  heldTokens: string[];   // non-standard tokens that owner's loadings are keyed to (may be empty)
}
```

**`heldTokens` may be an empty array.** A schedule that only holds a `standard`-conditioned loading has no non-standard token to name, but the exception still fires because the owner holds a loading and the line's token was not recognised. An empty `heldTokens` means "this schedule holds a catch-all loading and no specific loadings — the condition on the line is unrecognised". Display such entries as "unrecognised condition, no alternatives" rather than suppressing them.

This exception is a **schedule concept only** — `condition` is part of a rate option's identity, so a rate-option charge never raises it.

**`condition_token_miss` was tightened in domain `0.25.1`.** Before that release, the exception was gated on `heldTokens.length > 0`, which meant a typo against a standard-only loading (or a schedule holding only a standard fallback) produced no exception and priced silently. Consumers compiled against an older domain will not see the empty-`heldTokens` variant.

### Other exception kinds

| Kind | When raised |
| - | - |
| `matrix_secondary_unresolved` | The rate table's side has no usable value for the line, so the line has no price. On a ranges side no number was supplied; on a named side no row key was supplied, or the key is not one of the rows. Carries `secondaryKey`, `secondaryLabel`, `secondaryKind` (`"ranges"` or `"named"`), and `unknownChoice` (the stored key that names no row, or `null`). api-estimate's only action is `supply_input` (**Choose row**); there is no Accept. A row persisted before pricing 2.7.0 may still carry `defaultBandLabel`; nothing reads it. |
| `minimum_over_override` | A minimum-quantity rule raised the charged quantity above a band override. Carries `minimumQuantity`, `orderedQuantity`, `chargedQuantity`, and `deltaAmount`. |
| `band_override` | A project band override was applied rather than the automatic band. Carries `appliedBandKey`, `appliedBandLabel`, `automaticBandKey`, `automaticBandLabel`, `deltaAmount`, and `perUnit`. |
| `step_not_picked` | A stepped base has no pick, or the stored pick names no step. The line has no price. Carries `unknownBandKey` (the stored pick, or `null`). api-estimate's only action is `supply_input` with `fieldKey: "step"` (**Pick a step**); there is no Accept. |
| `step_retired` | The picked step has been retired. It still prices, but the estimator should pick another. Carries `stepKey` and `stepLabel`. api-estimate offers `accept` and `supply_input` (**Pick another step**). |

## Pricing source resolution

The `pricingSource` token on the pricing row selects either internal build-up or a specific subcontract bid. Token format: `"internal"` or `"subcontract:<bidRateId>"`.

The token's only persistent home is `estimate_work_item_pricing.pricing_source`. The `estimate_work_items` table carried a `pricing_source` column as a transition store during the M2 migration; that column is retired (snapshot v31) and no writer remains. Migration `0086` drops that transition column. `POST /work-items` and `PUT /work-items/:id` still accept `pricingSource` in the request body for compatibility, but the value is written only to the pricing row.

When no pricing row exists, or when the pricing row has a null or empty `pricingSource`, the **no-row default rule** (R2.a) applies: the item prices from the internal build-up. A subcontract bid is never auto-selected.

A bid applies only when an estimator explicitly sets `pricingSource` to `"subcontract:<bidRateId>"` on the pricing row. Every bid is still validated when `resolveNoRowPricingSource` is called, so a malformed bid throws as before, but the cheapest-bid auto-select behaviour that previously copied EstimatePage was retired in pricing `1.1.0`.

The rationale: a contractor may price on the assumption of winning all sections, so all returned rates must be reviewed before any one rate is chosen. Cherry-picking the cheapest bid silently is not permitted.

If a selected subcontract bid no longer exists, the source falls back to internal automatically.

**Package containers always price internal.** When a work item has a published package (a component with a `packageSellUnitPrice`), the item's cost and price are computed from the internal build-up regardless of any stored `pricingSource` token. The package sell is applied on top of the internal cost. A `subcontract:<bidRateId>` selection on a package container has no effect on the money recompute.

**Read-path accuracy.** `GET /work-items/:id` and `GET /estimates/:id/work-items` report the `pricingSource` value that the money recompute actually used, not the raw stored token. The effective source is `"internal"` when:

* no pricing row exists, or the stored token is null or empty;
* the stored token names a bid that does not belong to this work item;
* the stored token names a bid that belongs to this item, but the item has a published package.

In all other cases the stored token is returned as-is.

## Package sell and margin sell calculations

`computePackageSell(costCents, marginPercent)` converts a package cost in integer cents and a whole-percent margin on sell into an integer sell price in cents. It is exported from the `@stralos-co-nz/domain` barrel and used wherever a published sell amount must be derived from cost and margin.

```ts theme={null}
computePackageSell(costCents: bigint, marginPercent: number): bigint
```

`computeMarginSell(costCents, marginPercent)` is the corresponding function for pricing a work item line on the margin basis. It is called by `computeWorkItemTotals` when `marginPercent` resolves to a non-null value. Both functions share one private implementation so the formula exists in exactly one place.

```ts theme={null}
computeMarginSell(costCents: bigint, marginPercent: number): bigint
```

`marginPercent` is a **whole percent** (19.9 means 19.9%), matching the `numeric(6,3)` activities contract. This is the opposite convention to the domain's legacy markup fraction (`0.199`).

Both functions stay on scaled integers throughout and perform one half-up rounding at the final cent boundary:

```text theme={null}
sellCents = round(costCents × 100 / (100 − marginPercent))
```

`computeMarginSell` exists as a separate export rather than converting the margin to an equivalent markup fraction and reusing `applyMarkupCents`. That conversion is lossy at the engine's five decimal-place fraction scale — measured at 59 cents short on a \$1,000,000 cost line at 15%. Tender revisions are hashed, so they must reproduce to the cent.

Validation rules (each throws `RangeError` on violation, for both functions):

* `costCents` must be non-negative.
* `marginPercent` must be in the range `[0, 95]` (margins above 95% are rejected). The check is at three decimal places precision.
* `marginPercent` must be a finite number (NaN throws).

## Pricing rules (D14)

`@stralos-co-nz/domain` `0.22.0` adds a shared pricing-rule resolver and write-boundary validator. These are the domain contracts for D14 flexible pricing.

### Rule types

Each rule is a JSON payload stored in `rates_pricing_rules.rule`. The `PricingRule` type is a discriminated union keyed on `ruleKind`:

| `ruleKind` | `slot` | Description |
| - | - | - |
| `simple` | `base` | Quantity × unit price from the charge context. |
| `tiered` | `base` | Quantity mapped to a band; supports `whole_quantity` (apply the band's rate to all units) or `progressive` (stepped accumulation across bands). |
| `matrix` | `base` | A rate table: a primary quantity axis across the top and a secondary side down it. The side is either numeric **ranges** (for example depth in mm) or **named rows** (for example concrete type). `whole_quantity` only. Users see it as a **Rate table**. Must-pick: unpriced until the side has a value or a cell is pinned. See [Rate table must-pick](#rate-table-must-pick). |
| `fixed` | `base` | A single lump-sum amount regardless of quantity. Cannot carry a quantity minimum or an additive modifier. |
| `cost_plus` | `base` | Supplied per-unit cost × (1 + `percent` / 100). Requires a `suppliedCost` in the charge context; throws `MissingSuppliedCostError` when absent. |
| `stepped` | `base` | User-named lump-sum steps; the estimator picks one on the line. No default step, no regular cost and no unit conversion. Unpriced (not \$0) until a step is picked. See [Stepped base rules](#stepped-base-rules). |
| `minimum` | `minimum_quantity` or `minimum_amount` | Floors the charged quantity or the money amount. Carries either a `value` (positive) or `suppress: true`, never both. |
| `modifier` | `modifier_additive` or `modifier_multiplicative` | Per-unit additive surcharge or whole-amount multiplier applied after the base. Carries a `label` shown in captions. |

### Owner types

Rules belong to one of three owner types (`PricingRuleOwnerType`):

| Owner type | Allowed base rules | Notes |
| - | - | - |
| `rate_option` | `simple`, `tiered`, `matrix`, `fixed`, `stepped` | All conditions must be `standard` because condition is part of option identity. `cost_plus` is not allowed. |
| `day_rate_schedule_line` | `simple`, `fixed`, `cost_plus` | Banded rules (`tiered`, `matrix`) and `stepped` are not allowed on schedule owners. |
| `day_rate_schedule_revision` | `simple`, `fixed`, `cost_plus` | Same as line. `minimum_quantity` slot is not allowed. |

### Conditions

Every rule in a set is paired with a `condition` token. A condition identifies the pricing context — for example a time-of-day loading or a rate variation. `base` rules must always use `"standard"`. Non-base rules can carry non-standard tokens to apply loadings only when a matching condition is active.

`normalizePricingRuleCondition(value)` normalises a raw token: trims whitespace and lowercases. Stored condition values must match the pattern `[a-z0-9]+([ _-][a-z0-9]+)*` and must be at most 40 characters.

### `resolvePricingRule`

`resolvePricingRule(input)` is a pure function that computes a charge from a context and a set of rule scopes. It never reads or writes any external state.

**Resolution order (locked):** conversion → quantity floor → base → additive modifier → multiplicative modifier → money floor.

```ts theme={null}
type ResolvePricingRuleInput = {
  context: ChargeContext;
  scopes?: ReadonlyArray<PricingRuleScope>;
  bandOverrideKey?: string | null;   // "t:<band-index>", "m:<secondary>,<primary>", or "s:<step-key>"
  unitPriceOverride?: number | null; // negotiated project rate; supersedes base-rule unit price (never on a stepped base)
  chargeLabel?: string;
};
```

`ChargeContext` supplies the measured quantity, unit price, optional conversion factor (e.g. t/m³), and the rate table side for matrix rules. An optional `condition` token (defaults to `"standard"`) selects modifier loadings. `unitPrice` is nullable: only a stepped base may leave it null, and every other base refuses a null with a `RangeError` rather than pricing it as \$0.

A rate table's side is supplied by kind:

| Side kind | Context field | Value |
| - | - | - |
| Ranges (`secondary.kind` `"ranges"` or absent) | `secondaryInputs[secondary.key]` | The number, such as a depth in mm. |
| Named (`secondary.kind: "named"`) | `secondaryChoices[secondary.key]` | The chosen row's `key`. |

A missing or unknown key leaves the line unpriced and raises `matrix_secondary_unresolved`; there is no default row and the resolver never guesses. A stored value of the other kind is treated as no value.

`PricingRuleScope` groups one owner's rules with its `ownerType` and `ownerId`. The resolver canonicalises scope order internally — for a schedule charge, line-scope rules win over revision-scope rules; for a rate-option charge, exactly one rate-option scope is required.

The returned `RuleResolution` carries:

| Field | Notes |
| - | - |
| `measuredQuantity` / `orderedQuantity` / `chargedQuantity` | Three-stage quantity after conversion and minimum-quantity floor. |
| `amount` / `amountCents` | Final dollar amount and integer cents. |
| `unitPrice` | Effective post-modifier unit price back-derived from the rounded amount. `null` when charged quantity is zero and the base is not `fixed`. |
| `baseRuleKind` | The kind of rule that produced the base amount. |
| `resolvedBandKey` / `automaticBandKey` | Band keys used and the band the quantity would have selected automatically. Both are `null` when a `unitPriceOverride` supersedes the base rule. |
| `appliedBandLabel` / `automaticBandLabel` | Human-readable labels derived from band bounds; never persisted. |
| `minimumQuantityApplied` / `minimumAmountApplied` | Whether a floor was triggered. |
| `projectOverrideApplied` | `true` when `unitPriceOverride` was used. |
| `modifierLabels` | Display labels from applied modifier rules. |
| `captions` | Ready-to-display strings summarising blended rates, minimum applications, and modifiers. |
| `exceptions` | Structured diagnostics — see below. |

**Exceptions.** `RuleResolution.exceptions` is an array of `PricingRuleResolutionException` items. Each has a `kind`:

| Kind | When raised |
| - | - |
| `matrix_secondary_unresolved` | The rate table side had no usable input or choice, and no cell is pinned. The result is unpriced. |
| `condition_token_miss` | The charge condition does not match any modifier in scope; the held tokens are listed. |
| `minimum_over_override` | A quantity minimum applied on top of a project rate override, increasing the amount above what the override alone would have charged. |
| `band_override` | A manual `bandOverrideKey` selected a different band from the automatic one; the delta amount is included. A step pick (`s:<key>`) never raises it. |
| `step_not_picked` | A stepped base has no valid pick. The result is unpriced. |
| `step_retired` | The picked step is retired. It still prices. |

**Constraints:**

* `bandOverrideKey` and `unitPriceOverride` cannot be combined.
* A `fixed` base cannot carry a quantity minimum (throws at runtime; `validatePricingRuleSet` rejects at the write boundary).
* A `fixed` base cannot carry an additive modifier (same guards).
* A `stepped` base rejects a `unitPriceOverride` with a `RangeError`: a project rate cannot replace a picked step.
* A `matrix` base accepts a `unitPriceOverride`, which supersedes the table and prices the line with no row chosen.
* A `stepped` base cannot carry a minimum or a modifier. `validatePricingRuleSet` rejects the set ("A rate priced by steps can't also have a minimum or a loading. Please remove it.").

### `validatePricingRuleSet`

`validatePricingRuleSet(ownerType, input)` validates and normalises an owner's complete rule set at the write boundary. It returns `PricingRuleSetValidation`:

```ts theme={null}
type PricingRuleSetValidation =
  | { ok: true; rules: ConditionedPricingRule[] }
  | { ok: false; errors: PricingRuleValidationError[] };
```

A failed result returns every error in one response (`{ path, message }[]`), so the API can report all bad fields in a single 422 rather than one at a time.

Validation covers:

* Per-rule field shapes and value constraints (magnitude, decimal scale, enum membership).
* Slot/kind compatibility (e.g. `minimum` belongs only on `minimum_quantity` or `minimum_amount`).
* Cross-row rules: no duplicate slot + condition pair; `fixed` base incompatibilities.
* Owner-type restrictions (banded rules, `cost_plus`, `stepped`, `minimum_quantity` slot).
* Rate table and stepped shapes, with estimator-worded messages (for example "Please add at least one step, like 0–20 km.").
* Condition token format (normalised after trim + lowercase).

On success the returned `rules` array carries normalised condition tokens (and trimmed step labels). The resolver consumes this output directly.

### Stepped base rules

A `stepped` base (pricing 2.6.0, domain 3.13.0) holds user-named steps, each a lump sum charged once whatever the quantity. The estimator picks one step on the line. There is no default step, and the option's regular cost never applies.

| `PricingRuleStep` field | Rules |
| - | - |
| `key` | Stable identity, stored in the line's pick. Letters, digits, `-` and `_`, up to 64 characters, starting with a letter or digit. Unique within the rule. |
| `label` | The name the estimator sees, such as `0–20 km` or `Zone B`. Stored trimmed, up to 80 characters. Labels of steps that are not retired must differ (case-insensitive). |
| `amount` | Lump sum in dollars, at least 0, to the cent. |
| `retired` | `true` when the step is no longer offered. A retired step still prices a line that already picked it and raises `step_retired`; steps are retired, never deleted. |

A rule holds at most 50 steps, and at least one must not be retired.

* **Pick format.** A line stores its pick as `bandOverrideKey: "s:<key>"`. `stepBandKey(key)` builds it and `stepKeyFromBandKey(bandKey)` reads it back.
* **Unpriced result.** `resolvePricingRule` throws `StepNotPickedError` (code `step_not_picked`) when nothing valid is picked. Callers that can show an unpriced line use `resolvePricingRuleOutcome`, which returns `{ priced: false, amount: null, amountCents: null, unitPrice: null, ... }` with the quantities still resolved. Narrow on `priced` before reading money.
* **`requiresManualPick(rules)`** is true when the base must be picked on the line: a `stepped` base, or (since pricing 2.7.0) a `matrix` base.
* **Helpers.** `pickableSteps(rule)` returns the steps that are not retired, in stored order. `steppedAmountRange(rule)` returns `{ min, max }` over those steps, or `null` when there are none. `findSteppedBaseRule(rules)` returns the stepped base from a rule set or snapshot envelope, or `null`.

**Adoption.** api-rates, api-estimate, feature-rates and feature-estimate are all on pricing 2.7.0. api-rates stores stepped rules on rate options (migration 0064; see [Pricing rules service](https://help.stralos.app/internal/pricing-rules-service.md#must-pick-options)). api-estimate prices a stepped line from its pick, never selects a stepped option automatically, and writes **Step needed** in the export's money cells for an unpicked line. feature-estimate shows a **Step** picker on the line. feature-rates offers **Priced by step** in the rate option editor only while the `stepped-rates` PostHog flag is on (default off); an option that is already stepped stays editable with the flag off.

### Rate table must-pick

Since pricing 2.7.0 (domain 3.14.0) a `matrix` base is must-pick, like a stepped base. It has no default row, the option's regular cost never applies, and a line is unpriced until the table has a cell:

* **Ranges side**: priced once `secondaryInputs[secondary.key]` carries a number.
* **Named side**: priced once `secondaryChoices[secondary.key]` names a row key. An unknown key counts as no choice.
* **Cell pin**: a `bandOverrideKey` of `m:<row>,<column>` prices the line on its own. With no side value the pin is the line's pick and raises no `band_override`; beside a side value it overrides the cell that value selects.
* **Project rate**: a `unitPriceOverride` supersedes the table and prices the line whatever the side.

`resolvePricingRule` throws `RateTableRowNotChosenError` (code `rate_table_row_not_chosen`) when no cell applies; its message reads "Please choose the concrete type for … so it can be priced." (or "enter" for a ranges side). `resolvePricingRuleOutcome` returns the case as `{ priced: false, amount: null, ... }` with a `matrix_secondary_unresolved` exception. Narrow on `priced` before reading money.

`matrixPriceRange(rule)` returns `{ min, max }` over every cell price, or `null` for a table with no cells. It is what lists show for a rate table option now that the representative cost from the default row is retired.

### `RuleSetEnvelope`

`RuleSetEnvelope` is the snapshot stored alongside a price when rules are captured for offline resolution. Its `envelopeVersion` discriminator is currently `1`. Each row in `rules` is a `PricingRuleEnvelopeRow`, which duplicates the `slot`, `condition`, and `ruleKind` discriminators at the outer level alongside the inner `rule` object — the outer and inner discriminators are guaranteed by the type system to agree at compile time.

### Band label derivation

`deriveBandLabel(minQuantity, nextMinQuantity?, unit?, anyNoun?)` derives a plain-language label from stored inclusive lower bounds. Labels are never persisted; they are produced at display time. Since pricing 2.5.0 a band reads by where it starts and where the next band starts:

| Band | Example label |
| - | - |
| First band (starts at 0), more bands follow | `Under 10 m³` |
| Middle band | `10 to under 20 m³` |
| Last band (no next bound) | `20 m³ and over` |
| Only band of a one-band axis (starts at 0, no next bound) | `Any quantity`, or `Any <anyNoun>` when the caller names the side |

The label never closes the band at an inclusive upper value such as `0–9`, because 9.5 belongs to the first band.

`deriveTierBandLabel(rule, bandIndex, unit?)` reads adjacent bands from a `TieredPricingRule`. `describeBandRange(bands, index, { unit, anyNoun })` does the same for any array of lower bounds and throws `RangeError` for an index the axis does not have.

### Rate table sides and helpers

`MatrixSecondary` is a union. A **ranges** side (`MatrixSecondaryRanges`) has `key`, `label`, `unit` and `bands` (inclusive lower bounds from 0); its `kind` is optional because rules stored before named rows omit it. A **named** side (`MatrixSecondaryNamed`) has `kind: "named"`, `key`, `label` and `rows` (`{ key, label }[]`). Both keep an optional `defaultBandIndex` so older rules still parse; the validator range-checks a stored value, but pricing ignores it. api-rates accepts 1 to 12 rows, columns and bands.

A named row's `key` is generated once from its label and kept through renames, because estimate lines store the chosen key. The cell pin key is `m:<rowIndex>,<columnIndex>` for both side kinds.

| Helper | Purpose |
| - | - |
| `isNamedMatrixSecondary(secondary)` | Type guard for a named side. |
| `matrixRowCount(secondary)` | Row count for either side kind. |
| `describeMatrixRow(rule, rowIndex)` | A named row's label, or a ranges row's band label in the side's unit (`40 to under 50 mm`; one band reads `Any cut depth`). |
| `describeMatrixColumn(rule, columnIndex, unit?)` | A column's band label in the charged unit, which the caller passes. |
| `describeMatrixCell(rule, rowIndex, columnIndex, unit?)` | Row label, column label, combined `"<row> · <column>"` label, unit price and `m:` band key. The resolver builds its band labels through this. |
| `lowerCaseLabel(label)` | A label for mid-sentence use (`Cut depth` becomes `cut depth`); a label starting with two capitals, such as `DN size`, keeps its case. |
| `rowKeyFromLabel(label, existingKeys?)` | A key from a typed name (`25 MPa` becomes `25_mpa`), at most 40 characters before a `_2`, `_3` suffix that keeps it unique; `row` when the name has no letters or digits. |

## Subcontract bid amounts

`bidEffectiveAmountCents(bid)` computes `round(quantity × rate)` when both `unit` and `quantity` are present; otherwise `rate` is treated as a lump sum. Validation rules:

* `unit` and `quantity` must be supplied together (both-or-neither).
* `quantity` must be > 0 when present.
* `rate` must be ≥ 0.

A one-sided row (unit without quantity, or quantity without unit) throws rather than silently guessing a lump-sum interpretation.

## Whole-unit stock ordering per run

Lines priced in a stock unit — `length`, `roll_length`, `roll_area`, or `sheet` — order whole units separately for each run rather than rounding the total quantity. Each run represents a discrete start-to-end stretch. An offcut at the end of one run cannot begin the next, so every run is rounded up to the nearest whole unit independently.

**Example:** two 12.5 m runs of 6 m/length pipe order ⌈12.5 / 6⌉ + ⌈12.5 / 6⌉ = 3 + 3 = 6 lengths. Rounding the combined total would give ⌈25 / 6⌉ = 5 lengths and undercount by one.

The runs a quantity is split into are stored beside the quantity in `quantity_runs` on `estimate_work_items` and `estimate_work_item_components` (migration 0078). The column records the quantity it split alongside the run breakdown. A path that writes a new quantity without knowing about runs — a typed edit, an import — degrades the row to one run of the new quantity rather than splitting it by old runs.

**Run sources:**

* A calculator pipe row with N parallel pipes over a trench of L contributes N runs of L.
* A takeoff, area, or volume source contributes one run.
* Each counted rate-item row contributes one run.
* The link multiplier scales every run. A positive link offset contributes one additional run.
* A typed or overridden quantity is one run.

**Pricing scope.** `wholeUnitStockOrdering` in `src/pricing/unitPairConversion.ts` is called before `pairAwareConversionFactor` on every pricing path — frozen recompute, option comparator, re-resolve, reprice, exceptions, insert defaults, rule change, import refresh. It returns either a `{ orderedQuantity, charge }` result that replaces the scalar quantity in the resolver, `{ excluded: "conversion_missing" }` when the line cannot be priced in the option (for example a count measured against a per-length price), or `null` when the option is not a stock unit.

The unit list for stock units is provided by `@stralos-co-nz/api-common/units`. `src/lib/unitCanonical.ts` re-exports `canonicaliseUnit` from that package and adds write-side policies on top.

`measuredRunsForComponent` in `src/pricing/quantityRuns.ts` computes the runs for a single build-up line, following the same arms as `resolveComponentQuantityBasis`:

* A pricing-override quantity is one run.
* A whole-item line uses its own stored runs.
* A per-unit line uses `perUnitBasisRuns` from the domain, spreading the component runs over the work item's runs.
* A resource count k repeats the runs k times via `runsForResourceCount`.

`feature-estimate` mirrors this function as `measuredRunsForComponent` for the live pricing preview.

## Stored unit cost on banded lines

For a build-up line whose frozen base carries its own prices, a `tiered` (quantity breaks) or `matrix` (rate table) base, the stored `unit_cost` on the component is the unit price of the band actually applied, not the option's `cost_rate` or band 0's price. `baseCarriesOwnPrices` in `src/pricing/pricingRuleSnapshot.ts` decides which lines qualify.

The recompute in `src/pricing/recompute.ts` re-syncs `unit_cost` to the frozen resolution's `unitPrice`, so every write that can move the band lands the right figure: a quantity change, a pick, a re-selection or a reprice. It writes only on change. A rate table with no cell resolved stores 0. Two shapes are left alone:

* **Override-tier lines** (`resolvedTier === "override"`): the scalar is the negotiated project rate, which supersedes the bands.
* **`simple`, `fixed` and `stepped` bases**: a simple base's scalar is the resolver's input, so syncing the output back would compound it on every pass; a step is a lump sum with no unit price and stores 0.

For a `progressive` tiered rule, `unitPrice` is the blended rate (amount divided by charged quantity), so unit cost times quantity still equals the line amount.

`buildSelection` and `refreshPinnedSelection` in `src/pricing/repriceLines.ts` store the same figure through `storedBaseScalar`, so a re-resolve preview of a line whose band has not moved shows no change. feature-estimate's selection source sends the applied band's price as the selection's base unit cost for the same reason.


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