scope, supplierId, locationId, and cost.
Base prefix
Owns
- Estimates and sections.
- Estimate document references.
- Takeoff measurements and map sheets.
- Work items, components, pricing snapshots, and TAG links.
- Subcontractor packages and bid review.
- Programme items and dependencies.
- TAGs/tender clauses.
- Import jobs.
- Contract baselines, variations, lines, revisions, events, and assessment runs.
Endpoint groups
Adding build-up lines
Resources (labour, plant, materials, subcontract) go inside an activity. Every new build-up line sits inside an activity of a work item.- Add a line to an activity that already exists: use
POST /work-item-components/:id/children, where:idis the activity. The line’squantityBasissets how it is charged inside the activity, for examplewhole_activity(its quantity once) orper_allocated(per unit of the activity’s quantity). - Add several lines together: create one activity with
POST /work-items/:id/manual-activity, then add each line withPOST /work-item-components/:id/children. - Add one line on its own: a line sent to
POST /work-item-componentshas no parent, so the server puts it in a new activity named after it, in the same transaction. A line in hours, days, weeks or months sets the activity’s duration. Any other line goes into a one-day activity, chargedwhole_activity. The201response is the line, whoseparentComponentIdis the new activity, plusactivityContainer, the new activity.
wrapInActivity can be left out or sent as true. On POST /work-item-components, these return 422:
POST /work-items/:id/day-rate-lines follows the same rule. With parentComponentId, the line goes inside that activity. Without one, a Flat line priced per hour, day, week or month goes into a time container, and any other line (a Fixed line, or a non-time Flat line) goes into a new one-day activity named after it. The response’s container is the new activity. At the top level, wrapInActivity: false returns 422 line_needs_activity and a quantityBasis returns 422 wrap_quantity_basis_not_allowed.
POST /work-items/:id/components/:componentId/move moves a line to another place in the same work item’s build-up. A line can’t move out of every activity (422 move_needs_activity). A line whose quantity is linked to a takeoff or calculator can’t move into an activity (422 move_linked_line_into_activity): unlink its quantity first, then move it.
Some older lines can still sit outside an activity, where a line has no equal inside one (for example, a line with a delivery link). They keep their price.
Onboarding summary
Cache-Control: no-store.
Response
200 OKError responses
Retired routes
The following retired routes return404. Use the active routes above for current integrations.
The
POST, PUT and DELETE routes for sections, takeoff measurements, map sheets, work item components, subcontract packages, programme items, programme dependencies and tender clauses are unchanged.
Retired estimate field
projectScope (column estimates.project_scope) is retired. POST /estimates and PUT /estimates/:id no longer accept it, and estimate reads no longer return it. A body that still sends projectScope has it stripped rather than refused. A saved estimate version that still carries the key restores normally.
Resolve estimate access scope
Response
200 OK
The response carries
Cache-Control: no-store. Project membership is resolved fresh on each call; do not cache the result.
Error responses
Portfolio values
Query parameters
Response
200 OKitems corresponds to one matched estimate in the caller’s organisation:
Projects with no estimate in the caller’s organisation, or estimates whose totals are not readable, are omitted from
items. The response always returns 200; an empty items array means none of the requested projects matched.
Error responses
Calculator substance usage
deleted_at set) are excluded from the count. The response covers all three calculator types: trench, pit, and volume, which all store their strata under existingLayers in the calculator config.
Authentication: requires a valid bearer token with a resolved organisation context.
Response
200 OKitems array is sorted by count descending, then by substanceId ascending as a tie-break. Only substances with a count of at least one appear.
Error responses
Section deletion
204 No Content. The estimate must be editable (draft or review). On a frozen estimate (submitted, accepted, rejected, or archived) the delete returns 409 estimate_frozen with the estimate’s status, and the section is kept, because the delete recomputes the estimate’s totals.
The operation acquires the estimate-totals advisory lock before the delete so the lock is held across both the stamp and the recompute.
The delete is soft: the section is stamped deletedAt and leaves every list. Its work items are moved to the Unsectioned pool in the same transaction (the route clears their section_id explicitly, as the foreign key’s ON DELETE SET NULL did while the delete was hard). A later restore would not move them back.
Estimate totals are recomputed unconditionally after deletion. This covers both ordinary sections and the Shared Costs section, which has additional side effects:
- Deleting an ordinary section moves its rows to Unsectioned and updates the estimate totals to reflect the new membership.
- Deleting the Shared Costs section removes that section’s rows from the pool, clears every persisted
spreadableCostshare that was distributed to other sections, and recomputes totals so the formerly pooled rows are counted normally.
Stored rates and totals
Every work item stores its sell rate to the cent, and its price is alwaysround2(quantity × unitRate) in exact decimal arithmetic. Each section stores its subtotalPrice, and the estimate stores its totals. Clients read these figures rather than summing rows.
Sections response envelope
GET /estimates/:id/sections returns the standard list envelope (items, totalCount, start, length) plus the estimate’s stored totals as top-level fields, so a client that re-reads only the sections after an edit never shows a stale total. The read stores any missing item rates and totals before it answers.
null means the estimate has no stored totals yet.
Work item unitRate
Work item reads carry unitRate, the stored sell rate to 2 decimal places. It is null for provisional sums, rows with a quantity of zero or less, and frozen rows whose stored price no cent rate reproduces. Use unitRate when it is non-null; derive a display rate from totalPrice ÷ quantity only when it is null.
When sell rate rounding moved the rate, unitRateUnrounded holds the cent rate before rounding; otherwise it is null. See Sell rate rounding.
Shared Costs
isSpreadable, titled Shared Costs, numbered S, and seeded with a Shared Costs item. The section receives an end-of-list sortOrder so existing SOQ numbering is not disturbed, while estimate reads surface it first.
The endpoint is idempotent:
201 Createdcreates the section and placeholder item.200 OKreturns the existing section without adding another item.
{ section, workItem, created }. workItem is the seeded row on creation and null when the pool already existed.
During recompute, the pool’s rolled cost and sell totals are apportioned independently among non-pool top-level rows. Both apportionments use each recipient’s rolled non-fixed cost as the weight: fixed-price portions and provisional sums do not receive a share, while an open portion of a partly fixed row does. Largest-remainder allocation reconciles each set of integer-cent shares exactly to its own pool total. The cost share is added to totalCost and recorded in spreadableCost; the independently calculated sell share is added to totalPrice, so the two shares can differ when the pool carries margin.
GET /estimates/:id/work-items includes spreadablePrice on each work item row: the row’s Shared Costs sell share. It is already inside totalPrice, exactly as spreadableCost (the cost share) is already inside totalCost. The value is 0 when the row takes no share, and null when the estimate’s rows predate a stored spread base. The field is omitted entirely on estimates with no Shared Costs pool.
Distribution runs when either the pool cost or pool sell total is positive, the non-fixed recipient base is positive, and at least one recipient exists. This includes a zero-cost pool with a positive sell total. Once distributed, pool rows remain visible but their section contributes zero to estimate totals because their cost and sell have moved into the recipients. If there is no valid base or recipient, the pool remains an ordinary section for that pass and its rows continue to count. Sub-items are never separate recipients; their non-fixed cost rolls into the top-level parent.
Add further pool rows through the normal work-item routes using the returned section ID.
Plant hire prices
Query parameters
Price resolution
The endpoint uses the project’s current price-selection order, restricted to supplier options. Internal (own-fleet) options are excluded before selection. A supplier project override wins first. The project’s rate rules and pricing location then form one ordered preference list; the first entry with an eligible supplier option wins. A broad rule placed before a specific rule can win, and the pricing location can win before a rule when it comes first. If no preference yields an option, the project’s office location and then the deterministic lowest-amount supplier option are tried. The day and hour quotes are resolved independently. A day option does not derive hours from the day rate, and a week option is not converted to days. Only options whose unit spelling maps to the target time basis are candidates for that quote. Accepted unit spellings:- Day basis:
day,days,d - Hour basis:
hour,hours,hr,hrs,h
Response
200 OK
Each quote object contains:
Error responses
Work item export
Download the export workbook
.xlsx workbook for the estimate’s work items. The response is a binary application/vnd.openxmlformats-officedocument.spreadsheetml.sheet file with Content-Disposition: attachment and Cache-Control: private, no-store.
Authentication: requires a valid bearer token with a resolved organisation context.
Request body
level: "build_up_resources" behaviour
Each item row is followed by one row per resource in its own build-up, in the build-up grid’s order. Subcontract mirrors are excluded. Activity containers are not rows; each resource carries its nearest enclosing activity as columns (activity path, instances, allocated quantity, allocation unit, production rate, and duration in days).
Resource rows carry separate columns (build-up group, resource count, usage, charged quantity, rate unit, resource unit cost, resource cost rate, resource total cost) plus the pricing engine’s per-line markup or margin percentage, sell rate, and total sell. Every item money and quantity column is left empty on resource rows so no item total is counted twice.
A line priced by a rate table with no row chosen, or by a stepped rate with no step picked, has no price. Its money cells hold the text Row needed or Step needed instead of an amount, and it contributes nothing to the totals.
Two item-level columns are added in this mode:
shared_costs_sell_share: the item’s share of the Shared Costs pool’s sell.item_price_adjustment: the item’s price before Shared Costs less its resource line sells. Captures allowance amounts, fixed-package sells, and rounding so the reconciliation closes.
Error responses
Build-up level preview
build_up_resources level workbook would write, as JSON, without building the file. Use this to show a preview of the build-up level on the export page without a client-side copy of the row builder.
Applies the same 10,000-row cap check as the export route. Refused with 422 when the cap is exceeded.
Authentication: requires a valid bearer token with a resolved organisation context.
Response: 200 OK
The response carries
Cache-Control: private, no-store.
Error responses
Tender submission PDF
"submission" workbook. The response is application/pdf with Content-Disposition: attachment; filename="<project name> - Schedule.pdf" and Cache-Control: private, no-store.
The PDF carries a title block with the project, estimate, revision, client, site, reference, and date; the section summary and headline totals; the schedule grouped by section, with GST and the total including GST; and the departures and clarifications. Every figure comes from the estimate’s stored totals. A spread Shared Costs pool is not listed, because each item’s amount already includes its share. The PDF uses the company’s brand colour and logo, falling back to the Stralos look, and the unit catalogue’s display symbols.
Authentication: requires a valid bearer token with a resolved organisation context.
Request body
Limits. The PDF is refused with
422 before drawing when it would exceed 4,000 rows (items, breakdown lines, section headings and subtotals, and departures and clarifications), and after drawing when it exceeds 4 MB. Both messages suggest exporting the XLSX. The 4 MB message also suggests turning off the cost breakdown, and the row message does so when the breakdown is on. A terms PDF appended by the tender download (at most 10 MB) is not counted toward the 4 MB.
Error responses
Tender Word file
application/vnd.openxmlformats-officedocument.wordprocessingml.document, with Cache-Control: private, no-store. It takes the PDF’s body, except that sections is required (400 without it): the Word file exists only as a tender download.
It draws the same blocks as the PDF, in the same order and from the same model and tender layout. Block headings are Word Heading 1, the project name is the Title, and the schedule, summary, documents, and totals are Word tables whose header rows repeat. Page numbers are Word fields. Inter, and Sora when the layout uses it, are embedded. A terms PDF can’t go inside a Word file, so the terms block says the terms are a separate PDF.
Limits. Refused with 422 before drawing above 4,000 rows, and after drawing above 4 MB.
Error responses are those of the tender submission PDF, plus 400 when sections is absent.
Tender downloads, names, and stored copies
A request withsections to the "submission" workbook, the PDF, or the Word route is a tender download:
- The file is named
<project> - Tender - Rev Nwith its extension, with the estimate’s name after the project for an alternative estimate, and- No priceswhen prices are left out. A draft carries the revision it will become, stamped with today’s date; a submitted revision carries its own number and submission date. - On a tender frozen at its latest revision, the response is that revision’s stored as-submitted file (
X-Tender-Copy: stored), or one drawn from its submitted figures when none was stored (X-Tender-Copy: live). A request withleavePricesOutis drawn fresh. A draft answersX-Tender-Copy: draft. - Every tender download is logged.
Tender download sections
The"submission" workbook, the submission PDF, and the tender Word file take sections and documentIds, read and checked the same way. Without sections, the workbook and the PDF are what they were before the tender download.
sections is an object of six booleans, all required: projectScope, summary, schedule, documentsReceived, departures, terms. The ticked sections are written in that fixed order. The workbook writes a sheet for each one; the cost breakdown is written only when schedule is ticked.
- Project scope: the project’s rich-text
scopefrom the Global API. A ticked scope that is empty is skipped, unless it is the only tick. - Documents received: the
documentIdsthe project holds, read from the Documents API, by title (else file name), folder, and received date in the project’s time zone. IDs the project does not hold are ignored. - Terms and conditions: the company’s terms from
GET /api/global/organisation-terms. Typed terms print as their own section. A terms PDF is appended after the PDF’s last page, and the section says the terms are attached; the workbook and the Word file say they are attached as a separate PDF.
Work item name suggestions
search
value is matched as literal text, so %, _, and \ are ordinary characters.
When more than one work item shares the same normalised name, the most recently
updated occurrence is the exemplar — its stored spelling and project are what
the response returns.
Query parameters
Response
200 OK — a list envelope{ items, totalCount, start, length }.
Each items entry contains:
totalCount is the count of distinct normalised names matching the query, not the count of raw matching rows. When limit is lower than totalCount, the response carries the truncated set and the full count so callers can render a “Showing N of M” message.
The response does not include project or client names. Those live in the Global service; join the returned projectId against the project register the caller already holds.
With facets=projects, the response also carries projects: one { projectId, count } entry per project with live work items in the months window, where count is that project’s number of distinct normalised names. The facet ignores search and projectIds, so a client filter built from it lists every project with work in the period. The Reuse a past build-up sheet groups these projects by client for its Clients filter and sends the chosen clients’ projects as projectIds.
Error responses
Re-resolve prices
- Dry run (
dryRun: true) — returns the per-component analysis without writing any changes and includes apreviewHashthat can be used in the confirmed mode. - Confirmed apply (
dryRun: false,previewHash,resolutionInstant) — hash-gated atomic apply. The hash echoes the one returned from a prior dry run, binding every resolved input; the server rejects the write if the preview has changed since the dry run.
Eligibility (D12 candidates)
A component is eligible for re-resolution when all of the following are true:- It has a live
rateItemId(not a generated or mirror row). - Its pricing provenance is
auto, or it is a pinned or legacy component explicitly listed inconvertToAuto.
companionArrival: "ticked") cannot be re-resolved directly. They are reviewed through their parent component. Attempting to include one in convertToAuto returns 409 with code managed_companion_requires_parent.
Request body
The request body is a discriminated union: Dry-run mode (dryRun: true)
Confirmed apply mode (
dryRun: false with hash)
Price resolution
A component carrying a pinnedrateOptionId is excluded before automatic selection. For other components, project-scoped override options are considered first. The project’s rate rules and pricing location form one ordered preference list, using each entry’s position; the first matching entry with an eligible option wins. List position takes precedence over rule specificity. An earlier rate-group rule can beat a later item rule, and the pricing location can beat a rule when placed earlier. If no preference produces an option, the project’s office location and then the deterministic lowest-amount candidate are tried.
A rule that covers the line but matches no candidate records resolvedRuleOutcome as unsatisfiable or policy_unmet, and selection continues to the next entry. Within a matching entry, the candidate with the lowest resolved line amount at the line’s quantity wins, with option id ascending as the tie-break. A display rate alone does not account for bands, matrix, floors, or loadings.
source rules — All-locations targets match only location-less prices.
A source rule with locationId: null (an All-locations target) matches only the owner’s prices that carry no locationId. It never matches a place-specific price from that owner, even a cheaper one. If the owner has no all-locations price for the item, the rule records resolvedRuleOutcome as unsatisfiable and selection continues to the next entry. A source rule with a specific locationId is unchanged.
Response
200 OK
Each
items entry contains:
Apply behaviour
WhendryRun is false, the server:
- Runs the fan-out read (project rate context, office location, project rate rules, and rate option alternatives) before opening a transaction.
- Opens the estimate advisory lock.
- In confirmed mode, computes the preview hash and compares it with the supplied
previewHash. If they differ, returns409with codestale_re_resolve_previewand a freshpreviewpayload in the error body. - Verifies under the estimate lock that the estimate, pricing rows, work items, and related component rows still match the fan-out snapshot. A difference aborts the whole write and returns
409 stale_re_resolve_previewwith a fresh preview. - Writes the previewed pricing changes atomically. A line that moves onto the mirror of its current option (same project, supplier, yard, and condition, confirmed by
mirrorsOptionId) is not treated as a parent change; its ticked fixed-charge companions are preserved. - Removes managed fixed-charge companions listed in
managedTickRemovals. - Never writes a quantity override on recipe-derived child rows.
- Recomputes estimate totals once after all writes.
Error responses
Rule change preview and apply
submitted, accepted, rejected, or archived) returns 409 estimate_frozen on both routes. Variation lines are never read or modified by either route.
Rule change preview
Each entry in
entries is one of two shapes:
Location entry (kind: "location") — represents the pricing-location preference:
Rule entry (
kind: "rule") — a discriminated union keyed on ruleKind:
Entry order is position order: the first entry is position 1, the second is position 2, and so on. A live rule whose
id is absent from the entries is deleted. A rule entry without an id creates a new rule.
Response — 200 OK
The same response shape is returned by the rule change apply endpoint on success. The applied flag distinguishes the two.
Each entry in
preferences contains:
When
detail is "lines", groups is an array sorted by rate group:
Each entry in groups contains:
Each entry in
items contains:
Each entry in
lines contains:
Error responses
Rule change apply
PUT /project-rate-settings/rule-set in the Rates API has committed the new rule set.
Request body
Apply behaviour
The server:
- Re-plans under the rules currently stored in the Rates API.
- Computes a fresh digest and compares it with the supplied
impactDigest. If they differ, returns409 stale_rule_change_previewwith a fresh preview. Nothing is written. - Validates the
apply,pin, andunpinselections against the plan. Every re-priced line must be in exactly one ofapplyorpin. - Opens the estimate advisory lock.
- Writes all resolutions, pins, and unpins in one transaction, together with re-stamped lines and managed companion removals.
- Recomputes estimate totals.
- Recounts the rate exception badge from the new state.
applied: true and populated groups. The response does not include a detail field.
Error responses
Rate exceptions
Rate exceptions identify components where the current pricing selection or frozen pricing-rule outcome needs review. The server computes them on demand by running a counterfactual resolution pass against today’s pricing context and comparing it with what is stored.List rate exceptions
200 OK
Each exception row contains:
Exception kinds
Action tokens
Count rate exceptions
200 OK
The endpoint reads the persisted badge count and checks whether the digest of the current inputs still matches. When a peer service is unavailable during the stale check,
stale is set to true rather than returning a false “fresh” status.
Accept a rate exception
403.
Accepting an exception records a suppression and refreshes the badge count. It does not repin a rate option or recompute totals, but a frozen estimate is read-only, so the acceptance is refused on one.
The acceptance is keyed on (componentId, kind). Accepting a later exception for the same key replaces the previous acceptance.
Request body
Response —
200 OK
Error codes
Delete a rate exception acceptance
(componentId, kind) pair, making the exception visible again. Invalidates the persisted badge count so the next panel open recomputes it.
Request body
Response —
204 No Content
Error codes
Apply rule to a component
rule_changed exception whose old rule no longer covers the line, it follows the ordinary resolution ladder. The new selection is written with provenance auto.
Request body
Response —
200 OK — the updated component and pricing override:
Error codes
Choose an option for a component
pinned.
Request body
Response —
200 OK — same shape as apply-rule response.
Error codes
Unpin a component
auto, allowing the next re-resolve pass to apply the project rule or default selection. Returns 422 if no eligible option is available after unpinning.
Request body — empty object {}
Response — 200 OK — same shape as apply-rule response.
Error codes
Clear a band override
Response —
200 OK — same shape as the apply-rule response.
Error codes
Project rate links
These routes manage the relationship between work items in an estimate and the project’s rate catalogue. They cover three distinct operations:- Attaching a project rate option to a work item as a candidate (before it is priced).
- Inserting a project rate into one or more work items as a priced component.
- Reading which project rate options are used in an estimate and where.
409 estimate_frozen when the estimate status is submitted, accepted, rejected, or archived.
Get project rates for a work item
attached— rate options attached to this work item but not yet inserted as priced components.costed— priced component lines on this work item that are priced from the project’s rates.
Each
attached entry:
Each
costed entry:
Attach a project rate to a work item
409, so a document import that records the same work item twice is idempotent.
Request body
Response
201 Created— the attachment was created.200 OK— an existing attachment for the same option was updated.
attached array entries above).
Error codes
Delete a project rate attachment
204 No Content. The estimate must not be frozen.
Error codes
Get project rate usage
Each
options entry:
Insert a project rate into work items
attachmentIds to delete additional attachments from this rate item on the targeted work items.
The insert validates every target before writing. If any target fails (e.g. the item is already priced from this project’s rates), the entire operation is refused and nothing is written.
After a successful insert, Stralos recomputes the rate exception count for the estimate.
Every inserted line sits inside an activity. A target with parentComponentId (an activity on that work item, library activities included) adds the line at the end of that activity; only total is accepted there. In an activity with daily production the line is charged whole_activity; otherwise it is stored per unit of the item so its total is the typed quantity. A target without one puts the line in a new activity named after the rate, so the line’s money is what the caller previewed. Each inserted entry also carries parentComponentId and, when the insert created one, activityContainer.
Request body
Response — 201 Created
Error codes
Automatic re-price
api-rates after a project rate write. Rule-list changes use the separate rule-change preview and apply flow. It is not normally called directly by client applications.
After the re-price commits, Stralos recomputes the rate exception count even when no line moved — a new quote can create an exception without moving any existing line.
Query parameters
Request body
Response — 200 OK
Error codes
Subcontract bid review
selectable flag and per-rate warnings to guide rate selection.
The response is computed under the estimate-totals advisory lock and covers the package, its associated work items, and all bid rates received for those items.
Authentication: requires a valid bearer token scoped to the package’s organisation. An unknown package returns 404 without leaking cross-tenant information.
Response
200 OK
Each
workItems entry contains:
Each
bidRates entry contains:
Warning tokens
A bid rate is selectable only when:
amount is not null, internalCost is not null, hasPackagePricing is false, basisMatches is true, the rate is the only bid for its work item, the package response has not been declined, and the package status is one of draft, issued, or estimates_received.
Error responses
Contractor portal capability tokens
The token inGET /subcontract-portals/:token is a bearer capability. Treat it as a
credential: do not log it, place it in analytics payloads, or include it in support
messages.
An unknown capability returns 404 with the generic message
Subcontract portal not found. The API deliberately does not echo the supplied token
in that response.
All three public portal routes are unauthenticated. They carry security: [] in the OpenAPI document. The organisation is resolved server-side from the capability token using a SECURITY DEFINER function; no bearer token is accepted or required.
The GET /subcontract-portals/:token read endpoint remains accessible regardless of estimate status. A contractor can open their portal and read back their submitted rates even after the tender has been submitted. The two write endpoints (PUT .../rates/:workItemId and POST .../response) return 409 estimate_frozen when the estimate has been submitted.
Save a portal bid rate
422.
Saving a rate does not select it as the active pricing source. The estimator makes that selection explicitly in the estimate workspace.
Request body
Response — 200 OK
The saved bid rate record, with internal fields (
organisationId, sourceDocumentId) stripped:
Error responses
Submit or decline a portal quote
submit— marks the package asestimates_receivedand setsresponseStatustosubmitted. At least one bid rate must be saved before submitting. Duplicate or unresolvable rates return409.decline— marks the package asclosedand setsresponseStatustodeclined. No rates are required.
respondedAt) is preserved and not updated on replay. Sending a different action after a response has been recorded returns 409.
After a response is recorded, the portal becomes read-only. The contractor can no longer save or edit rates. The estimator must reopen the package (set its status back to issued) before any further edits are possible. Reopening clears responseStatus and respondedAt.
A response never changes the estimator’s selected pricing source, recalculates money, or awards the package.
Request body
Response — 200 OK
Error responses
Package responseStatus and respondedAt
Two fields are added to subcontract package rows to track contractor responses:
These fields are returned on the package list endpoint,
GET /estimates/:id/subcontract-packages, and on the package object of GET /subcontract-packages/:id/review. They are read-only outside of the portal response and the reopen path.
Reopening a package
Setting status to "issued" on PUT /subcontract-packages/:id when responseStatus is not null is treated as a reopen. The server clears responseStatus and respondedAt in the same write, making the portal editable again. Any other PUT on a package with a recorded response returns 409 Conflict until it is reopened.
Deletion guard
DELETE /work-items/:id returns 409 Conflict when the work item is a member of a package that has a recorded response. The estimator must reopen the package to issued before the work item can be deleted.
Work item TAG links
GET /work-items/:id/tags returns the tender clauses linked to the work item,
ordered by group (case-insensitive, ungrouped clauses last), then sortOrder, then id.
POST /work-items/:id/tags adds links, removes links, and creates new free-text
TAGs in one mutation transaction. If any mutation fails, every mutation rolls
back. The handler lists the resulting links after the transaction commits, so
a later response-read or transport failure does not undo a successful write.
Request body
An ID appearing in both
add and remove returns 422.
Each create entry produces an included manual clause in the
tags_clarifications list. No code is generated. baseItemId is null, its sort order follows
the existing list, and the organisation, project, and estimate IDs come from
the work item and estimate rather than the request. Creation and linking are
atomic, including when the request also adds or removes existing links.
Response
200 OKError responses
TAGs/tender clauses sync-on-read
GET /estimates/:id/tender-clauses triggers a sync step before listing. On each call, the server fetches the organisation’s departures and clarifications from the global API (GET /api/global/departures-clarifications) and inserts any items not yet present on the estimate, using the global item’s ID as baseItemId to detect duplicates. The response is then returned ordered by group (case-insensitive, ungrouped clauses last), sort order, and ID.
Tender clause schema
Sync behaviour
- Single source: the server calls one endpoint (
GET /api/global/departures-clarifications), not two separate setting lists. Clauses from the legacytags_clarificationsandallowances_assumptionslists that were previously copied retain their storedlistKey; new synced clauses usetags_clarifications. - Group stamping: each synced clause receives the global item’s
groupvalue (trimmed; blank or absent becomesnull). No code is written on synced rows. - Idempotent: rows already present (matched by
estimateId+baseItemId) are skipped viaON CONFLICT DO NOTHING. Concurrent first reads are safe. - Non-destructive: an existing clause that has been edited, regrouped, or excluded by the project retains its project-specific values. The sync never overwrites an existing row, including a clause whose group was changed on the estimate.
- Frozen-safe: the sync step is skipped entirely when the estimate is frozen (submitted, accepted, rejected, or archived). The endpoint returns
200with whatever clauses exist on the frozen estimate. This ensures a frozen tender’s clause list reflects exactly what was submitted. - Fail-soft: if the global service is unreachable, returns a non-2xx response, or returns an unreadable body, the sync step is silently skipped and the endpoint returns
200with whatever clauses already exist. - Org-gated: the sync only runs for authenticated callers with a resolved organisation ID. Anonymous callers receive
401; authenticated platform or null-org callers receive403. - Field mapping: each synced clause is stamped with
organisationIdandprojectIdfrom the estimate row (not from the caller’s token). The global item’slabelmaps totext. Values that exceed the column length limit are clamped with a server-side warning rather than causing an error.
POST /tender-clauses, PUT /tender-clauses/:id, DELETE /tender-clauses/:id) are unaffected and do not trigger any sync. The CRUD write routes (POST, PUT, DELETE) return 409 estimate_frozen when the estimate is frozen.
Takeoff map sheets
A map sheet is a named, saved map view (centre, zoom, and imagery layer) that map measurements are drawn on. It is the map equivalent of a drawing page.GET /estimates/:id/takeoff-map-sheets returns the estimate’s live sheets, oldest first. A sheet row returns its view as centreLat, centreLng, zoom, and layer.
Create request body
PUT /takeoff-map-sheets/:id accepts { name } only. The view is saved separately with PATCH /takeoff-map-sheets/:id/view, which accepts { centre, zoom, layer } in the same shapes, so a view save sent while the map pans never races a rename. The PATCH returns 409 deleted on a deleted sheet.
DELETE /takeoff-map-sheets/:id soft-deletes the sheet and returns 204. A repeat delete also returns 204. The delete is refused with 409 map_sheet_in_use and { linkedMeasurementCount } while any live measurement on the sheet is linked to a work item; unlink or delete those measurements first. Otherwise only the sheet is stamped: its measurements are not deleted, but they drop out of measurement lists because lists include only measurements on live sheets.
Every write returns 409 estimate_frozen on a frozen estimate.
Map measurements
Takeoff measurements carry acoordinateSpace:
Creating a map measurement.
POST /takeoff-measurements with coordinateSpace: "geo" takes estimateId, mapSheetId, label, type (line, area, or count), points as { lat, lng } (at most 2,000), quantity, and optional workItemId, category, unit, and sourceSnapshot. There is no documentId, scale, paperSize, or pageNumber. The server computes the quantity geodesically and stores it with scale: "geodesic". The unit is fixed by type (line in metre, area in square_metre, count in each) and may be omitted.
Updating a map measurement. PUT /takeoff-measurements/:id recomputes the quantity from new points under the same rules. When the quantity changes, a linked work item that was created from the measurement and whose quantity has not been edited by hand takes the new quantity in the same transaction. This also applies to drawing measurements.
Multi-line and Multi-segment takeoff
A Multi-line (type: "multi_line") and a Multi-segment (type: "multi_segment") are structured measurement types that group spans or lines by org takeoff tag. Both types store a parts descriptor alongside the flat points array.
parts structure
Clients supply
spanLengths on create and on any PUT that changes the number of lines, adds or removes points, or shifts line starts. When a PUT changes only the tags in parts and leaves starts unchanged, the server re-groups the already-stored per-line lengths without requiring spanLengths.
quantityBreakdown
The server stores and returns a quantityBreakdown field alongside parts. It is never accepted from the client.
Tagging and quantity links
When a Multi-line or Multi-segment has tags, each tag subtotal becomes a linkable source alongside the measurement total:- Candidates (
GET /estimates/:estimateId/work-items/:itemId/quantity-link/candidatesand the component variant) include abyTagarray on each tagged measurement candidate. Each entry hastag,label,quantity,spans, andlines(Multi-line only). - Usage (
GET /estimates/:id/takeoff-measurements/usage) returns one entry per source (total or tag). Each entry carriesmeasurementId,tag(UUID ornullfor the total),workItems,components, andcalculatorFields. - Creating a work item from a tagged measurement.
POST /estimates/:id/work-items/from-measurementaccepts an optionaltagUUID. When supplied, the new work item takes its quantity from that tag subtotal and is linked to it. The work item name is<measurement label> — <tag label>. - Quantity link descriptor. A
takeoffsource with a non-emptytaglinks to that tag’s subtotal. A descriptor may not mix the total of a measurement with one of its own tag subtotals (quantity_link_takeoff_overlap).
R15 — refusing edits that remove a linked tag
PUT /takeoff-measurements/:id checks whether the edit would remove a linked tag from the measurement. An edit removes a linked tag when it repoints that tag’s last line (Multi-line) or last span (Multi-segment) to another tag, or removes the points that carry it.
When the edit would remove a linked tag, the server returns 409 takeoff_source_removed without writing. The body contains:
Adding
?unlink=true to the request applies the edit and unlinks all affected users. The response body then includes unlinked: an array of { workItemId, quantity } for every work item that was unlinked (frozen at its last value).
Council utilities
These routes return reviewed public council three-waters assets (stormwater, wastewater, and drinking-water nodes and pipes) for map takeoff. They require normal authentication and an organisation context, and read no estimate data. The Takeoff panel shows utility overlays only when a feature flag is on; the flag does not restrict the routes. The sources cover parts of 41 council areas through 27 providers. Coverage is not complete for any council or asset type, and an empty response is not proof that no utilities exist. Display the returnedattribution wherever the data is shown or exported.
Two map routes sit alongside them: layer availability for the councils and layers in a view, and property boundaries from LINZ. They have the same authentication and organisation guard.
Providers
{ providers }, the catalogue of supported sources. Each provider has id, name, bounds, sourceUrl, licenceUrl, attribution, coverageNote, checkedOn, and layers. Each layer has key, name, network (stormwater, wastewater, or drinking-water), kind (node or pipe), areas (the council areas it covers), and optional bounds. Each council area and network belongs to one provider only. The bounds are coarse discovery rectangles, not council boundaries.
Features
The view can be at most 0.025° of longitude by 0.02° of latitude. No other parameters are accepted.
Response. A GeoJSON
FeatureCollection with fetchedAt, provider, providerName, sourceLayer (the layer key), sourceUrl, attribution, and licenceUrl. Node layers return Point features and pipe layers return LineString features; a multipart pipe is split into separately selectable parts. Each feature’s id is <provider>:<layerKey>:<objectId>:<part>, and its properties hold provider, sourceLayer, layerId, objectId, part, network, kind, assetId, assetType, material, diameter, district, loadedAt, and the source’s scalar attributes.
A view with more than 2,000 feature parts, 50,000 vertices, or a 3 MB response fails rather than returning a partial result. Each API worker caches a response for up to 15 seconds and coalesces identical requests in flight.
Layer availability
west, east, south and north with the same ranges as Features, but no size limit, so a regional view can be checked. No other parameters are accepted.
Response. { providers, councils, coverage, maxView }:
providers: the provider catalogue narrowed to providers whose bounds intersect the view and that have a layer for a council in view. Each layer’sareaslists only the councils in view.councils:{ id, name }for each territorial authority in view.coverage:sourceUrl,attributionandlicenceUrlfor the Stats NZ council boundaries.maxView:{ longitude: 0.025, latitude: 0.02 }, the largest view that Features and Property boundaries accept.
Property boundaries
west, east, south and north with the same ranges as Features, and the view can be at most 0.025° of longitude by 0.02° of latitude. No other parameters are accepted.
Response. { outlines, fetchedAt, sourceUrl, attribution, licenceUrl }. Each outline is { id, points }, where id is <objectId>:<part> and points are { lat, lng }. No parcel attributes are returned.
A view with more than 2,000 parcels, 5,000 outlines, 100,000 vertices, or a 3 MB response fails rather than returning a partial result. Each API worker caches a response for up to 15 seconds and coalesces identical requests in flight.
Recording provenance
When a map measurement is created from a utility feature, the client may sendsourceSnapshot:
The server checks the snapshot’s shape and that
provider, sourceLayer, and layerId name a layer in the catalogue, but does not check it against the upstream source, and still computes the quantity itself. Later point edits do not change the stored snapshot, and workspace versions include it.
Frozen estimate writes
A frozen estimate refuses every user write except a rename. An estimate is frozen when it is:- a tender whose status is
submitted,accepted,rejected, orarchived - a tender that was not awarded (hidden when another estimate was marked won)
- a Delivery estimate in
in_sign_offorsigned_off - a saved version
draft or review and Delivery estimates in draft are editable. Delivery is a separate phase that creates a copy of the estimate data; the submitted tender is preserved exactly as submitted.
Every write route that changes the estimate returns 409 estimate_frozen on a frozen estimate. This covers work items, components, calculators, the margin ladder, pricing, subcontract packages, project rate links, rate-exception decisions, acceptances and band clears, sections, all fields on PUT /estimates/:id, and the tender document data:
Rename exception.
PATCH /estimates/:id, and a PUT /estimates/:id whose body contains only name, succeed on a frozen tender (submitted, accepted, rejected, archived, or not awarded) and on a Delivery estimate in sign-off or signed off. A saved version stays fully read-only: the PATCH returns 404 estimate_not_found and the PUT returns 409 estimate_frozen. A PUT that carries name alongside any other field is refused as a whole.
The 409 response body includes the error code estimate_frozen, a human-readable message naming the actual status and how to proceed, and a status field for client branching:
A not-awarded tender answers “This estimate was not awarded, so it’s read-only.” and adds
hidden: true. A saved version answers “This is a saved version, so it’s read-only.” and adds kind: "saved_version". A deleted estimate returns 409 with code deleted instead.
Read endpoints are not affected. All GET endpoints remain available regardless of estimate status.
Sync-on-read GETs write nothing. GET /estimates/:id/tender-clauses skips its library-sync step on a frozen estimate. GET /estimates/:id/programme-items skips its programme-header backfill step. Both return 200 with whatever data the frozen estimate holds.
Lifecycle transitions, variation writes, and version creates are not gated. Those are the supported ways to act on a frozen estimate.
Document references
GET /estimates/:id/document-references?documentId={uuid} returns every reference within an estimate that points at a given document. This endpoint is used by the cloud-drive document-sync service as a pre-deletion guard: if the response contains an empty references array the document is safe to unroute; a non-empty array means at least one estimate record still depends on it.
Query parameters
Document reference response
Reference kinds
Each reference includes the
id of the referencing record and, where available, a label (the measurement label, package name, or work-item name).
Behaviour notes
- Returns
404if the estimate does not exist. - Returns
200with an emptyreferencesarray when the estimate exists but nothing references the document. - All four reference kinds resolve by
documentIdalone — no per-suggestion parameters are accepted. - Bid-rate and work-item rows that were accepted before
source_document_idwas introduced carrynullin that column and are not visible to this endpoint. A one-off backfill (migration0013) can populatesource_document_idfor any such legacy rows.
OpenAPI document
- Unauthenticated: no bearer token is required.
- Cached bytes: the document is serialised once at boot and returned unchanged on every request.
Cache-Control: no-storeis set so intermediate caches do not hold stale copies. - Contract version:
info.versionis the descriptor-contract version, not the deployed service version.
Response bodies and phasing
Request schemas come from the same Zod objects used by handlers. Response coverage is being added in phases:- A route with
x-stralos-response-status: undocumentedin its response object does not yet document its success body. The response object is present and describes the record type; the body schema will be added in a later pass. - List routes document the shared
ListEnvelope({ items, totalCount, start, length }) but leave the item element schema undocumented until row schemas are lifted. - The shared
ErrorEnvelope({ message, issues?, code? }) is referenced by every error response.
Security
The OpenAPI document marks protected operations with the document-levelbearerAuth requirement and a 401 response. The three public
subcontractor-portal routes (GET /subcontract-portals/:token and its
/documents/:docId variants) carry security: [] and do not document a 401.
Runtime authentication is mandatory on every protected operation.
Every authenticated descriptor operation documents and can return 403 Forbidden when the caller
holds a platform or service token with no resolved organisation context. This includes reads: all
tenant reads run through the row-level security scope, which rejects a null-org token before any row
is read. The three public subcontractor-portal operations
(GET /subcontract-portals/:token and its /documents/:docId variants) are excluded; they carry no
bearer token and resolve the organisation from the capability token, so a null-org scenario is
structurally unreachable.
Service version
Cache-Control: no-store. It reads SERVICE_VERSION for the version and SERVICE_COMMIT for the commit, both stamped by the deployment pipeline. When SERVICE_VERSION is absent it falls back to the package version; when SERVICE_COMMIT is absent it returns "unknown".
Response — 200 OK
The release coordinator reads this endpoint before mutation and compares it with the manifest’s minimum version.
Billing access
The Estimate API enforces subscription access on user write operations. When an organisation’s subscription is not in full access mode, any write request from a user token returns:Project estimate
Get or create a project’s estimate
When a
409 project_not_provisioned is returned, the estimate will be created shortly. Retry after the Retry-After interval. Do not treat this response as a permanent failure.
Ensure a project’s estimate exists
hoursPerDay stay frozen at their original values.
201 Created— the estimate was created by this call.200 OK— an estimate already existed; it is returned unchanged.
GET above and is subject to the same 402, 403, 404, and 502 responses. It is not affected by the lazy-create mode — it is always the authoritative create path.
Authentication: requires a valid bearer token with a resolved organisation context.
Project estimates
A project can hold more than one tender estimate when its Multiple estimates setting is on (see Project estimate settings). One live tender estimate is always the primary — the estimate that opens on project load and thatGET /projects/:projectId/estimate returns. The others are alternative pricing scenarios. A Delivery estimate is created at award, not through these routes.
The write routes run under the project’s lifecycle lock, so they serialise with submit, revise and award.
List a project’s estimates
includeHidden=true is passed.
Query parameters
Response —
200 OK
Each
items entry:
Create an estimate
POST /estimates/:id/make-primary to change the primary afterwards.
Request body
The three creation modes:
- Blank (omit
from) — an empty estimate that takeshoursPerDayand the margin ladder from the organisation defaults at creation time. - Duplicate (
from.estimateId) — a full copy of a live tender estimate on this project, including its work items, build-up, sections and project rates. The copy is created indraftstatus. - From version (
from.versionId) — adraftcopy of a saved version of an estimate on this project.
500 copy_parity_failed if the copied totals do not match the source to the cent.
Response — 201 Created
The new estimate row.
Error responses
Rename an estimate
Response —
200 OK
The updated estimate row.
Error responses
Make an estimate primary
200 OK
The updated estimate row.
Error responses
Delete an estimate
204 No Content. A repeat DELETE on an already-deleted estimate also returns 204 and changes nothing.
Error responses
Document suggestion idempotency
Accepting a document suggestion (POST /estimates/:id/work-items/accept-suggestion) is idempotent per estimate. Accepting the same suggestion into a different estimate of the same project is treated as a fresh accept in that estimate, not a replay. Each estimate tracks its own accepted suggestions independently.
The same rule applies to subcontract bid suggestions: idempotency is per subcontract package, so duplicating an estimate also duplicates its packages, and the same bid can be accepted into the new package.
Project estimate settings
Get estimate settings
200 OK
Update estimate settings
Response —
200 OK
A body that fails validation returns
400.
Estimate commercial settings
The editable fields above are accepted on
POST /estimates and PUT /estimates/:id; pricingBasis is response-only. Changing defaultMarkupPct, defaultMarginPct, or contingencyPct through the estimate PUT recomputes work-item totals and section subtotals in the same transaction under the estimate-level advisory lock. pricingBasis is server-owned and cannot be changed after creation. On a frozen estimate, PUT /estimates/:id returns 409 estimate_frozen for every field, including gstRate, currency, quantitySource, and soqUnlocked, not only the repricing fields. The one exception is a body that contains only name, which is treated as a rename and succeeds on a frozen estimate other than a saved version. A body that carries name alongside any other field is refused as a whole.
Sell rate rounding
sellRateRounding on PUT /estimates/:id rounds every item’s sell rate up to a step from SELL_RATE_ROUNDING_OPTIONS (see Estimate rounding) and reprices every item that has no rounding of its own. Each rate is rounded up, stored to the cent, and the amount is quantity × rate. A token outside the list is 422 sell_rate_rounding_invalid; a frozen estimate is 409 estimate_frozen.
sellRateRounding on PUT /work-items/:id is the item’s own setting: null inherits the estimate’s and "none" keeps the item unrounded. The response carries effectiveSellRateRounding and unitRateUnrounded (the cent rate before rounding, null when rounding moved nothing). Provisional sums, rows with a quantity of zero or less, and rows in a Shared Costs section are never rounded.
Working day inheritance
WhenhoursPerDay is absent from a POST /estimates request body, the server reads the organisation’s defaultHoursPerDay from the Global API and stamps that value onto the new estimate. An explicit hoursPerDay value in the body always wins over the organisation default.
The same inheritance applies when an estimate is created lazily via GET /projects/:projectId/estimate: if no estimate yet exists, the server creates one using the organisation’s working-day default.
When the Global API is unavailable or returns an unreadable value, the server falls back to 8 and logs a warning. The fallback never causes the request to fail.
A work item no longer carries a markup of its own.
markupPct was removed from the work-item create and update bodies on 2026-09-18: a markup belongs to the activities and materials inside a work item, not to the item. A request that still sends the field is accepted and the field is ignored.Quantity entry policy
ThequantitySource field controls how work-item quantities are entered for the project.
The default is
soq. This field is accepted on POST /estimates and PUT /estimates/:id. Unlike the commercial settings above, changing quantitySource does not trigger a pricing recompute — it controls editability only.
soqUnlocked
soqUnlocked is a boolean field on the estimate row. The database column defaults to false, but the GET /projects/:projectId/estimate lazy-create and PUT /projects/:projectId/estimate ensure paths write true. POST /estimates also defaults to true while allowing the caller to supply false explicitly. An existing estimate can be locked by accepting an SOQ import suggestion (which sets soqUnlocked: false under the estimate-totals lock) or by explicitly locking it via PUT /estimates/:id.
This field is accepted on POST /estimates and PUT /estimates/:id. It is in the same class as quantitySource — it controls editability, not pricing — but it does take the estimate-totals advisory lock on write because it determines which rows are SOQ-frozen and the work-item writers read it under that lock.
Setting soqUnlocked: true lifts:
- The quantity
422onPUT /work-items/:idand on batchsetQuantity. - The move and renumber
422onPUT /work-items/:id. - The relative-order
422onPOST /estimates/:estimateId/work-items/reorder. - The bulk-move
422onPOST /estimates/:estimateId/work-items/batch. - The section move and renumber
422onPUT /sections/:id.
soqUnlocked does not unfreeze automatic item numbering. An imported row carrying its own itemNumber keeps it through a reorder even after the estimate is unlocked. A person may still PUT a new itemNumber by hand on an unlocked estimate.
soqUnlocked is excluded from tender revision snapshots (it is an editing-mode flag, not a pricing input, and two revisions of one estimate must hash identically regardless of lock state). Estimate version snapshots include it and restore it verbatim.
SOQ lock
A work item’s quantity is read-only when all of the following are true:- the estimate’s
quantitySourceissoq; - the estimate’s
soqUnlockedisfalse; - the item’s
quantityOriginisimport; sourceQuantityis notnull; andquantityexactly equalssourceQuantity.
quantityOrigin: "measurement") remain editable in soq mode. If an
estimator changed an imported quantity while the estimate was in plans mode, switching back to
soq does not restore or re-lock that changed value.
The lock is value-aware: submitting the stored quantity again succeeds, so unrelated edits such as
renaming, retagging, repricing, or changing markup are still allowed. A different quantity returns
422 Unprocessable Entity.
An SOQ-locked item’s position is also fixed. These operations return 422 and write nothing when
they would move or renumber a locked item:
All of the above restrictions are lifted when
soqUnlocked is true. See soqUnlocked above.
Work item pricing
Read all pricing rows
componentOverrides array, synthesised from each live component’s price-selection columns and active value overrides. It is a compatibility shape only: the stored JSON column was dropped (migration 0095), and the key cannot be written (see below).
Use priceSelection and valueOverrides on individual component reads (GET /work-items/:id/components and the components array on GET /work-items/:id) to read pricing state per-component without fetching the full estimate pricing.
Save a pricing row
A body that includes
componentOverrides is refused with 422 component_overrides_retired. Write a component’s price selection through PUT /work-item-components/:id/pricing-rule and its hand-changed values through PUT /work-item-components/:id/value-overrides.
Sparse pricing update
pricingSource on a work item’s pricing row without replacing the full row.
The body accepts:
A body that includes
componentOverrides is refused with 422 component_overrides_retired. Write a component’s price selection through PUT /work-item-components/:id/pricing-rule and its hand-changed values through PUT /work-item-components/:id/value-overrides.
Effective pricing source on read
GET /work-items/:id and GET /estimates/:id/work-items return a pricingSource that reflects the source the money recompute actually used, not the raw stored token. The effective source is "internal" when the stored token is absent, null, or empty; when the named bid does not belong to this work item; or when the item has a published package. In all other cases the stored "subcontract:<bidRateId>" token is returned unchanged.
Frozen pricing rule selection
- Set — include
selection,envelope,resolvedBandKey, andchargedQuantity. - Clear envelope only — include
selection, setenvelope,resolvedBandKey, andchargedQuantitytonull. - Clear entirely — omit
selectionand setenvelope,resolvedBandKey, andchargedQuantitytonull.
selection object accepts these fields:
Moving the component’s unit atomically (
unit field)
A plain PUT /work-item-components/:id can change the unit on a frozen selection, but it deliberately unfreezes that selection: pricedUnit and conversionFactor are removed while the existing rate option identity and numeric unit cost remain for the caller to correct. A row governed by a pricing-rule snapshot still refuses a plain unit change because the rule owns its unit.
Send unit through this pricing-rule endpoint when the caller is selecting another option priced in the target unit. The component’s unit, pricing selection, and recomputed totals then move in one write without passing through an unfrozen intermediate state.
The unit value must equal the canonical form of pricedUnit. This restricts the move to same-unit selections by construction: an option that requires a unit conversion has a pricedUnit the component is not moving to, so it cannot satisfy the equality and the component’s unit is left unchanged.
Do not set unit when a conversion applies. A row measured in m³ priced from a per-tonne option legitimately keeps m³ and carries a conversion factor. Sending unit on such a selection returns 422.
422 responses
Hand-changed value overrides
provenance: "pinned" with the edited unit cost as the scalar.
Request body
Send either { clear: true } to revert every override, or one or more of the following fields:
At least one field is required unless
clear: true is sent.
Response — 200 OK
Each entry in
valueOverrides:
Error codes
Contract variations
Work item delete behaviour
DELETE /work-items/:id runs in a single transaction under the estimate-level advisory lock. Deletions are soft: the row is stamped with deleted_at rather than removed. Every work item, its live sub-items, every live component on those items, and each item’s programme header subtree share one deleted_at timestamp from the same delete call.
The operation performs the following steps:
- Nulls the
workItemIdon any linked takeoff measurements (bare-UUID reference, no foreign key). - Removes the deleted item’s ID from the
workItemIdsarray on every subcontract package within the same estimate. Packages in other estimates are not modified. - Stamps the work item, its live sub-items, all of their live components, and all of their programme header rows with a shared
deleted_attimestamp. - Recomputes section subtotals and estimate totals.
204 No Content on success. A repeat DELETE on an already-deleted item also returns 204 and changes nothing. The item is not returned on list or aggregate reads after deletion, but GET /work-items/:id continues to return the row with its deletedAt field set.
Any write to a deleted work item (PUT, sub-item, duplicate, component creation) returns 409 { code: "deleted" }. The caller must not assume the item was removed from the database; the row survives for audit purposes.
404 Not Found is returned only when the item does not exist or is outside the caller’s organisation.
Duplicating a work item
POST /estimates/:estimateId/work-items/:itemId/duplicate creates an atomic copy of a work item within its estimate:
- The copy is named
<original name> (copy), truncated to the name column limit. - Provenance fields (
sourceDocumentId,sourcePageNumber,sourceExcerpt,sourceSuggestionId) are cleared — the copy is not linked to any source document or suggestion. - Package-owned mirror components (those with
type: "subcontract", asubcontractBidRateId, or asubcontract:pricing source) are not copied. Only internal build-up components are cloned. - If the source item’s pricing source is
subcontract:<bidRateId>, both the work item column and canonical pricing row are reset tointernalon the copy, because the clone is not a member of that package. - Pricing overrides are remapped onto the new component IDs. An override referencing a component that was skipped (package-owned) is dropped from the copy rather than carried as an orphan.
- Calculator-generated component keys are rebound to the clone’s component IDs. Rebound keys are canonicalised and any collisions are resolved deterministically, preserving a unique reconcile identity within the copied work item.
- The copy is recomputed immediately; totals and section subtotals reflect the new item before the response is returned.
201 Created with the new work item and its derived tagCount and hasTakeoffLinks fields. It returns 404 Not Found if the source item does not exist or does not belong to the specified estimate.
Splitting an SOQ line into sub-items
Placement rules
- The parent must be SOQ-sourced: its
quantityOriginmust beimport. A takeoff-measured item (quantityOrigin: "measurement") does not qualify even though it carries asourceDocumentId. - The parent must not itself be a sub-item. Only one level of nesting is supported. The server checks this first and returns
422 sub_item_nesting_unsupportedwhen it fails. - The sub-item always lives in the parent’s section. There is no
sectionIdin the request body.
Item numbering
When the parent has an item number and the generated value fits the 80-character ceiling, the sub-item receives a number of the form<parent itemNumber><letter> — for example, 2.3a for the first sub-item under 2.3, 2.3b for the second, and so on. Suffixes walk a through z, then aa, ab, and continue indefinitely. The server allocates the suffix against all existing children of that parent. If the parent has no number or no candidate fits within the column limit, the sub-item’s item number is null.
Sub-items numbered before this change keep their stored <parent>.<n> numbers. A parent that already holds a child numbered 2.3.1 gives its next sub-item the suffix a without colliding.
Reorder behaviour
Sub-items are excluded from the section reorder membership set. When callingPOST /estimates/:estimateId/work-items/reorder, send only top-level item IDs. Sub-items are re-ranked with their parent when the parent’s position changes; the response returns the whole section, sub-items included.
Move behaviour
A sub-item cannot be moved independently to a different section (422 sub_item_section_bound). Moving a parent is allowed and carries all its sub-items to the new section.
Delete behaviour
Deletions are soft. Deleting a parent stamps the parent and all of its live sub-items with a shareddeleted_at timestamp in one transaction. Deleting a sub-item individually stamps only that sub-item and recomputes totals. In both cases the rows survive; list and aggregate reads exclude them, and a repeat delete returns 204 without changing anything.
Request body
Response
201 Created — the new sub-item row, with the same derived fields (tagCount, hasTakeoffLinks) as every other work-item creator.
Error responses
parentWorkItemId is not accepted in the generic work-item create or update bodies. This endpoint is its only writer.
Copying a build-up into a work item
:itemId. It preserves the hierarchy of materialised activity instances — instance containers are copied with their instance fields intact, and their children are re-parented onto the copied container.
Request body
Rate refresh
WhenrefreshRates is true, the server re-resolves each rate-library-bound component (those with a non-null rateItemId) against the current library before inserting into the target. Hand-entered lines without a rate-library link are never refreshed.
With the current Rates contract, the server has two effective match tiers in order, stopping at the first hit:
The resolver retains a legacy code-match branch, but current rate-item responses carry no code, so that branch cannot select a candidate. A tier that finds more than one matching item is treated as ambiguous; no choice is made and the line retains its historical price. Only rate-library-bound lines refresh; the rate-refresh pass runs entirely before the write transaction so that no peer-service call holds the estimate advisory lock.
Copy rules
- Package-owned mirror components (
type: "subcontract",subcontractBidRateIdset, or asubcontract:pricing source) are excluded. They point at source-package state the target does not own. - Calculator-generated rows (
generatedKeyset) are excluded. A generated row belongs to the calculator that produced it and does not transplant to a work item whose calculator does not own it. - Day-rate lines (
type: "day_rate") are copied verbatim, including theirunit_costoverrides. When the source is from a different project, the copied lines retain the source revision and are markedinProjectSet: falseon read if the target project pins a different revision of that schedule. - Instance containers are copied with their
allocatedQuantity,productionRate,productionOutputUnit,productionProfileId,scopeLabel,chainLabel, andactivityCountfields intact. Instance children are re-parented onto the copied container and retain theirquantityBasisvalues. - An instance container whose
productionOutputUnitdoes not match the target work item’s unit (normalised, case-insensitive) is silently dropped along with its children. The response includes a warning with the count of dropped instances and the target’s unit. - An unknown
componentIdsentry, or an activity-instance child selected without its container, returns422 Unprocessable Entity. - A copied line does not arrive loose. A line copied without its activity (possible only for an older activity with no daily production) goes into one new activity, named after the line when there is only one and after the source work item otherwise. A source line that sits outside an activity goes into one new activity named
Activity(orActivity 2, and so on, if the name is taken). Each line is charged so its cost is unchanged. New activities are listed first incopied. A line that has no equal inside an activity stays outside one. - With
exactSelection: true, any selected line inside an activity whose activity is not selected returns422 orphan_component_selection. An activity with no rate of its own that is selected with none of its lines returns422 empty_activity_selection. Both list the offending IDs ininvalidComponentIds. An activity priced by its own rate item may be copied on its own. - Estimate-owned pricing overrides are deep-copied with component IDs remapped. When
refreshRatesistrue, a successfully refreshed ordinary rate line retires its historical override; unmatched, ambiguous, and no-price lines retain their frozen pricing inputs.
Copy-build-up response
The endpoint returns201 Created with { copied, warnings, unitMismatchCount, rateRefresh }.
The
rateRefresh object contains:
When
refreshRates is true, each copied component row also carries a rateRefreshStatus field:
The operation runs under the target estimate’s advisory lock. It reads the source build-up without locking the source estimate. Returns
404 Not Found if the target work item or source work item does not exist or is outside the caller’s organisation. Returns 502 Bad Gateway when refreshRates: true and a peer service (Rates API or Global API) read failed.
Work item component rateGroup
rateGroup is an optional classification stamp on a component row. It records which cost category the component belongs to, and the pricing engine reads it to decide whether the labour multiplier applies.
Accepted values
Behaviour on create and update
- A valid token stamps the field. An invalid token returns
400 Bad Request. - An explicit
nullclears a previously stamped group. - An omitted key leaves an existing stamp untouched (null-vs-absent contract).
nulland omitted are both valid on create; the field defaults tonull.POST /work-item-componentsreturns the fully-stamped component row in its201 Createdresponse. TherateGroupvalue in the response body reflects the persisted state after stamping, not the pre-write input row.
category
category is a free-text description field used by the legacy classifier. A recognised rateGroup is an authoritative snapshot that supersedes category: a labour token applies the labour multiplier regardless of the category text, and any other recognised token suppresses it. When rateGroup is null, absent, or unrecognised, the engine falls back to the category-based sniff. Rows with no recognised group therefore price identically to their pre-stamp behaviour.
Propagation
When an activity is materialised into a work item using POST /work-items/:id/materialise-activity, each expanded leaf row receives the rateGroup stored on the corresponding library component. A library component with no rateGroup produces a leaf with rateGroup: null.
When a work item is saved as an activity using POST /work-items/:id/promote-to-activity, the rateGroup on each component is included in the recipe key digest. A re-promote that changes a component’s group mints a new recipe rather than replaying the existing one.
Project-calculator rows
Project-level calculators publish physical outputs through their assignment register. The destination component owns its rate item, rate option, unit cost, category, and rate group; these pricing fields do not live in the calculator configuration.
Work item component calculator provenance fields
Every component read response —GET /work-items/:id/components and the components array on GET /work-items/:id — includes calculator provenance fields on every row. Hand-entered rows carry null for the identity fields and false for the flag.
calculatorName is not stored on the component row. It is read from the live calculator record so that a rename is immediately visible. calculatorId is the stable identity; calculatorName can change or be absent without affecting the link.
calculatorReusesExcavatedMaterial identifies rows produced by a calculator layer configured with isBackfill: true. Because the material is drawn from the site’s own excavation, its supply quantity may be zero. Consumers can use this flag to distinguish reuse rows from bought-in fill during pricing review.
Tender revision snapshots
rateGroup is a pricing input. A snapshot that omits it cannot reproduce the labour-multiplier decision it recorded. The snapshot schema version was advanced to v9 when this field was introduced. Snapshots stored under v1–v8 remain valid as recorded because their rateGroup is null, which falls through to the category-based sniff — the same path those snapshots used at submission time.
Work item component rate option snapshot fields
When a component is linked to a rate item and a rate option is selected, the server persists a snapshot of the selection context. These fields are stored on the component row itself and are returned on every component read.rateOptionId is a generated read-only field. It is derived from the component’s stored price selection and reflects the pinned rate option when selection_provenance is pinned, and null otherwise. To set or change a component’s rate option, use PUT /work-item-components/:id/pricing-rule.
Organisation-owned selections and sourceName
When a selected rate option is owned by the organisation (its scope is organisation), sourceName is always stored as null. Display the organisation’s current name from your shell context rather than from this field. Supplier-owned selections store the supplier’s name as sourceName as normal.
Name currency
The stored sourceName and locationName on editable estimates are kept current automatically. When a supplier or location is renamed in the global API, the server updates these fields on every affected build-up line in every editable estimate of that organisation. Frozen estimates keep the names they held at submission.
Write protocol for sourceName and locationName
On POST /work-item-components, supplied values are stamped and omitted fields are stored as null.
On PUT /work-item-components/:id, a supplied value replaces the snapshot and an omitted key preserves it. Both fields accept an explicit null on update, which clears the stamp.
Rate item re-link exception
When a PUT /work-item-components/:id request changes rateItemId, the previous selection snapshot is stale. The server automatically clears any of sourceName and locationName that the same request does not replace. Explicitly supplied values, including null, are applied as sent.
Pricing behaviour
rateOptionId, sourceName and locationName are snapshot fields used for display and provenance. They do not affect pricing calculations.
Work item component priceSelection and valueOverrides fields
Every component read response — GET /work-items/:id/components and the components array on GET /work-items/:id — includes two additional fields that expose the component’s full pricing state without requiring a separate call to GET /estimates/:id/work-item-pricing.
priceSelection — null when no rate option is selected. When a selection exists, it includes:
valueOverrides — an array of manual field-level overrides applied to this component. Each entry contains:
Existing components
Existing components retain their current pricing. The option snapshot fields are populated when a component is next explicitly selected or refreshed through the pricing panel. No automatic backfill occurs.
Work item component deliveryActivityComponentId
deliveryActivityComponentId is an optional nullable UUID field on a work item component row. It records that a line-entered material row is associated with (delivered through) a canonical activity container on the same work item.
Source eligibility (D12)
Only a line-entered, price-bearing material row may carry an outgoing delivery link. The following rows are not eligible as a source:
- Calculator-generated rows (those with a non-null
generatedKey). - Materialised child rows (those with a non-null
parentComponentId). - Activity container rows.
422 Unprocessable Entity.
Target predicate (D13)
The target must be a canonical activity container on the same work item: a component whose type is "activity", parentComponentId is null, activityId is non-null, and rateItemId is null. The target must belong to the same organisation.
Write protocol (null-vs-absent triad)
- A UUID value sets the link.
- An explicit
nullvalue clears the link. - An omitted key preserves the existing link.
PUT /work-item-components/:id (update). A new line always arrives inside an activity, so POST /work-item-components refuses a link with 422 wrap_delivery_link_not_allowed. Only an existing line outside an activity can carry one.
Invariant enforcement
The server validates the effective post-patch state on every write. An edit that would leave the link in an invalid state returns 422 without making changes — links are never silently cleared by a conflicting edit. To resolve an invariant conflict, clear the delivery link first, or delete the container (which triggers the delete sweep and nulls all incoming links on the same work item).
Cost parity (D14)
A delivery link is metadata only. It does not move cost between components or change work item totals. Totals are identical before and after setting or clearing a link.
Copy and duplication behaviour
POST /estimates/:estimateId/work-items/:itemId/duplicate— the link is rebound to the equivalent container in the copied work item.POST /estimates/:estimateId/work-items/:itemId/copy-build-up— links within the copied component set are rebound to the copied containers. A material row copied without its linked container has its delivery link cleared tonull.
DELETE /work-item-components/:id on an activity container that is the target of one or more delivery links nulls those links in the same transaction. This is the only write path that nulls a delivery link without an explicit null from the caller.
Tender snapshot
deliveryActivityComponentId is included in the tender revision snapshot. It is present on each component node that carries a link.
Work item component generatedKey
generatedKey identifies generated component rows. Current project calculators reconcile their own rows through the assignment register; legacy embedded keys remain compatible stored identities.
The server mints project-calculator keys in lower case. The ordinal is omitted for the first row and included from the second row when a layer is partitioned across destinations in the same work item. See Project calculators for the current routes and Legacy embedded calculator columns removed for compatibility behaviour.
POST /work-item-components refuses a generatedKey with 422 generated_key_not_allowed. On PUT /work-item-components/:id, the server lowercases the embedded UUID in a wic: key while preserving the case of <calcKey>. wi: keys and values outside the wic: scheme are stored unchanged.
When a work item is duplicated, each component-scoped key is rebound to the corresponding component in the copy. If more than one rebound key canonicalises to the same value, later occurrences receive a deterministic ~N suffix, starting at ~2, without taking a suffix already present in the copied build-up.
Legacy embedded calculator columns removed
Migration0058 guards against unconverted embedded configurations. Migration 0059 physically drops the calculator columns on work items and components. Their replacement is the estimate_calculators entity and its assignment register.
Stale clients that still send a calculator key on POST /work-items, PUT /work-items/:id, POST /work-item-components, or PUT /work-item-components/:id are not rejected. Zod strips undeclared keys, so the field is silently ignored and nothing is written.
New calculator work uses the project-level estimate_calculators entity and its assignment register. Project-level reconciliation owns the calc: generated-row namespace.
Build-up line quantity links
A build-up line (work item component) can carry aquantityLink descriptor that links its quantity to the sum of one or more calculator readings or takeoff measurements. The descriptor uses QuantityLinkV1 from @stralos-co-nz/calculators. See Linked work-item quantity for the full descriptor schema. rateItem sources are not accepted on build-up lines.
Stralos re-resolves every linked build-up line at the head of each estimate recompute, so a calculator save, reconcile, or delete updates the line’s quantity in the same request.
Eligibility. Only whole-item priced lines that are not calculator- or recipe-generated can carry a link. Lines priced per unit of the work item, lines belonging to activities, and calculator-generated lines are ineligible.
Rounding. quantityLink.rounding rounds the line’s linked quantity up after the multiplier and offset. A line priced in whole stock lengths, rolls, or sheets already rounds up and takes none: 422 component_link_rounding_whole_stock.
Build-up line quantity-link candidates
Error responses
Materialising an activity into a work item
Preview an activity expansion
planHash (SHA-256 hex digest) that binds the expansion inputs and the reachable recipe, component, option, publication, and warning state observed at preview time. Pass this hash to POST /work-items/:id/materialise-activity as planHash to detect a relevant change before insert.
Request body
Response — 200 OK
Error responses
Insert a materialised activity
Request body — legacy mode
OmitallocatedQuantity to insert using the legacy (quantity-fold) mode.
Commercial-rule resolution
The three commercial rules (minChargeDuration, roundingIncrement, mobilisationAmount) were removed as a feature on 2026-09-20. The instance columns on work item components were dropped by migration 0060. Activity production profiles no longer carry default values for these fields.
Any request body that still sends these field names is accepted and the fields are ignored. No response carries them back.
Charged-duration calculation
For an instance container, the children’s total cost is calculated from a duration derived from the allocation:unpriced.value figure in the response uses the charged duration.
Request body — instance mode
SupplyallocatedQuantity to create an activity instance. Hour- and day-based activities retain their base duration unit. A quantity-based activity is also valid when its unit matches the work-item unit; Stralos converts it to a day-based instance and scales its recipe by the supplied units-per-day rate.
The materialisation request does not accept
productionOutputUnit. Creation derives the allocation unit from the work item; a selected production profile can supply its rate automatically only when its output unit matches that work-item unit. To use a different allocation unit after creation, update the returned container through PUT /work-item-components/:id with productionOutputUnit and any corresponding allocation or production-rate adjustments; the editing contract below canonicalises and stores it.
In instance mode the container’s quantity is fixed at 1; the allocation drives the duration rather than a container quantity multiplier. Child quantities are stored per base unit of the activity (per day or per hour).
The commercial-rule fields (minChargeDuration, roundingIncrement, mobilisationAmount) are no longer accepted. Sending them is ignored — no 422 is returned — but they are not stored or applied.
Materialisation response
The endpoint returns201 Created with { container, children, warnings, unpriced, planHash }. The returned hash is the plan the server committed.
The
unpriced object contains:
Package sell fields on the container
When the inserted activity is a published fixed-price package, the container row carries these additional fields:Package add-on provenance on child rows
Materialised children are detached snapshots: later library edits do not update them. Their server-owned
parentComponentId attaches them to the container, and pricing treats them as recipe-derived rows. They are written with generatedKey: null, so calculator reconciliation cannot claim or remove them.
Editing an activity instance container
After materialisation, the instance container can be updated usingPUT /work-item-components/:id. The following fields apply only to instance containers:
A non-null
activityCount must belong to an instance container. Explicit null is accepted as a harmless clear on other component rows.
Child quantity basis
quantityBasis controls how a direct child of an instance container is charged:
When a library activity component carries
whole_activity or per_count, the materialisation planner propagates that basis to the leaf row. The insert endpoint applies the same token to the written component, so an activity designer’s charge basis reaches the estimate without manual adjustment after materialisation. An activity whose recipe contains any non-null basis token requires instance mode (allocatedQuantity) — materialising it in legacy mode returns 422 quantity_basis_requires_production.
On PUT /work-item-components/:id, omission preserves the stored basis, a token
sets it, and explicit null clears it. POST /work-item-components/:id/children accepts the field when creating a direct
instance child. A non-null basis on any other row returns 422; explicit
null is accepted. Invalid tokens fail schema validation with 400.
Changing the basis recomputes estimate totals in the same transaction. During
POST /work-item-components/:id/enable-daily-production, children that already
carry a non-null basis are left unchanged rather than being rescaled as legacy
per-container quantities.
Both activityCount and quantityBasis are pricing inputs included in tender
revision snapshots. Their introduction advanced the snapshot schema to v19.
The container’s activityId, unit, quantity, and type are fixed. Delete
and re-materialise it to use another activity or unit. A work item’s unit cannot
be changed while it has an instance whose productionOutputUnit matches the
work item’s unit (canonicalised, case-insensitive). An instance denominated in
a different unit does not block a work item unit change.
Option-group exclusivity
When an activity recipe includes optional components under anoption_group, the materialisation plan enforces at most one selected line per group and owning activity. Two nested recipes can use the same group name independently, so selecting one option from each is valid.
Expansion and error behaviour
- The root activity is depth 1. Nested activities are expanded through depth 3; deeper content is skipped with a warning.
- A missing nested activity is skipped with a warning. A missing root activity returns
404 Not Found. - A recipe cycle returns
422 Unprocessable Entity. - A recipe requiring more than 200 activity fetches returns
422 Unprocessable Entity. - An unreachable Activities service, a non-success response other than a missing activity, or an unreadable response returns
502 Bad Gateway. - A missing work item, including one outside the caller’s organisation, returns
404 Not Found. - A
parentActivityComponentIdthat is missing, belongs to another work item, or is not an activity container returns422 Unprocessable Entity. - A quantity-based activity whose unit does not match the work-item unit returns
422 Unprocessable Entity. - Supplying instance-mode fields without
allocatedQuantityreturns422 Unprocessable Entity. - Supplying
quantityalongsideallocatedQuantityreturns422 Unprocessable Entity. - A
productionProfileIdthat does not belong to the root activity returns422 Unprocessable Entity. - A mismatched profile unit without an explicit
productionRatereturns422 Unprocessable Entity. - A derived duration that underflows or exceeds bounds returns
422 Unprocessable Entity. - Omitting
planHashreturns422 Unprocessable Entitywith codeplan_hash_required, before activity fan-out or any database write. A present hash that is not 64 lowercase hexadecimal characters is a schema-level400. - A
selectedOptionalComponentIdsentry that is not a reachable optional component in the plan returns422 Unprocessable Entity. - Two entries in
selectedOptionalComponentIdssharing the same activity-scoped exclusivity group returns422 Unprocessable Entity. - A published fixed-price package nested beneath an existing production instance container returns
422 Unprocessable Entitywith codepublished_package_instance_unsupported. - An activity that contains components with
whole_activityorper_countquantity bases, materialised withoutallocatedQuantity, returns422 Unprocessable Entitywith codequantity_basis_requires_production. Enabling daily production (instance mode) is required so the charging bases can be applied. - When the reviewed hash differs from the fresh plan, the server returns
409 Conflictwith codestale_materialisation_plan. The response body includes an updatedplanfrom which the client can display the changes and re-confirm.
Saving a work item or activity subtree as an activity
componentIds to copy the whole build-up. Otherwise, each supplied ID must identify either a top-level component or an activity container; selected activity containers include their complete descendant subtree. The current detail-pane flow supplies the selected activity container’s ID to save that activity subtree. The operation does not modify the work item or attach the new activity to it. Library writes require the caller to be an organisation admin.
Request body
category is not part of the activity record. If a stale client sends it, schema parsing strips it and the value is not saved.
Copy rules
- Ordinary internal build-up rows are copied in their stable display order using their stored base unit costs. Subcontract mirror rows are excluded because the activity library cannot represent bid pricing.
- A materialised activity container is omitted and its expanded children are copied as ordinary flat components. This preserves the work item’s snapshot and prevents the container and its children from pricing the same recipe twice.
- An activity container with no attached expansion is omitted with a warning because there is no recipe content to copy.
- Calculator identity, multiplier, working-day, and pricing-override data are retained in component
metadataas provenance where present. These values are inert in the activity library; the saved recipe prices from base unit costs. - A manual row without a rate-library link cannot be copied. The server refuses the entire save before calling the Activities service because that row would be skipped as unpriced when the saved activity was later materialised. Add each manual line to the rate library and link it before saving the activity.
- If no eligible components remain, the API saves an empty activity and returns a warning.
- An explicit empty
componentIdsarray (present but zero-length) returns422 Unprocessable Entitywith codeempty_component_selection. An absentcomponentIdsmeans copy the whole build-up; an empty array means nothing was selected and is rejected rather than treated as a full copy.
Save-as-activity response
The Estimate API always returns201 Created, including when the Activities API reports an idempotent replay.
The endpoint returns
403 Forbidden when the caller is not permitted to maintain the organisation’s activity library, 404 Not Found when the work item is unavailable, and 502 Bad Gateway when the Activities service cannot complete the save.
Work item similarity search
401 auth_required; an authenticated caller without organisation context returns 403.
Results are ranked by name similarity. Items below the service’s 0.2 similarity cutoff are excluded, and the remaining results are ordered by descending score.
Only work items that have at least one build-up component are candidates. Items with no components are excluded before the limit is applied. This means the response may return fewer results than limit, or an empty items array, when the organisation’s past work items are largely unpriced imports.
Query parameters
Response
200 OK —{ items } where each entry is a work item row extended with a similarity score and a components array containing that item’s build-up components in display order. Every item in the response has at least one component.
Manual activity
POST /work-item-components/:id/children.
Request body
The new container is inserted at the end of the work item’s build-up (or at the end of the parent container’s children when
parentActivityComponentId is supplied).
Response — 201 Created — the created activity container component row.
Error responses
Wrapping a time-based rate in its own activity
labour or plant_vehicles, and any rate whose unit is not hour, day, week, or month. A caller cannot use this endpoint to wrap a material, a subcontractor row, or a compound productivity unit such as m/day.
Request body
How duration maps to the activity container
The server maps the entered duration into the container’s base unit before deriving production:- For an hourly rate, the container unit is
day,instanceDuration = duration / hoursPerDay, and the nested child quantity ishoursPerDay. This preserves the entered total hours while expressing production in working days. - For day-, week-, and month-based rates, the container keeps that unit,
instanceDuration = duration, and the nested child quantity is1. - When the mapped duration is greater than zero,
allocatedQuantityis the work-item quantity when that value is positive, otherwise1;productionRate = allocatedQuantity / instanceDuration. - When
durationis zero, the container is structural (non-instance) and holds a zero-quantity child. The estimator can fill it in later.
Response
201 Created —{ container, child }.
Error responses
Appending a child row to an activity container
:id must be an activity container (a component with type: "activity"). A non-container target returns 422.
Request body
The body accepts the same fields asPOST /work-item-components, except that workItemId, type, activityId, subcontractBidRateId, generatedKey, calculator, deliveryActivityComponentId, and all activity-instance fields are omitted. The type defaults to rate_item.
Response —
201 Created — the created child component row. Estimate totals are recomputed.
Error responses
Converting an activity to daily production
allocatedQuantity and productionRate must both be absent), and its quantity must be greater than zero. The container’s unit must match the parent work item’s unit.
The server converts the container’s existing legacy-basis child quantities from absolute (scaled by the container quantity) to per-day-output-unit quantities using the supplied production rate, then sets allocatedQuantity, productionRate, and productionOutputUnit on the container atomically. A child with a non-null quantityBasis is preserved and not rescaled.
Request body
Response —
200 OK — the updated activity container component row. Estimate totals are recomputed.
Error responses
Project work item import
These two endpoints together implement a two-phase import of work items from one project’s estimate into another: the whole schedule, or chosen sections and lines. Both routes are organisation-scoped and require a valid bearer token.Preview
previewToken that must be supplied when confirming the import.
Request body
Response
200 OKError responses
Confirm import
requestId a second time returns the previously recorded result without writing again.
Request body
Import selection
Calculators feeding a brought line are copied only when
calculators is copy.
Whole-schedule append also checks section titles. It numbers the arriving sections first: a top-level section keeps its number when the destination has nothing at or under it, otherwise it takes the next free whole number, and its subsections follow it. If any arriving section then shares a title with a destination section at the same level, the import is refused with 422 project_import_section_title_taken and nothing is written.
Response
201 Created (first delivery) or 200 OK (idempotent replay).Error responses
Work item batch mutations
POST /estimates/:estimateId/work-items/batch applies multiple work-item mutations to a single estimate in one atomic transaction with a single trailing pricing recompute.
Request body
All operation groups are optional, but at least one must be present.Envelope invariants
The request is fully validated before any write. The entire batch is rejected if:- No operation contains any work-item IDs (400).
- Any ID appears in more than one operation group, or appears twice within one group (409 — the response includes a
conflictingIdsarray). - Any ID does not belong to the path estimate (404).
reviewedBidPackageis present alongsidedelete,move,setQuantity, orduplicate(422 — a reviewed bid selection may only be combined withreprice).
Execution order
When multiple operations are present, they run in this fixed order:setQuantity → reprice → move → duplicate → delete. A single recompute covers all writes.
Reviewed bid selection
WhenreviewedBidPackage is supplied, the server validates the whole-return review snapshot before applying the reprice entries. This prevents rates from being applied to work items whose quantities, units, or package membership have changed since the review was opened.
The reviewedBidPackage object requires:
When
reviewedBidPackage is present, all reprice entries must use a selectable bid rate from that package. The batch validates that every named pricingSource of the form "subcontract:{bidRateId}" identifies a bid rate that was selectable in the snapshot — correct package, matching unit and quantity, no warnings that disqualify selection, and no duplicate rates for that work item.
If the snapshot has changed (for example, a bid rate was edited, a work item quantity changed, or the package membership changed since the review was loaded), the server returns 409 with a message prompting the caller to refresh the review before selecting rates. Nothing is written.
If a selected rate is not selectable (missing, wrong package, basis mismatch, or a disqualifying warning), the server returns 422. Nothing is written.
Error responses specific to reviewed bid selection
Batch response
HTTP 200 on success.
A failure in any operation rolls back the entire batch.
Reorder work items within a section
sectionId: null for unsectioned items.
The service takes the estimate lock and compares the supplied IDs with current membership before writing. It rejects partial, duplicated, stale, or filtered sets with
409 work_item_membership_drift; the response includes currentIds so the client can refresh. A missing or cross-organisation section uses the same non-leaking 409 response, while a visible section from another estimate returns 422.
If the requested order would change an SOQ-locked item’s relative position, the service returns 422 Unprocessable Entity with code soq_order_violation and writes no rows. The response body includes offendingIds — the IDs of the locked items that form at least one inverted pair. See SOQ lock for the locking conditions.
The SOQ check compares the relative order of locked items, not their absolute positions. Manual (non-locked) items may be placed anywhere in the list, including between two locked rows. The invariant that must hold is that all locked items appear in orderedIds in the same sequence they currently occupy — this is a subsequence check. A reorder that does not move any locked item past another always succeeds, regardless of what the stored sortOrder values happen to be.
On success the service updates both work-item sortOrder values and their linked top-level programme headers. An empty list is a valid no-op for an empty group. The response is 200 OK with the reordered work items.
After writing the new sort order, the service re-derives itemNumber for every row in the section. See Work item numbering for how numbers are assigned.
Work item numbering
Stralos assigns and maintainsitemNumber automatically on every server write that affects a section’s membership or order. Clients normally omit the field; the server derives one from the section’s own number and the row’s position. Import integrations may supply a contractual number explicitly, which the service stores verbatim.
Section prefix
A section that has asectionNumber uses that value as the prefix. A section with no number uses its 1-based sort position instead (the first section in an estimate is 1, the second is 2, and so on). Unsectioned items have no prefix and are never numbered automatically.
Unfrozen sections
A section is unfrozen when none of its existing rows are SOQ-locked. In an unfrozen section every row receives a dense sequence:{prefix}.1, {prefix}.2, and so on in display order. When a row is inserted, moved, or reordered, the whole section is renumbered to close any gaps and maintain the sequence.
Frozen sections (SOQ-sourced rows)
A section is frozen when it contains at least one SOQ-locked row whose number Stralos must not rewrite. In a frozen section:- Existing rows are never renumbered. Their
itemNumbervalues stay exactly as imported. - A newly inserted row without a number is allocated a number from the row above it. When the new row is appended at the end of the section (nothing follows it), Stralos uses the next sibling — for example,
1.3after1.2. Segment width is preserved, so1.009advances to1.010rather than1.10. When the new row is inserted between two existing rows, Stralos nests under the row above ({above}.1) to avoid claiming a reference a subsequent SOQ row may carry. If the candidate is already taken, the suffix increments until an unused one is found. - A reorder in a frozen section moves
sortOrdervalues but leaves everyitemNumberunchanged.
Inserting a row with an existing number
An imported row that arrives carrying its ownitemNumber keeps it exactly and is treated as a frozen contractual reference, regardless of the estimate’s quantitySource. Setting quantitySource to plans controls whether quantities are editable; it does not unfreeze item numbers on rows that carried their own reference at import time. The section that receives such a row becomes frozen; subsequent inserts nest or use the next sibling as described above.
Column length limit
itemNumber is varchar(80). When the server cannot build a number that fits within 80 characters it leaves the row unnumbered rather than truncating. The row remains visible and the write that created or moved it still succeeds; numbering is a side effect and never causes a write to fail.
Programme header mirror
WhenitemNumber changes, the corresponding top-level programme header is updated in the same transaction.
Server-owned estimate fields
Thestatus, version, and workingVersion fields on an estimate are server-owned. Clients cannot set or change them through POST /estimates or PUT /estimates/:id.
Both explicit
POST /estimates creation and lazy creation through GET /projects/:projectId/estimate use these initial values.
Error responses
Sending a server-owned field in a create or update body returns400 Bad Request:
The API checks the raw request body before schema parsing, so it returns the error even if the supplied value matches the current value. An update that omits all server-owned fields, such as renaming an estimate, is unaffected.
Provisional sums and item kinds
Item kinds
TheitemKind field classifies how a work item is priced. Accepted values are:
lump_sum is retired. Any write that sends itemKind: "lump_sum" stores measured instead — the API never refuses the value, so older clients and integrations keep working. A stored lump_sum row becomes measured on its next save. Lump sums are priced from their build-up like any other measured item.
Provisional sum rules
A provisional sum is a work item whose price is its allowance and nothing else. No build-up, no margin, no sub-items.- Allowance only. The allowance entered on the item is both its cost and its price. Build-up components, activities, and sub-items cannot be added to a provisional sum. Any of these writes returns
422with codeprovisional_sum_no_build_up. - Quantity always 1. A provisional sum’s quantity is coerced to
1on every write. Sending a different quantity is never refused — it is silently replaced. - Unit PS and
itemKindmove together. The unitprovisional_sum(canonicalised fromps) anditemKind: "provisional_sum"are the same fact. Changing either sets the other. A write that changes both to contradictory values returns422with codeprovisional_sum_unit_mismatch. allowanceAmountbelongs to provisional sums. AnallowanceAmountsent for any other item kind is silently cleared, not refused.
Converting an item to a provisional sum
When aPUT /work-items/:id write changes an item’s kind or unit to provisional_sum:
- The item’s entire build-up is soft-deleted (components and sub-items).
- Any quantity link is cleared.
- Any calculator destinations, rate attachments, and pricing selections are removed.
- The allowance is set to the
allowanceAmountin the request body, or to the item’s current total price when none is sent. - The quantity is set to
1.
provisionalSumConversion object:
Converting a provisional sum back to a measured item
Change the unit away fromprovisional_sum (or send itemKind: "measured" with the new unit). The allowance is cleared. The quantity remains at 1 until you update it. The build-up is empty; add components manually.
Work item unit normalisation
Theunit field on work items, takeoff measurements, and subcontract bid rates is normalised to a catalogue code at every write. The API maps common document abbreviations to their canonical equivalents before storing the value.
Examples of recognised spellings and their canonical codes:
The normalisation is case-insensitive and tolerates leading or trailing whitespace.
ML (megalitre) is the one case-sensitive exception. An unrecognised spelling is retained after trimming and collapsing repeated whitespace; no other spelling is guessed.
The four time-unit canonical codes (hour, day, week, month) are also the accepted units for POST /work-items/:id/time-resource-activity. A rate whose unit does not normalise to one of these four returns 422 from that endpoint.
Subcontract bid rates with no unit (unit-less bids) produce a lump_sum mirror component. A unit-less bid represents a lump-sum amount rather than a unit rate.
Update behaviour
PUT update schemas omit owner/parent foreign-key fields for records that should not be re-parented. Omitted fields are left untouched rather than reset to create-time defaults.Clearing work-item notes and clause references
notes and clauseRef use a three-state update contract on PUT /work-items/:id: omission preserves the stored value, an empty string is
coerced to omission and is therefore a no-op, and explicit null clears the
field. Neither field affects pricing totals.
Estimate lifecycle
Estimate status moves through a fixed set of states. Status changes only viaPOST /estimates/:id/transition; direct writes to status on the create or update endpoints return 400.
Statuses
Pricing freeze
A tender estimate is editable only when its status isdraft or review and it has not been hidden as not awarded. Every user write (work items, components, sub-items, calculator links, margin-ladder rungs, pricing rows, subcontract packages and bid rates, reorder operations, matrix inputs, band overrides, companion charges, sections, rate-exception decisions and acceptances, and every field on PUT /estimates/:id) returns 409 estimate_frozen for any other status. The error body includes the current status. Renaming is the one exception; see Frozen estimate writes.
Reads, previews, re-resolve dry runs, lifecycle transitions, variation writes, and version creates are not blocked.
To change pricing after submission, use Revise tender (action: "revise") to return the estimate to draft, make the changes, then submit again.
Transition endpoint
404 { code: "estimate_not_found" } response from that route signals no estimate exists for the project. Global decides the outcome by action: loss, archive, unarchive, and non-award admin overrides can continue with no estimate; award actions fail with baseline_required; and reopen preserves the estimate_not_found failure.
These protected endpoints require a valid EdDSA bearer token.
Request body
User-facing actions — require a user token (not a service token):
System-to-system (S2S) actions — require a service token with the
lifecycle:transition scope plus a forwarded actor:
Response — the updated estimate object on success.
Error codes
One-in-flight guarantee
Only one transition may be in progress for a given estimate at a time. Asubmit or revise action places a pending reservation in the status history until the peer call completes. Any concurrent transition attempt returns 409 transition_in_flight.
An additional per-project guard prevents two estimates from both reading a zero submitted count and both calling submit_tender: any estimate of the project that holds a pending transition blocks a new orchestrated submit or revise on the same project. If another estimate of the project holds a pending transition, the attempt returns 409 transition_in_flight on the project route.
If the peer call result is ambiguous (network timeout, 5xx), the pending reservation is left in place and the background sweeper replays the correlation ID against the project service until it receives a definitive outcome.
Tender revisions
A tender revision snapshot is committed each time asubmit transition completes. The snapshot captures the full priced work-item graph at the moment of submission under a REPEATABLE READ transaction, with a SHA-256 hash for integrity verification.
The current snapshot schema version is v31. New submissions produce v31 snapshots. Stored v1–v30 snapshots remain valid and are read-tolerated.
The estimate’s version field is set to the latest committed revision number (as a string) when each submit transition commits. An accept of a qualifying legacy estimate can also create revision 1 retroactively and set the version to match it.
Quote numbers
Each tender estimate, saved version and revised tender carries its own quote number, allocated by api-global’sPOST /projects/:id/quote-numbers. The project’s original primary estimate uses the project’s own quote number. Other estimates are numbered when they are created or copied, a saved version when it is saved or first listed, and the working estimate again when Revise tender or Reopen returns it to Draft. Delivery estimates have none.
GET /estimates/:id and GET /projects/:projectId/estimate number a tender estimate that has no quote number yet, and submit and accept do the same before they run. If api-global cannot allocate a number, the request returns 502 and can be retried: the allocation is keyed by a stable identity, so a retry reuses any number already allocated.
Tender downloads, the submission PDF and work item spreadsheet exports print the estimate’s quote number.
List revisions
quoteNumber is the quote number the estimate carried when that revision was submitted, or null for a revision submitted before quote numbers were recorded on revisions.
Response — 200 OK
GET /estimates/:id/revisions/:revision, which returned one revision with its full snapshot, is retired and returns 404. The sign-off and comparison views read through their own endpoints.
Margin ladder
The pricing ladder resolves a margin or markup percentage for each component according to the estimate’s frozenpricingBasis. It uses the same five-rung order on both bases:
- Component override — a per-rate override set directly on the component row (
margin_pct_override). - Item category — the component’s rate category.
- Rate group — the component’s rate group (labour, plant & vehicles, bulk materials, materials, subcontractors, or disposal).
- Tender default —
defaultMarginPctordefaultMarkupPct, according to the frozen basis. - Organisation default — the organisation’s global percentage, frozen onto the estimate at creation.
Component override (marginPctOverride)
marginPctOverride is a writable field on a component row. It is accepted by four write endpoints: POST /work-item-components, PUT /work-item-components/:id, POST /work-item-components/:id/children, and POST /work-items/:id/time-resource-activity.
The body schema accepts the union of the two basis bands (whole percent, 3 dp, −100 to 999.999) because the schema is parsed before any estimate is read. The estimate’s frozen pricingBasis is checked inside the write transaction. If the supplied value is outside the band that applies to this estimate, the server returns 400 component_margin_override_out_of_band naming the basis and the allowed range.
null clears the override so the component inherits from the next rung. Omitting the key preserves the stored value on update.
At pricing time, a stored override that is outside the band for the estimate’s basis is dropped and the next rung resolves instead. This means a value copied from a markup tender into a margin one cannot cause a recompute error.
The organisation default and per-category and per-rate-group percentages are frozen onto the estimate at creation (copied from the organisation’s Global Settings). Pricing reads only from that frozen copy — it does not call the Global API at recompute time. The frozen values appear on the GET response as organisationPercent on each rung alongside whatever the tender itself has set.
These routes require organisation context and access to the estimate in that organisation. An unknown or foreign-organisation estimate returns 404; organisation-admin status is not required.
Get the margin ladder
"organisation" or "tender"), and the frozen organisation value underneath — enough information to display the pricing-basis panel and offer a Reset to organisation action without a second read.
Response — 200 OK
Each
rungs entry:
Set a tender rung
affectsTotals gate.
The organisation default rung (scopeKind: "organisation") cannot be set here. To change the tender’s own default, use defaultMarginPct or defaultMarkupPct on PUT /estimates/:id, according to the estimate’s basis.
Request body
Response — 200 OK — the full updated ladder in the same shape as the GET response.
Error codes
Reset a tender rung
204 because the requested end state already holds.
:scopeKind must be "rate_group" or "category". Attempting to delete the "organisation" rung returns 400 margin_ladder_organisation_rung_not_editable.
Response — 204 No Content
Error codes
Estimate workspace versions
Workspace versions let you save immutable snapshots of an estimate’s editable workspace at any point during estimating. Each saved version captures the full priced work-item graph including sections, components, pricing, takeoff measurements, subcontract packages, programme items, and tender clauses. Versions are independent of the tender revision flow: a workspace version can be saved at any lifecycle status, and saving one does not change the estimate’s status or tender revision history. All workspace-version endpoints are protected. A missing or invalid bearer token returns401 auth_required; an authenticated caller without organisation context returns 403.
The workingVersion field on the estimate tracks the current version number. It is server-owned and advances to the next integer each time a version is saved or branched.
List workspace versions
200 OK
isCurrent is true for the version whose number matches the estimate’s workingVersion. sourceVersionId is non-null when the version was branched from a historical one.
versionEstimateId is the ID of the read-only saved-version estimate that holds this version’s rows; the estimate screen uses it to open a past version read-only. restorable is true when versionEstimateId is set. Versions saved in the older format have versionEstimateId: null and restorable: false: they can be viewed but not restored or duplicated.
Each version object includes a summary field with frozen money read directly from the saved snapshot. These values are never repriced or recalculated after saving.
Error responses
Save a workspace version
workingVersion advances to the new version number.
Request body
Response —
201 Created — the saved version summary object, including the summary field with frozen money.
Error responses
Branch a version
workingVersion advances to the new version number.
Use this to return to an earlier pricing state without losing the history of intermediate versions.
:version is the integer version number (1-based) to branch from.
Request body
Response —
201 Created — the branched version summary object, including the sourceVersionId pointing at the historical version it was created from, and the summary field with frozen money read from the restored snapshot.
Error responses
Baseline supersede
accept transition returns 409 baseline_conflict because the existing baseline is referenced by variations.
Requires the admin role. Authentication is always enforced.
Existing variations retain their original baseline. New variations bind to the new active baseline.
Request body
Response —
201 Created — the newly created baseline and its items (same shape as POST /estimates/:id/baseline).
Error codes
Linked work-item quantity
A linked quantity is a work-item quantity that Stralos derives automatically from one or more sources and re-resolves whenever those sources change. Sources can be calculator readings (a specific layer and output from a project calculator), all whole-item build-up rows of a rate item on that work item, or a takeoff measurement. The resolved value isΣ sources × multiplier + offset, rounded up when the link carries a rounding.
Quantity link descriptor
ThequantityLink field on PUT /work-items/:id accepts a descriptor or null:
Linking or replacing a link
Each entry in
sources is one of three shapes:
Calculator source
Rate-item source
Takeoff source
Unlinking
Send
quantityLink: null to unlink. The work item keeps its current quantity as a manual origin value (or stays import for an SOQ line).
Quantity origin
A linked work item hasquantityOrigin: "derived". An SOQ-locked item (quantityOrigin: "import") that is linked stays "import" — its measured-alongside value is what moves. Unlinking restores "manual" (or leaves "import" on an SOQ line).
Write rules
A typed quantity that differs from the stored value on a linked item is refused422 quantity_linked. An equal resend (a full-row echo) passes without error. Unlink the item before typing a new quantity.
A new or replaced link is checked before anything is written. The server refuses:
The batch
setQuantity operation applies the same quantity_linked guard: a linked item’s quantity cannot be set via the fill-handle batch. The entire batch rolls back if any item in it is linked and being given a different quantity.
Activity followers on quantity change
When a work item’s quantity changes — whether through a typed edit, a link resolution, or a batch set — Stralos also updates any top-level activity container that was allocated exactly the previous quantity in the same unit. These followers are reported in the response:
Skipped reasons:
Linked quantity re-resolution
Every write that can move a source — a calculator save, a calculatorPUT /calculators/:id/links, a takeoff measurement update, or a POST /estimates/:id/recompute-totals — re-resolves linked quantities for the affected estimate as a pre-pass before pricing. Frozen estimates are skipped (D14).
When a re-resolution moves a linked item’s quantity (a derived move), the write response includes:
POST /work-item-components, PUT /work-item-components/:id, and calculator write routes when at least one linked item moved.
Delete guards
Deleting or modifying a source that a linked item uses is refused409 until confirmed.
Takeoff measurement delete — DELETE /takeoff-measurements/:id
Returns 409 measurement_in_use with a usage array when at least one live work item’s link names that measurement:
?unlink=true to confirm. The server unlinks each using work item entirely (not just the deleted source), keeping its last quantity as a manual value. The response includes unlinked (each item and its retained quantity) and workItemsChanged (the derived moves the re-resolution triggered).
Map sheet delete — DELETE /takeoff-map-sheets/:id
Applies the same measurement_in_use guard over all measurements on the sheet. ?unlink=true unlinks and removes the sheet in one step.
Calculator delete — DELETE /calculators/:id
Returns 409 calculator_in_use with a usage array listing each work item that links to any layer of the calculator. Add ?unlink=true to confirm; the server unlinks whole items and reports them in unlinked.
Calculator save (PUT) — PUT /calculators/:id or PUT /calculators/:id/links
If the save would stop the calculator emitting a layer that a link names, the server returns 409 calculator_source_removed before writing anything:
?unlink=true to confirm. The calculator is saved and the affected items are unlinked in the same transaction. Both calculator saves and deletes trigger a full re-resolution pass unconditionally afterwards.
Get quantity link candidates
Each
calculators entry:
Each
readings entry:
Each
rateItems entry:
Excluded row reasons:
Each
takeoffs entry:
Error responses
Creating a work item from a measurement
POST /estimates/:id/work-items/from-measurement now creates the new work item with a takeoff link by default. When the estimator accepts the measurement’s quantity as-is and the measurement’s unit shares a dimension with the item’s unit, Stralos sets quantityOrigin: "derived" and stores a quantityLink pointing at the measurement. The item’s quantity then tracks the measurement automatically.
If the estimator overrides the quantity (types a different value) or uses a unit in a different dimension, the item is created as a manual item with no link.
The measurement’s work_item_id back-pointer continues to record the “created from” relationship and is not used to resolve linked quantities.
Project calculators
Project-level calculators feed physical quantities into one or more work items. Pricing belongs to each consuming work-item line. Every route is organisation scoped, and each mutation holds the estimate totals lock while it reconciles rows and recomputes pricing.List calculators
{ items }, sorted by calculator name. Each calculator includes its configuration, assignment links, default destination, distinct Used by destinations, and linkedUseCount.
linkedUseCount is the number of live work items and build-up lines whose quantityLink names this calculator. A work item or line that reads several of the calculator’s readings counts once. This is the count behind the “ƒ n uses” indicator in the Calculations list.
Create a calculator
Returns
201 { calculator, warnings, engineWarnings }. Pricing data in the configuration, a malformed or misplaced backfillFrom, or an ineligible destination returns 422. A fresh reinstatement selection whose referenced stratum has canUseForReinstatement: false returns 400 calculator_config_backfill_from_ineligible.
Copy a calculator
201 { calculator, warnings, engineWarnings }.
Update or rename a calculator
A body containing only
name renames the calculator without running the engine, rewriting links, reconciling rows, or repricing. Supplying config, defaultDestination, or inputLinks performs a full save. Inheriting links follow a changed default; explicit links remain on their selected targets.
When a config or inputLinks save would reconnect a row that an estimator previously unlinked, the server returns 409 calculator_unlinked_rows instead of saving. The caller must supply unlinkedRowChoices to resolve each pending row before the save proceeds.
Returns 200 { calculator, warnings, engineWarnings }.
Unlinked row resolution
WhenPUT /calculators/:id carries config or inputLinks and the calculator has rows that were previously unlinked (via POST /work-item-components/:id/unlink-calculator), the server checks whether the updated engine would re-create a row at the same destination. If it would, the server returns 409 with code calculator_unlinked_rows and a list of pending rows.
A measurement edit that re-resolves a linked input field does not ask. When a takeoff measurement changes and a linked calculator input is updated as a follow-on, the server reconciles without triggering the 409: the unlinked row is left as-is and the conflict check is deferred to the next explicit save.
The error body contains:
PUT with unlinkedRowChoices to confirm how each row should be handled:
Up to 500 entries are accepted. Once the choices are applied, the unlinked row record is cleared and subsequent saves proceed without the conflict check.
Replace assignment links
{ links: [...] } with at most 500 entries:
The replacement is atomic. Fill layers must have one whole-layer destination. Excavated-stratum shares may total less than one, which returns a warning, but cannot exceed one. The API reconciles every destination reached by the calculator and returns
200 { calculator, warnings, engineWarnings }.
Delete a calculator
keepRows is required. true detaches generated rows so they become ordinary editable lines; false deletes them. The response reports how many rows were detached or deleted.
The delete is soft. The calculator is stamped deletedAt, and with keepRows=false the rows it generated share that one stamp. Its links are kept as history, but every write route refuses a deleted calculator (409 deleted), so nothing regenerates rows from them. A repeat DELETE returns 200 with both counts 0.
The calculator’s own activity. A calculator whose destination is a work item itself puts its lines in a manual activity named after it. The delete soft-deletes that activity too, with the same stamp, when all of these hold: the calculator names it as its default destination or in a link; it is a live top-level manual activity (no library activity, rate item, generated key, or bid mirror); it still carries the calculator’s current name and has no live value override; it was created no earlier than the calculator; and it is empty once the rows are gone (no line, companion, delivery link, or other live calculator sending lines to it). With keepRows=true the kept rows sit inside it, so it stays.
Quantity-link usage check. If any live work item’s or build-up line’s quantityLink names this calculator, the delete returns 409 calculator_in_use with a usage array listing the affected items. Add ?unlink=true to confirm: Stralos removes each quantity link and keeps the last resolved value as a manual quantity. The response then includes:
unlinked— work items whose quantity link was removed, each with their keptquantity.unlinkedComponents— build-up lines whose quantity link was removed, each withcomponentId,quantity, and alabelof the form<item number item name> · <line name>.
Unlink a calculator row
generatedKey is cleared, and the calculator no longer manages it.
The calculator records the unlinked row. When the same calculator is later saved with a config change that would re-create a row at that destination, the server returns 409 calculator_unlinked_rows until the caller resolves the conflict via unlinkedRowChoices on PUT /calculators/:id. See Unlinked row resolution.
Response — 200 OK
Engine warnings (engineWarnings)
engineWarnings is an array of advisory objects the engine emits alongside the calculator response. Each object has a kind string and optional numeric fields. Clients must tolerate kinds they do not recognise — new advisories are added by a pin bump alone.

