@stralos-co-nz/calculators defines the QuantityLinkV1 descriptor, the unit rule, and resolveQuantityLink, the one resolver api-estimate runs to write the stored quantity and feature-estimate runs to preview it. Sharing one function is what stops the preview and the stored figure drifting apart.
api-estimate persists
QuantityLinkV1 descriptors. Work items, build-up lines, and calculator input fields can each carry a takeoff-driven quantity link. See Takeoff-driven links below for the three link targets and their rules. The package supplies shared resolution and validation for both hosts.QuantityLinkV1
Two sources are duplicates when they have the same kind and ids. Ids are compared case-insensitively; a calculator
layerId is compared exactly.
Validation
quantityLinkIssues(link) checks structure only and returns QuantityLinkIssue[] (code, optional source index, message). An empty array means the descriptor is acceptable. It treats its input as untrusted, so a host can run it on a stored value as well as on a new one. Whether the named calculators, measurements, and rate items exist, and whether units match, is the host’s check at write time.
A host validates every link write through quantityLinkTargetIssues(link, target), which calls quantityLinkIssues and then applies the target’s own restrictions. target is "workItem", "component" (build-up line), or "calculatorInput". See Link targets.
Link targets
A host validates each write throughquantityLinkTargetIssues(link, target), which combines structural checks with these target restrictions:
Unit rule
canonicalUnit(unit) is the comparison fold used on both sides. ML is matched case-sensitively as megalitre first. Otherwise the unit is trimmed, lower-cased, ² and ³ become 2 and 3, ^ and full stops are removed, and inner whitespace is collapsed. The result is looked up in an alias table covering the unit spellings used by api-estimate, feature-estimate, and the calculators package, so lm, lin m, and lineal metre fold to metre, and sqm, sq m, and m2 fold to square_metre. An unrecognised unit is returned as the folded token, so Bag and bag compare equal. It is a comparison fold only; it does not change what the hosts store or display.
quantityDimension(unit) gives a unit’s dimension, and outputKeyDimension(outputKey) gives a calculator output’s:
Every other unit, including
kilometre, millimetre, hectare, kilogram, lot, item, lump_sum, and hour, has no dimension and matches nothing, not even another dimensionless unit. Readings are in metres, square metres, cubic metres, and tonnes, so adding them to a kilometre item unconverted would be wrong by a factor of 1000.
- A calculator or takeoff source must have the same dimension as the work item’s unit.
- A rate-item source counts only rows whose canonical unit equals the work item’s canonical unit.
Rate-item rows
A rate-item source counts only whole-item rows: rows whose stored quantity is the job total. A row priced per unit of the work item consumes the linked quantity, so counting it would make the resolver’s input depend on its own output. Rows of other rate items are ignored. Rows of the named rate item that are not counted are returned inexcluded with a reason:
The first four reasons are exported as
wholeItemExclusion(row) and isWholeItemPricedRow(row), which must stay in step with api-estimate’s and feature-estimate’s whole-item rules.
Resolver
resolveQuantityLink is pure and never throws. It is meant to run over stored descriptors during a whole-estimate save, so an invalid descriptor resolves stale with an invalid warning (and no source results) instead of failing an unrelated save.
QuantityLinkInput
QuantityLinkTakeoffInput.byTag is a ReadonlyMap<string, { label: string; quantity: number }> keyed by lower-cased tag id. It is required for a Multi-segment’s tag sources to resolve; any tag source on a measurement without byTag resolves missing. Build it from takeoffTagQuantities(parts, breakdown) (see Takeoff parts). Retired tags remain in byTag (a retired tag is not missing, per Q12).
QuantityLinkResolution
Output order is deterministic (sources in descriptor order, rows in
components order), so a host can compare a new resolution with the stored one and write only on change.
Rules
- Stale, never partial. If any source is not
ok, the whole link is stale:valueandsumarenull. Summing only the sources that resolved would silently under-count. - Whole-item rows only. See Rate-item rows.
- One rounding. The sum (3 dp), multiplier (4 dp), and offset (3 dp) are held as scaled integers. The result is computed at 7 dp and rounded to 3 dp once, half up. For example,
571.650 × 1.05 = 600.2325, which resolves to600.233. - Round up last. A link with a
roundingrounds the rule 3 result up to its step. See Round up to.
Source states
Stale causes (api-estimate)
When api-estimate stores aquantity_link_result or builds a 422 response, it adds a cause field to every non-ok source that gives a finer machine code than state. The reason string that accompanies it uses display units (for example, m² rather than square_metre) so the message is ready to show beside the source in the UI.
Warnings
api-estimate behaviour
Write-time 422 responses
When a link is written and a source fails the write-time check, the 422 body includessources[]. Each entry has index, label, state, and, when api-estimate has assigned one, cause and reason. The reason string always speaks display units — m² not square_metre — so the client can show it directly beside the failing source.
The two link-specific 422 codes are:
Calculator-owned row units
A build-up row fed by a project calculator (itsgeneratedKey starts with calc:) has its unit set and reset by the calculator on every save. Changing the unit on such a row via PUT /work-item-components/:id or via the pricing-rule unit move returns:
- Status:
422 Unprocessable Entity - Code:
component_unit_calculator_owned - Message: names the calculator and the layer that owns the unit, e.g. “The unit of this row is set by Trench A · Layer 2. Choose another output in the calculator, or unlink the row from the calculator to set its unit yourself.”
- Body:
{ origin: { calculatorId, calculatorName, layerId, layerLabel } }
m3 → cubic_metre) passes without error. Use POST /work-item-components/:id/unlink-calculator to detach the row from the calculator and take ownership of its unit.
Quantity-link candidates: row origin
GET /estimates/:estimateId/work-items/:itemId/quantity-link/candidates returns rateItems[]. Each eligible and excluded row now carries an origin field that describes where the row’s quantity comes from:
Round up to
From 2.1.0,QuantityLinkV1.rounding takes a QuantityRoundingOption (QUANTITY_ROUNDING_OPTIONS). The step is applied to Σ × multiplier + offset, always up; a value already on a step, zero, and a negative are unchanged. Absent or "none" resolves exactly as a pre-2.1.0 link.
When quantityLinkRuns splits a rounded link into per-run figures, the rounding uplift (value − unrounded) is added to the last run, so the runs still sum to the rounded quantity.
See Estimate rounding for the option list, labels, suggestQuantityRounding, and how api-estimate applies it.
Worked examples
The specification’s worked examples are published on a separate subpath so fixture data never reaches a consumer bundle through the main entry point:QuantityLinkWorkedExample objects (id, title, link, input, expected) with new maps and arrays. Every expected value was calculated by hand from the specification, not captured from the resolver, so hosts can use them as parity tests. From 2.1.0 the set includes four rounding cases: round-1, round-ends-in, round-after-multiplier, and round-on-step.
Constants
Build-up line quantity links
A work item component (build-up line) can carry its ownquantity_link on estimate_work_item_components. The descriptor is the same QuantityLinkV1 shape, but the allowed source kinds are restricted:
calculatorandtakeoffsources are accepted.rateItemsources are not accepted. A line reading a rate item’s rows could end up reading itself, so that kind is excluded entirely.
Eligibility
Not every build-up line can carry a link. A line is eligible when it meets both conditions:- It is a whole-item priced row (
isWholeItemPricedRow: not per-unit, not in an activity, not a copied activity child, not a container). A per-unit row consumes the work item’s quantity, so feeding it a total would double-count. - Its
generatedKeyisnull. A calculator-generated or activity-generated row is rewritten by its owner on every save; the owner would overwrite the linked value.
GET /estimates/:estimateId/components/:componentId/quantity-link/candidates returns:
Resolution timing
Build-up line links are resolved at the head of everyrecomputeEstimateTotals run, before the work-item pre-pass reads the lines. This means a calculator save, reconcile, or delete moves the line’s quantity in the same request — not only when takeoff is written.
Frozen estimates never re-resolve (SPEC D14). A stale source keeps the last stored quantity rather than zeroing it.
Calculator deletion and source-removing saves
When a calculator is deleted with?unlink=true, or when PUT /calculators/:id would stop emitting a layer that a build-up line’s link names, the response includes unlinkedComponents alongside unlinked:
409 calculator_in_use and 409 calculator_source_removed usage bodies also include a components array for build-up lines that name the calculator, alongside the existing workItems array.
linkedUseCount on the calculator response
The linkedUseCount field returned by GET /estimates/:id/calculators counts how many live work items and build-up lines have a quantity_link naming the calculator. A work item or build-up line that reads several of the calculator’s readings still counts as one. This count drives the “ƒ n uses” indicator in the Calculations list.
Takeoff-driven links
Three targets in an estimate can carry a takeoff-driven link. All three are updated automatically by the takeoff-driven pass, which runs before estimate totals are recomputed. Frozen estimates are never re-resolved.Work-item quantity link
estimate_work_items.quantity_link stores a QuantityLinkV1 descriptor. The server resolves the linked quantity and writes it into the item’s quantity field. The takeoff source may be a measurement total or a Multi-segment or tagged Multi-line subtotal.
PUT /estimates/:id/work-items/:itemId accepts quantityLink (a QuantityLinkV1 or null). Passing null unlinks and keeps the current quantity. A typed quantity that differs from the stored linked quantity is 422 quantity_linked unless it equals the stored value (equal resend passes).
Build-up line quantity links
Build-up lines accept calculator readings and takeoff totals or tag subtotals. See Build-up line quantity links for eligibility and candidate endpoints.Calculator input links
estimate_calculators.input_links stores a CalculatorInputLinksV1 ({v:1, fields}) where each field maps to a QuantityLinkV1 of takeoff sources only: a measurement total, or one or more tag subtotals of a Multi-segment or tagged Multi-line. Tag subtotals are in metres, so only a length field can take one. Supported fields:
PUT /estimates/:estimateId/calculators/:id accepts inputLinks. The resolved value is written directly into config so the engine and reconcile run unchanged. Errors:
422 calculator_input_link_kind— a non-takeoff source kind.422 quantity_link_unit: a source in another dimension from the field, for example a tag subtotal onvolume.areaM2.422 quantity_link_takeoff_overlap: a measurement’s total and one of its own tag subtotals appear in the same field’s link.422 calculator_input_field_inapplicable— the field does not apply to the calculator’s type or configured mode.422 calculator_input_linked— a typed config value differs from the linked field’s resolved value.409 calculator_source_removed— a config change (for example, a volume mode switch) would orphan a linked field. Pass?unlink=trueto unlink that field and keep its value.
GET /estimates/:estimateId/calculators/:calculatorId/input-link/candidates?field=<field> returns field, unit, applies, and takeoffs. takeoffs lists live measurements in the field’s dimension, each with its byTag tag subtotals where it has them, so an area field never sees a tag subtotal.
Takeoff usage
GET /estimates/:estimateId/takeoff-measurements/usage returns every live measurement’s total and each of its tags, with all three user types listed under each source. Works on frozen estimates.
When a measurement PUT would remove a linked source, the server returns 409 takeoff_source_removed with usage and removed: {total, tags} before writing anything. Pass ?unlink=true to apply the edit and clear affected links atomically. From calculators 1.16.0, only a tag loses its source, when its last span or line is deleted or retagged, so removed.total is always false. Deleting a span or line that leaves its tag elsewhere, a move, an insert, or a non-last retag re-resolves every linked quantity instead (work items, build-up lines, and calculator inputs).
DELETE /estimates/:estimateId/takeoff-measurements/:id and DELETE /estimates/:estimateId/takeoff-map-sheets/:sheetId both return 409 measurement_in_use when any work item, build-up line, or calculator field links to the measurement, listing workItems[], components[], and calculatorFields[]. Pass ?unlink=true to unlink every user and complete the delete in one request. The 200 response includes unlinkedComponents and unlinkedCalculatorFields alongside the existing unlinkedWorkItems.
