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

# Domain model

> Shared domain entities and calculations

`stralos-domain` contains shared construction domain types and calculations. User-facing docs should explain concepts; this page records implementation-facing entity boundaries.

## Published packages

Four packages are published independently from this repository:

| Package | Version tag prefix | Contents |
| - | - | - |
| `@stralos-co-nz/domain` | `v*` | Domain entities and compatibility types. |
| `@stralos-co-nz/base` | `base-v*` | Shared primitives and taxonomies. |
| `@stralos-co-nz/calculators` | `calculators-v*` | Physical quantity engines for trench, pit, and volume calculators. |
| `@stralos-co-nz/pricing` | `pricing-v*` | Money and pricing rules. |

The dependency graph from D85 (`stralos-domain` v1.0.0):

* `base` — no first-party dependencies. Owns `EntityId`, shared primitives, rate-group taxonomy, build-up grouping, duration helpers, the stored-config contract (`WorkItemComponentCalculator` and its full type closure), and the organisation/project time-zone resolver and date-in-zone helpers (base 1.5.0).
* `calculators` — depends on `base`. Re-exports every name from the stored-config contract unchanged so its public surface is identical to pre-D85.
* `pricing` — depends on `base`.
* `domain` — depends on `base` and `pricing` only. It no longer depends on `calculators`.

`domain` exports `WorkItemComponentCalculator` (and `EntityId`) by re-exporting them from `base`; it holds no calculator types of its own. Calculator engines remain in `@stralos-co-nz/calculators`.
`base` has no first-party package dependencies. `calculators` depends on `base`; `pricing` depends on `base`; `domain` depends on `base` and `pricing` only. The `WorkItemComponentCalculator` type contract — the stored-config shape used by `Activity.calculator` and `WorkItemComponent.calculator` — lives in `@stralos-co-nz/base` (D85), where both `domain` and `calculators` can reference it without the two packages depending on each other. Calculator engines — every function that reads a config and computes from it — remain in `@stralos-co-nz/calculators`, which re-exports all type names from `base` unchanged.

## Core entities

* Project
* Project membership (`ProjectDirectoryEntry`, `ProjectAccessRequest`)
* Project contract profile
* Party (companies register: unified client, supplier, and subcontractor record)
* Client, Supplier, Contractor (legacy compatibility types retained for consumers still being migrated)
* Rate item, rate option, and rate option scope (`RateOptionScope`)
* Activity and activity component
* Estimate
* Estimate section
* Takeoff measurement
* Work item and work item component
* Programme item and dependency
* Subcontractor package and bid rate
* Contract baseline and baseline item
* Variation, variation line, variation revision, and variation event
* Drive source, sync connection, sync run, and synced file (cloud-drive document sync)
* Scheduler person, plant, crew, assignment, unavailability, project requirement, project resource (crew-scheduling M1), and plan task (plan M0)

## Modelling rules

* Project is the root for project-specific workflows.
* IDs are strings.
* Geotagged entities use a shared geotag shape.
* Money, units, dates, quantities, and confidence values use explicit fields.
* Module APIs reference shared records by ID rather than copying source records.

## Project fields

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Server-assigned. |
| `clientId` | `EntityId` | Owning company. Project writes verify organisation ownership but do not re-check the company's current role flags. |
| `name` | `string` | Project display name. |
| `reference` | `string \| null` | The client's own reference. Omit on update to preserve it; send `null` to clear it. |
| `status` | `Project["status"]` | See [Project status](#project-status) below. |
| `geoTag` | `GeoTag` | Project location. |
| `startDate` | `string?` | ISO date. |
| `targetSubmissionDate` | `string?` | ISO date. |
| `endDate` | `string \| null` | Project window end. |
| `colour` | `string \| null` | Colour override. |
| `quoteNumber` | `string \| null` (optional) | Read-only. The project's initial quote number, allocated by api-global when the project is created. Each estimate alternative and saved version holds its own quote number in api-estimate. |
| `jobCreatedAt` | `string \| null` (optional) | Read-only. ISO 8601 time the project became a job, set when an estimate is marked Won. `null` until then. |
| `jobNumber` | `string \| null` (optional) | Read-only. Organisation-assigned job number, allocated when an estimate is marked Won and immutable after assignment. `null` until then. |

The three identity fields are optional on the type (domain 4.0.1) so older consumers' fixtures and mappers still compile.

## Project membership access

Explicit project membership is defined in `stralos-domain/src/projectAccess.ts` and exported from the `@stralos-co-nz/domain` barrel. It models which users can enter or manage a project.

### `ProjectRoleCode`

```typescript theme={null}
type ProjectRoleCode = "member" | "lead";
```

Two role codes are defined. Entry membership is independent of organisation-level role assignments; the set can grow in future milestones without breaking existing consumers.

The domain no longer exports a membership record type: `ProjectMembership` and `ProjectRoleAssignment` were removed in domain 4.0.0 because no repo imported them. Consumers declare their own.

| Code | Meaning |
| - | - |
| `member` | The user has standard project access. |
| `lead` | The user holds a lead role on the project. |

### `ProjectDirectoryEntry`

A lightweight project listing record, used when rendering the project directory. It deliberately excludes financials, coordinates, documents, team composition, and activity to keep the listing safe for broader consumption.

| Field | Type | Notes |
| - | - | - |
| `id` | `string` | Project ID. |
| `quoteNumber` | `string \| null` (optional) | The project's quote number. Omitted when the caller cannot enter the project. |
| `jobNumber` | `string \| null` (optional) | Organisation-assigned internal job number. Omitted when the caller cannot enter the project; `null` when the caller can enter but no estimate has been marked Won. |
| `name` | `string` | Project display name. |
| `clientId` | `string` | Owning company. |
| `clientName` | `string` | Display name of the owning company. |
| `region` | `string \| null` | Project region. |
| `status` | `string` | Current project status value. |
| `targetSubmissionDate` | `string \| null` | ISO date. |
| `startDate` | `string \| null` | ISO date. |
| `leads` | `{ id: string; name: string }[]` | Users holding the `lead` role on this project. |
| `canEnter` | `boolean` | `true` when the requesting user can open this project. |
| `canManage` | `boolean` | `true` when the requesting user can change project settings or membership. |
| `roles` | `ProjectRoleCode[]` | The role codes held by the requesting user on this project. Empty when the user has no explicit membership. |

### `ProjectAccessRequest`

A request raised by a user seeking access to a project they cannot currently enter.

| Field | Type | Notes |
| - | - | - |
| `id` | `string` | Request ID. |
| `userId` | `string` | The requesting user. |
| `reason` | `string` | The reason supplied by the requester. |
| `state` | `"pending" \| "approved" \| "declined" \| "withdrawn"` | Current lifecycle state of the request. |
| `createdAt` | `string` | ISO timestamp. |
| `decidedAt` | `string \| null` | ISO timestamp of when the request was approved or declined. `null` while pending. |
| `escalatedAt` | `string \| null` | ISO timestamp of when the request was escalated. `null` if not escalated. |

## Project readiness wire contract

`src/projectReadiness.ts` (domain 3.1.0 or later) holds the error codes services answer with while a page waits for a service-side project record, and the pure classifier that maps a failed read onto what the page should do next. The codes are API contract, so they live here; `useProjectReadiness` in `@stralos-co-nz/ui` is the polling engine and takes the classifier through its required `classify` option (see the [Design library](https://help.stralos.app/internal/design-library.md)).

| Constant | Value | Status | Meaning |
| - | - | - | - |
| `PROJECT_NOT_PROVISIONED_CODE` | `"project_not_provisioned"` | 409 | The project exists but this service's record has not been created yet. Sent with `Retry-After: 5`. |
| `PROJECT_NOT_FOUND_CODE` | `"project_not_found"` | 404 | No such project, or not in the caller's organisation. |
| `PROJECT_ACCESS_REQUIRED_CODE` | `"project_access_required"` | 403 | The project exists but the caller is not on its team. |

api-estimate emits the 409 on its lazy-create read, and api-global and the per-service project-access mappers emit the others.

`classifyProjectReadinessError(error: unknown): ProjectReadinessOutcome` returns `"preparing"`, `"not-found"`, `"no-access"`, or `"unavailable"`. It reads a numeric `status` (or `statusCode`) off the error, and `code` off the error or its parsed `body`. It never throws.

| Input | Outcome |
| - | - |
| `project_not_provisioned` with status 409 or no status | `"preparing"` |
| Status 404, or `project_not_found` | `"not-found"` |
| Status 403, or `project_access_required` | `"no-access"` |
| Anything else: a network error, a 5xx, an unrecognised response | `"unavailable"` |

A non-numeric status or non-string code counts as absent. `slow` and `stalled` are never classified; the hook derives them from elapsed time. A remote with its own error class can wrap the classifier and remap outcomes, as Project Settings does for estimate reads.

## Companies register

`Client`, `Supplier`, and `Contractor` are consolidated into a single `Party` entity in the companies register. A party carries three role booleans (`isClient`, `isSupplier`, `isSubcontractor`) so one commercial entity can hold multiple roles without duplicate rows. The domain package still exports the legacy types unchanged for consumers that have not completed their migration.

See [Companies register](https://help.stralos.app/internal/companies-register.md) for the full type reference, approval status rules, `rateSource` load-bearing notes, and location ownership changes.

## Status lifecycles

### Project status

Project status is a fixed enum:

| Value | Lifecycle meaning |
| - | - |
| `lead` | Opportunity identified. |
| `estimating` | Estimate preparation in progress. |
| `submitted` | Tender or proposal submitted. |
| `awarded` | Contract awarded. |
| `active` | Project active. |
| `completed` | Project completed. |
| `lost` | Tender not awarded. |
| `archived` | Project closed, withdrawn, or superseded and retained for reference. |

`lost` records an outcome and can later be reopened; `archived` preserves the prior stage for a later unarchive action.

**Lifecycle milestones:**

* M1 added the full enum (`lost`, `archived`) and the server-ownership guard that prevents status changes via the project update endpoint.
* M2 adds `POST /projects/:id/transition` with four standalone actions (`begin_estimating`, `revert_to_lead`, `start_project`, `complete`), the admin override action (`admin_set_status`), and the append-only `project_status_history` table. Coupled actions (`submit_tender`, `revise_tender`, `mark_won`, `mark_lost`, `reopen`, `archive`, and `unarchive`) returned `501` until M3.
* M3 added coupled actions that orchestrate a matching status change in `stralos-api-estimate` under the same `correlationId`. Each action uses an intent-first protocol: tx1 records one pending transition, the peer service runs its guarded action, and tx2 atomically commits history and the project status. Definitive peer rejections abort; ambiguous failures stay pending for the lifecycle sweeper. The partial unique index on pending history enforces one in-flight transition per project. `archived_from` preserves the status restored by `unarchive`, and admin overrides targeting `awarded` run the same baseline guard as `mark_won`.
* M4 adds `GET /projects/stats`, which returns current project counts by status (`countsByStatus`) and a win-rate summary (`winRate`) over a configurable time window. Window boundaries are calendar days in the organisation's IANA timezone (`organisations.timezone`, defaulting to `Pacific/Auckland`). The timezone field is server-owned and not editable via the API.
* Crew-scheduling M3 (current) adds `GET /projects/statuses` (batch S2S read for the scheduler service), the nullable `endDate` project window field, and nullable `colour` fields on both projects and companies.
* Nav M5 (0.11.0) adds `projectStatusLabels.ts` — the single authoritative resolver for project status display names. See [Project status display names](#project-status-display-names) below.

### Project status display names

Display names for project statuses are owned by the `project_statuses` **setting list**, not by code. All consumers — the dashboard shell, project settings, and both scheduler boards — must resolve labels through `resolveProjectStatusLabel` (exported from `stralos-domain/src/projectStatusLabels.ts`) so every surface shows the same string.

**How resolution works:**

1. If the setting list is still loading, failed to load, or returned empty, `isFallback` is `true` and `resolveProjectStatusLabel` humanises the raw enum value using `formatStatusLabel` (e.g. `"estimating"` → `"Estimating"`, `"IN_PROGRESS"` → `"In progress"`). Do not echo the raw fallback label verbatim — it is lowercase and intended only as a built-in placeholder.
2. If the list loaded, the setting-list label is used verbatim. A platform-admin relabel (e.g. `"estimating"` → `"Tendering"`) wins over any seeded or code-side value.
3. If the loaded list has no row for a given value (a status added to the enum before the list was reseeded), `resolveProjectStatusLabel` falls back to `formatStatusLabel`.

The seed display names (from `stralos-workspace/seed-hardcoded-api-data.mjs`) map the IA vocabulary onto the raw enum: `estimating` → `"Pricing"`, `awarded` → `"Won"`, `active` → `"Live"`. The other five, `lead` included, read as the raw value sentence-cased — `lead` → `"Lead"`. These are the first labels each row ever has; a platform admin can overwrite them in the admin console.

**Scope and permissions:**

`project_statuses` rows are platform-scoped (`organisation_id NULL`). A relabel is suite-wide — it applies to every organisation. An organisation admin cannot change these labels: the Settings surface renders the list read-only and every write returns `403`. Only a platform admin via the admin console (`stralos-admin-api PLATFORM_ENUM_KEYS`) can relabel a project status.

**`formatStatusLabel`:**

`formatStatusLabel` is a pure text transform — it is not project-specific. Use it for status enums with no authoritative display-name list and for unknown values. When a setting list owns the display name, use the corresponding resolver instead. Import the helper from `stralos-domain`; do not reimplement it in a service or remote.

**`PROJECT_STATUS_VALUES`:**

`PROJECT_STATUS_VALUES` is the canonical lifecycle-ordered list of all eight project statuses. Use it as both the built-in fallback for the `project_statuses` setting list and the option list for status pickers and filters. Its source object is typed as `Record<Project["status"], true>`, so adding a new status is a compile error rather than a silent omission.

### Estimate kind

`EstimateKind` is a three-value domain contract for an estimate's role (domain 3.9.0, PRD 210):

| Kind | Meaning |
| - | - |
| `tender` | A priced tender for submission. A project may hold multiple tender estimates (PRD 210 phase 2) when its **Multiple estimates** setting is on. Exactly one is `isPrimary`; that estimate opens on project load. |
| `delivery` | A post-award working copy of the won tender estimate. At most one live Delivery estimate exists per project. |
| `saved_version` | A frozen, never-listed save point of the workspace state. |

`kind` is optional on the `Estimate` type — an estimate served before PRD 210 carries no `kind` value and behaves as `tender` (the column defaults to `'tender'`).

### Estimate outcome

`EstimateOutcome` records the award result for a tender estimate (domain 3.9.0, PRD 210 §7.1, §8.2):

| Outcome | Meaning |
| - | - |
| `won` | This estimate was awarded the contract. |
| `not_awarded` | This estimate was not awarded when the project was marked won. |

The award action is implemented from PRD 210 phase 3. When a project is marked won, the named tender estimate receives `outcome: 'won'` and becomes the primary estimate. Every other live tender estimate receives `outcome: 'not_awarded'` and is hidden (`hiddenAt` stamped). Hidden estimates are read-only; they are the record of alternatives considered but not awarded.

### Estimate status

The domain package defines `EstimateStatus = TenderEstimateStatus | DeliveryEstimateStatus`. Tender and Delivery estimates share the `status` column; each kind uses its own transition route and the routes never cross over (`POST /estimates/:id/delivery-transition` for Delivery; `POST /estimates/:id/transition` for tender). See [Delivery estimate (PRD 210 phase 4)](https://help.stralos.app/internal/estimate-service.md#delivery-estimate-prd-210-phase-4) for the Delivery sign-off workflow.

Estimate status and version are server-owned. Status changes only via `POST /estimates/:id/transition`; version advances to the latest committed revision number when a `submit` transition commits.

**Tender estimate statuses (`TenderEstimateStatus`):**

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

**Delivery estimate statuses (`DeliveryEstimateStatus`):**

| Status | Meaning |
| - | - |
| `draft` | Delivery estimate being prepared. |
| `in_sign_off` | Sent for sign-off approval. |
| `signed_off` | Signed off by a project lead. |

User-facing transitions (`send_to_review`, `return_to_draft`, `submit`, `revise`) require a user token. System-to-system transitions (`accept`, `reject`, `reopen`, `archive`, `unarchive`, `admin_set_status`) require a `lifecycle:transition`-scoped service token.

The `submit` and `revise` actions are orchestrated: the estimate service calls api-global's project transition peer under the same `correlationId`. A pending-transition sweeper replays ambiguous peer calls until a definitive outcome is recorded. Each `submit` commits a tender revision snapshot in `estimate_revisions`. The `accept` transition runs an award guard that creates or binds a contract baseline from the accepted revision.

### Estimate PRD 210 fields

The following fields were added to the `Estimate` type in domain 3.9.0 (PRD 210). All are optional so an estimate served before PRD 210 still satisfies the type.

| Field | Type | Notes |
| - | - | - |
| `kind` | `EstimateKind \| undefined` | The estimate's role. Absent on pre-PRD 210 estimates; treated as `"tender"` by consumers. |
| `isPrimary` | `boolean \| undefined` | `true` on the tender estimate that opens on project load. Exactly one estimate per project is primary (R2). |
| `outcome` | `EstimateOutcome \| null \| undefined` | Set by the mark-won action; `null` or absent before award. |
| `hiddenAt` | `string \| null \| undefined` | ISO 8601 timestamp set when the estimate was hidden at award (R3). `null` while the estimate is listed. |
| `sourceEstimateId` | `EntityId \| null \| undefined` | The estimate this one was copied from (duplicate or Delivery). `null` for a blank estimate. |

The estimate API also returns `signedOffRevisionId` (a Delivery estimate's latest signed-off revision; `null` on every tender), which the domain `Estimate` type does not declare.

**Lifecycle milestones:**

* M1 added the server-ownership guard that prevents setting `status` or `version` on create or update.
* M3 added `POST /estimates/:id/transition` with the full state machine, tender revision snapshots (`estimate_revisions` table), status history (`estimate_status_history` table), the pending-transition sweeper, and baseline-bound award logic.
* Org-security M3 applied FORCE RLS and `organisation_id` to all twenty estimate tenant tables, introduced the three-connection runtime layer, and added the SECURITY DEFINER portal resolver for the unauthenticated subcontractor portal. See [Organisation security](https://help.stralos.app/internal/org-security.md) for the isolation model and [Runbooks](https://help.stralos.app/internal/runbooks.md#org-security-m3-backfill-and-migration-estimate-api) for the deployment sequence.
* Domain 3.9.0 (PRD 210 phase 1) added `EstimateKind`, `EstimateOutcome`, `TenderEstimateStatus`, `DeliveryEstimateStatus`, and the `kind`, `isPrimary`, `outcome`, `hiddenAt`, and `sourceEstimateId` fields on `Estimate`. `EstimateStatus` widened from the six tender values to `TenderEstimateStatus | DeliveryEstimateStatus`.
* PRD 210 phase 2 lifted the one-tender-per-project constraint (migration 0093). A project may hold multiple tender estimates while its **Multiple estimates** project setting is on.
* PRD 210 phase 3 implemented the multi-estimate award action: several tender estimates can be submitted at once. Marking the project won names one estimate as the winner (`outcome: 'won'`, `isPrimary: true`); the others receive `outcome: 'not_awarded'` and are hidden. Project-level actions (`reject`, `archive`, `reopen`, `unarchive`) fan out to the active tender estimates under one correlation ID.
* PRD 210 phase 4 implemented the Delivery copy, tracking sell and sign-off state machine. The Delivery routes are live only where `ESTIMATE_DELIVERY_ENABLED` is on (dev; off in test and prod).

### Deprecated `Estimate` fields

`Estimate.projectScope` (`@deprecated`, domain 3.8.0) — the field is kept optional in the domain type until the next major version and will be removed then. No client ever populated it, and `api-estimate` no longer stores or returns `estimates.project_scope` (api-estimate PR 372). Do not read or write `projectScope` in new consumers. See [Estimate API — retired estimate field](https://help.stralos.app/api/estimate.md#retired-estimate-field) for the API-level behaviour.

## Work item component calculator

Work items, work item components, and activities can carry a `calculator` field that holds a structured calculator configuration. `WorkItemComponentCalculator` is a discriminated union with three mutually exclusive arms on the same slot:

| `type` | Arm | Derives |
| - | - | - |
| `"trench"` | — | Excavation, reinstatement, disposal, and pipe quantities from cross-section inputs. |
| `"pit"` | `PitCalculatorArm` | Excavation, per-stratum disposal, bedding, an in-situ concrete pad, and backfill around and above a list of structures. |
| `"volume"` | `VolumeCalculatorArm` | Excavation, per-stratum disposal, and a bottom-anchored reinstatement stack over a bulk volume. |

The engine for all three lives in `@stralos-co-nz/calculators` (`packages/calculators/src/` in the `stralos-domain` repository) and is executed server-side inside the save transaction; the modals import the same functions for preview only. Consumers migrate to the independently versioned calculator package separately; current package pins must be checked before documenting a new calculator contract as application behaviour.

See [Trench calculator](https://help.stralos.app/internal/trench-calculator.md), [Pit / Structures calculator](https://help.stralos.app/internal/pit-calculator.md) and [Volume calculator](https://help.stralos.app/internal/volume-calculator.md) for the full type references and normalisation rules.

The calculators package also defines `QuantityLinkV1`, a descriptor that links a work item's quantity to the sum of calculator readings, takeoff measurements, or rate-item build-up rows, and its resolver. See [Linked work-item quantity](https://help.stralos.app/internal/quantity-link.md).

## Crew scheduling model

Crew and resource scheduling domain types are defined in `stralos-domain/src/scheduler.ts` (crew-scheduling PRD §5.12). Entity interfaces mirror the `stralos-api-scheduler` database schema: eleven entity tables (persons, licences, endorsements, competencies, plant, crews, crew members, assignments, unavailability, project requirements, and project resources), all with closed-union status/kind columns.

`POST_AWARD_STATUSES` (`["awarded", "active", "completed"]`) is the project status subset that permits an assignment to be confirmed (gate D6). It is coupled to `Project["status"]` via a `satisfies` constraint so the subset cannot drift silently if the project lifecycle union changes.

`autoProjectColourSlot` maps a project UUID to a stable chart-palette slot (1–6) using a shared FNV-1a 32-bit hash. Consume it from `stralos-domain`; never re-implement it in a remote or service.

Plan M0 adds `SchedulerPlanTask` — a work breakdown structure (WBS) node stored in `scheduler_plan_tasks`. Tasks are self-referential (parent/child) within a project. `SchedulerAssignment` now carries a nullable `planTaskId` field; an assignment linked to a task with children is a scope booking. The plan-delete route detaches assignments before removing a task.

## Project rate rules

`stralos-domain/src/projectRateRules.ts` still exports the earlier `supplier`, `source_location`, `location`, and `policy` model, with pointer and provider-kind fields. That package export is a legacy contract; it is no longer the live shared contract for Rates, Estimate, or Project Settings. Do not use it for new service integrations.

The current services use their own project-scoped, ordered `source` and `policy` rules. A `source` selects an owner and an optional location through `partyId` and `locationId`; a null location selects only the owner's location-less prices. A `policy` selects the cheapest eligible price within a road-distance radius. Rules can target an item, category, or group. They are evaluated at their positions alongside the pricing-location entry, and the first eligible entry in that preference list wins. Rules do not carry money. See [Rates API — project rate context and rules](https://help.stralos.app/api/rates.md#project-rate-context-and-rules) and [Estimate API — re-resolve prices](https://help.stralos.app/api/estimate.md#re-resolve-prices) for the live wire and resolution contracts.

> [!WARNING]
>
> **`ProjectRateOverride` no longer exists in `stralos-domain`.** The type was retired with the project rates migration and has been removed from the domain barrel export. Do not reference it in new consumers. Use the project rates types and routes (`/api/rates/project-rates`) instead. The retired `/api/rates/project-rate-overrides` routes return 404; see [Rates API — retired override routes](https://help.stralos.app/api/rates.md#retired-override-routes).

## Rate-group taxonomy

The shared domain contract defines six rate-group tokens. Estimate and activity component snapshots accept all six, and the build-up classifier displays them as separate groups.

The catalogue services use the same six-group contract. Global rate categories accept all six tokens, including `disposal`, and Settings can save disposal categories. Within the catalogue, the group is **derived, never chosen on a rate item**: every item category carries a required `parent_group`, a rate item references that category by `category_id`, and the rate's cached classification is derived from that reference.

`RateItem` in `stralos-domain` carries `rateGroup?: RateGroup | null` directly. A token is the classification derived from the referenced category's parent. `null` means that category reference no longer resolves; `undefined` means the field was absent from the value supplied to a consumer.

A rate's group is server-derived from the category it references rather than chosen by the user. The `rate_group` column is propagated onto pricing inputs, but `api-rates` stamps it from the category's `parentGroup` on every item upsert. Consumers must not send `rateGroup` as classification intent.

### Wire tokens and display labels

| Wire token | Default display label |
| - | - |
| `labour` | Labour |
| `plant_vehicles` | Plant & Vehicles |
| `quarry_materials` | Bulk Materials |
| `other_materials` | Materials |
| `subcontractors` | Subcontractors |
| `disposal` | Disposal |

The domain token set is fixed — there are no per-organisation custom groups. `RATE_GROUPS`, `RateGroup`, `RATE_GROUP_LABELS`, and `isRateGroup` are exported from `stralos-domain/packages/base/src/rateGroups.ts` and the package barrel. Global accepts all six choices. Organisations may override their display labels through `rate_group_labels`, but the tokens, classification, and pricing semantics remain fixed.

**Important naming notes:**

* Use the labels exactly as listed above: **Plant & Vehicles** and **Materials**, not slash-separated or “Other Materials” variants.
* The `subcontractors` group describes a rate item that is priced from a subcontractor's rate. It is not the same as the subcontract-BID pricing mechanism (`pricing_source = "subcontract:{bidRateId}"`), which uses separate mirror rows that are excluded from totals.
* The `disposal` group is authoritative-only. No legacy sniff vocabulary exists for it, so a NULL-group rate can never fall through to `disposal`. Only rates carrying an explicit `disposal` token reach that group.
* The `category` name column on a rate item is a display mirror of the referenced category, refreshed by the read-path reconcile. A rate whose reference no longer resolves has a NULL group and shows in the rates page's **Unresolved category** queue — NULL means "the category no longer resolves", not "nobody classified it".

### Item-category taxonomy

Each current rate category carries a `parentGroup` field (one of the six catalogue-service tokens), an `allowedUnitCodes` list, and an optional `isCartageVehicle` capability flag. The category — not each rate — is the authoritative classifier for all items grouped under it.

**Per-parent flags on categories:**

| Flag | Permitted under |
| - | - |
| `isCartageVehicle` | `plant_vehicles` only |

This flag is enforced by the API, which returns `422` under an incompatible parent. The retired `isOutgoing` field is not accepted; disposal is represented by the `disposal` parent group. Category reads and writes are tenant-scoped. The Rates API resolves classification from the category's `parentGroup`, validates each option's priced unit against `allowedUnitCodes`, and treats `categoryId` as the only item-classification input.

### Classifier

`classifyBuildUpGroup` in `stralos-domain/packages/base/src/buildUpGroups.ts` maps any component into one of nine build-up display groups (the six rate groups plus the structural `activity`, `day_rates`, and `other`). See [Pricing module — Build-up display groups](https://help.stralos.app/internal/pricing-module.md#build-up-display-groups) for the full precedence rules.

### Rate-group contract

`rateGroupContract.ts` (and `test/fixtures/rateGroupContract.json`) is the cross-repo fixture that keeps `stralos-domain`, `stralos-api-rates`, and `stralos-feature-estimate` in agreement about how a pre-PRD (NULL-group) rate classifies. The contract covers the full legacy sniff vocabulary — 41 rows including word-boundary negative controls — and records each row's fall-through group and labour verdict. A `contentHash` (SHA-256 over the vocabulary and rows) pins the fixture; any change to the classifier vocabulary or any row's verdict changes the hash and causes the drift test to fail loudly.

The API rates service (M3) commits a verbatim copy of the JSON and replays it through its backfill SQL `CASE` expression. The feature-estimate service (M6) asserts its API-client token literals equal `RATE_GROUPS`.

## Rate option ownership and locations

A `RateOption` is one priceable choice for a rate item: **scope, supplier, location, condition, basis, unit and cost in a single row.**

> [!WARNING]
>
> **`RateOffering` and `RatePrice` no longer exist.** They were the middle and leaf
> layers of the old three-table stack, and both were removed with the rates reimage
> — `stralos-domain` exports neither type, and a `@ts-expect-error` guard in
> `exportSurface.test.ts` fails the build if either name is reintroduced. The
> offering existed only to hold a source/location pair, which is now two columns on
> the option; the price existed to hold a cost, which is now one column on the
> option. See [Rates API — removed routes](https://help.stralos.app/api/rates.md#removed-routes).

> [!WARNING]
>
> **`RateSource` and `RateSourceLocation` no longer exist.** The `rate_sources` and
> `rate_source_locations` tables were removed in the rate-source collapse
> (`stralos-domain` 2.0.0, PR #127; also included in 3.0.0). `stralos-domain`
> exports neither type, and `exportSurface.test.ts` guards against their return. A
> supplier's identity is now its `parties.id`; ownership of a location is recorded
> by `locations.party_id`. The old `rateSourceId` / `rateSourceLocationId` pair on
> `RateOption` is replaced by the `scope` / `supplierId` / `locationId` triple.

### `RateOptionScope`

```typescript theme={null}
type RateOptionScope = "organisation" | "supplier";
```

`RateOptionScope` replaces the retired `rate_sources` table as the ownership discriminant for a rate option:

| Value | Meaning |
| - | - |
| `"organisation"` | The option is priced by the organisation itself. The rates service stores no organisation id and no organisation name for pricing scope — RLS already scopes the row to the tenant, and the display name comes from the shell's membership projection. |
| `"supplier"` | The option is priced by a named supplier. `supplierId` is non-null exactly when scope is `"supplier"`. |

### `RateOption`

`scope`, `supplierId`, and `locationId` are a **required triple** on every option row. All three must be present; omitting any one is a compile error. The `scope` value determines what `supplierId` and `locationId` contain:

* `scope: "organisation"` — our own price list. `supplierId` is `null`; the organisation's identity comes from the shell's membership projection, not from a field on the option.
* `scope: "supplier"` — a supplier's price. `supplierId` is a `parties.id` in api-global. `null` is not permitted.

`locationId` is `null` when the option applies to all of the owner's locations; a non-null value is a `locations.id` in api-global.

| Field | Type | Notes |
| - | - | - |
| `id` | `EntityId` | Stable across updates — a `PUT` mutates in place, so a pinned option id never migrates. |
| `rateItemId` | `EntityId` | The rate item this option prices. |
| `scope` | `RateOptionScope` | Who owns the price. `"organisation"` means the tenant's own rate; `"supplier"` means a named third-party supplier. |
| `supplierId` | `EntityId \| null` | A `parties.id` in api-global. Non-null exactly when `scope` is `"supplier"`. |
| `locationId` | `EntityId \| null` | A `locations.id` in api-global. `null` means the option applies to all of the owner's locations. |
| `sourceItemCode` | `string \| null` (optional) | The supplier's own code for this item. Normally absent for organisation-scoped options. |
| `sourceItemName` | `string \| null` (optional) | The source's own name or description for this item, as printed on its price list, quote, or invoice. `null` means the source printed no name. Display and reconciliation only: it never feeds identity, matching, or pricing. |
| `condition` | `string` | A normalised, stable lower-case **token** (`standard`, `night`, `saturday`, …), not a label — the display form is derived, never stored. |
| `basis` | `"fixed" \| "multiplier"` | |
| `basisMultiplier` | `number \| null` (optional) | Set exactly when `basis` is `"multiplier"`. |
| `unit` | `UnitOfMeasure \| string` | The authoritative unit this option is **priced in**, which may differ from a consuming line's target unit. |
| `conversionFactor` | `number \| null` (optional) | Physical conversion scalar. Both orientation fields define its meaning when present; otherwise the category and unit pair supply the natural orientation. **Disposal options carry none.** |
| `conversionNumeratorUnit` | `UnitOfMeasure \| string \| null` (optional) | Numerator half of the persisted orientation. Quarry density is `tonne` per `cubic_metre`; a `length`-sold option stores `metre` per `length`, a `roll`-sold option stores `metre` (or `square_metre`) per `roll`, and a `sheet`-sold option stores the covered unit per `sheet`. |
| `conversionDenominatorUnit` | `UnitOfMeasure \| string \| null` (optional) | Denominator half of the persisted orientation. Read it together with `conversionNumeratorUnit`. |
| `costRate` | `number \| null` | `null` **exactly when** `basis` is `"multiplier"` — a database `CHECK` enforces the biconditional. A multiplier option stores no cost, because a `0` placeholder and a real zero would be indistinguishable downstream. |
| `derivedCostRate` | `number \| null` (optional) | Present on **reads** of a multiplier row: the server's `standard.costRate × basisMultiplier`. Domain never recomputes it from an unseen Standard. |
| `currency` | `"NZD"` | Phase 1 is single-currency; multi-currency needs a conversion policy before the column widens. |
| `provenance` | `"manual" \| "price_list" \| "project_quote" \| "invoice"` | |
| `validFrom` / `validTo` | `string` / `string \| null` | The **commercial** validity the source quotes — not a row history. |
| `status` | `CatalogueStatus` | `"active"` or `"inactive"`. A database `CHECK` ties the two together: `status = 'active'` **if and only if** `validTo` is null. |
| `needsReview` | `boolean` | A flag on the row; it does not affect pricing. |
| `notes` | `string \| null` (optional) | |
| `supplier` | `Party \| null` (optional) | Read-side hydration only. The company named by `supplierId`, resolved from `parties` across the database boundary. Never present on a write. Always absent when `scope` is `"organisation"` — there is no party to resolve, and the organisation's own name comes from the shell. |
| `location` | `Location \| null` (optional) | Read-side hydration only. The place named by `locationId`. `null` when the option applies to all of the owner's locations. |

**The multiplier invariant is a service rule, not a `CHECK`** — a row-level constraint cannot see a sibling row. A multiplier option requires a Standard option for the same **item + scope + supplier + location**, and every transition that removes that Standard's availability (delete, retire, `validTo` expiry, or a move to another supplier or location) first converts its dependants to `fixed` at their last derived value, in the same transaction.

`Location` carries a `kind` field (`"office" \| "yard" \| "depot" \| "branch" \| "quarry" \| "other"`) and an optional `geoTag`.

### Rate option normalisation

`normalisedCostRate(option, item, targetUnit?)` (exported from `stralos-domain/src/rateOptions.ts`) converts a `RateOption` cost into a comparison unit and returns either a normalised value or a named exclusion reason. `targetUnit` defaults to the legacy optional `item.unit`. When neither supplies an axis, the effective cost is returned in the option's priced unit; callers must provide an axis before comparing options.

**Conversion orientation** is resolved in this order:

1. If the option carries both `conversionNumeratorUnit` and `conversionDenominatorUnit`, those define the scalar's meaning — no inference is performed.
2. Otherwise, orientation is inferred from the rate group only: `t/m³` for `quarry_materials`. No other pair inference is applied.

Key normalisation rules:

* `disposal` options carry no `conversionFactor`. A disposal option that carries one is excluded as `conversion_missing`. A valid disposal option priced in a different unit from the target is excluded as `disposal_unit_by_substance`, with its own price and unit retained for display.
* `quarry_materials` options require a positive, finite `conversionFactor` no greater than 1,000,000, including for an equal-unit comparison.
* Both non-empty orientation halves override inference. Exactly one non-empty half is corrupt and returns `conversion_missing`; neither half falls back to category and unit-pair inference.
* Normalisation accepts NZD only. Any other currency returns `currency_mismatch`.

The `metre`/`each` unit-pair inference for length-sold materials was retired in domain 3.5.0. Options priced in the `length`, `roll`, or `sheet` units carry an explicit `conversionFactor` with stored `conversionNumeratorUnit` / `conversionDenominatorUnit` fields rather than relying on inference.

### Whole-stock per-run ordering (domain 3.5.0)

Three unit codes represent materials purchased as whole stock pieces per calculator or work-item run rather than by a measured physical quantity:

| Unit code | Meaning | Typical orientation |
| - | - | - |
| `length` | One whole supplied length (e.g. a 6 m pipe or timber stick). | `metre` per `length` — how many linear metres each piece covers. |
| `roll` | One whole roll. Applies to materials sold by the roll where each roll covers a fixed linear run or area (e.g. geotextile, filter fabric, reinforcing mesh). | `metre` per `roll` for linear coverage, or `square_metre` per `roll` for area coverage. |
| `sheet` | One whole sheet. Applies to materials sold as flat sheet stock (e.g. ply, membrane, formwork panels). | The covered unit (e.g. `square_metre`) per `sheet`. |

Options priced in `length`, `roll`, or `sheet` must carry a positive `conversionFactor` with both `conversionNumeratorUnit` and `conversionDenominatorUnit` set. The orientation fields are always persisted; `normalisedCostRate` does not infer the direction from the unit pair. An option that carries only one orientation field is `conversion_missing`.

The `metre`/`each` unit-pair inference introduced for earlier length-sold materials was retired alongside this change (domain 3.5.0, pricing 2.1.0, calculators 1.13.0). Any option that previously relied on that inference must now carry explicit orientation fields.

### `RateItem.unit` retirement

`RateItem.unit` remains optional in the shared domain type for legacy consumers, but the Rates API no longer serves or stores it. The pricing pivot is the work item line's unit. Pass an explicit target to `normalisedCostRate`; use `RateSummary.modeUnit` for display and initial values.

### `RateSummary.modeUnit`

`RateSummary` carries `modeUnit?: string | null`. It is the most common unit among the item's live options. Ties are resolved by the number of Standard options, then the earliest `validFrom`, then unit code ascending. It is `null` when the item has no live options.

`modeUnit` replaces `RateItem.unit` as the display and seed axis for rate item pickers and unit pre-fill. It can drift as options change. Any frozen selection or stored snapshot that needs a stable unit must store its own value rather than re-reading the summary.

### `RateSummary.suppliers` and `hasOrganisationOptions`

`RateSummary.suppliers` is a list of distinct supplier company names resolved from `parties`. The field name was previously a misnomer for "rate-source names". Since the rate-source collapse (`stralos-domain` 2.0.0) it is literal: it contains supplier company names only.

Organisation-scoped options (`scope: "organisation"`) contribute nothing to `suppliers`. The rates service holds no organisation name. Use `hasOrganisationOptions` to indicate "we price this ourselves too" and render the organisation's own name from the shell.

`RateSummary.hasOrganisationOptions?: boolean` is `true` when at least one live option on the item has `scope: "organisation"`. It is optional — an absent value means "not reported by this producer", not `false`. Producers compiled against an older domain that predates this field continue satisfying `RateSummary` without it.

## Calculations

Shared helpers include money rounding, markup/margin helpers, quantity conversions, and estimating-specific resource calculations. Keep calculations in `stralos-domain` when more than one feature needs the same behaviour.

## Time zones

Time-zone handling is defined in `packages/base/src/timeZone.ts` and exported from `@stralos-co-nz/base` (base 1.5.0, PRD D6). Every API route and every screen that needs "today" or a calendar date in a project context must use this module so dates are consistent across the estimate, the rates register, and the scheduler.

### The effective time zone

A single resolver determines the zone in force for any given project:

```
effective = project.timezone ?? organisation.timezone ?? DEFAULT_TIME_ZONE
```

`DEFAULT_TIME_ZONE` is `"Pacific/Auckland"` — the zone every organisation had before D6. Stored values are IANA names, never UTC-offset strings (e.g. `+12:00` is rejected because offset strings carry no daylight-saving rules and would be an hour out for half the year).

### Exported functions

| Function | Description |
| - | - |
| `normaliseTimeZone(value)` | Returns the canonical IANA spelling, or `null` for an unrecognised or non-IANA value. Normalises case (`"pacific/auckland"` → `"Pacific/Auckland"`). Store the result, never the raw input. |
| `isIanaTimeZone(value)` | Returns `true` when `normaliseTimeZone` resolves the value. |
| `resolveEffectiveTimeZone(projectTz, orgTz)` | Applies the three-level resolver. A bad stored value falls through to the next level rather than throwing, so no row can make a date unrenderable. |
| `dateInTimeZone(instant, timeZone)` | Returns the calendar date `instant` falls on in `timeZone`, as `YYYY-MM-DD`. Use this instead of `toISOString().slice(0, 10)`, which gives the UTC date and is the previous day in New Zealand until midday. |
| `todayInTimeZone(timeZone, now?)` | Returns today's `YYYY-MM-DD` in `timeZone`. `now` is injectable for tests. |
| `timeZoneOffsetLabel(timeZone, at?)` | Returns the UTC offset in force at `at` (e.g. `"UTC+13:00"` for Auckland in summer), accounting for daylight saving. |
| `listTimeZones()` | Returns every IANA zone the current runtime knows, sorted, with `UTC` included. |

### Usage rules

* Always store the result of `normaliseTimeZone` — never store the user's raw input, which may have inconsistent casing.
* A value missing from `listTimeZones()` at read time should still be offered by the caller as the current selection; do not silently drop it.
* `dateInTimeZone` and `todayInTimeZone` are the authoritative source for `"valid from"` comparisons and schedule day logic. Do not use `toISOString().slice(0, 10)`.
* `resolveEffectiveTimeZone` is the only resolver. Do not re-implement the three-level fallback in a service or remote.

## Contract variations model

The contract variations model sits in the Estimate service. Key rules:

* One active baseline per estimate (enforced by a partial unique index on `estimate_id WHERE superseded_at IS NULL`).
* Variation references (`VO-001`, `VO-002`, …) are allocated from a per-estimate monotonic counter — never client-supplied.
* `draft`, `assessed`, and `priced` resting statuses are server-derived from line count and assessment state; submit, withdraw, approve, and reject require an explicit action.
* Every line mutation and status transition appends a row to the `estimate_variation_events` table (append-only).
* Submitting a variation freezes an immutable revision snapshot. Revisions are numbered per variation and cannot be altered.
* Line valuation follows NZS 3910 clause 9.3 using exact scaled-integer arithmetic with a single half-up rounding to the cent.

## Cloud-drive document sync

Shared types for the cloud-drive sync feature are exported from `stralos-domain` (except the document record itself, which consumers declare) and documented in detail at [Cloud-drive document sync](https://help.stralos.app/internal/cloud-drive-sync.md). Key modelling rules:

* A document's `estimateId` is `EntityId | null`. A synced document starts project-scoped and unrouted (`null`). Route it via `POST /documents/:id/route` to assign it to an estimate.
* `SyncedFileStatus` distinguishes a **transient** `error` (retried up to five times per version marker) from a **terminal** `failed` (quarantined until a new version marker or explicit re-include).
* `CreateSyncConnectionResponse` is a three-way discriminated union: `created: true` (fresh insert), `reactivated: true` (soft-deleted mapping reactivated), or neither (idempotent no-op on an already-live mapping).
* The raw proof nonce is not stored in domain types or returned as its own API field. Registration returns only the derived proof file name while verification is pending.

## Package activity domain contract

`@stralos-co-nz/domain` `0.23.0` extends `Activity`, `ActivityComponent`, and `WorkItemComponent` with the fields that drive package pricing. This section records the domain contract; the activity service and estimate service implementations consume these types directly.

### `Activity` package fields

| Field | Type | Notes |
| - | - | - |
| `pricingMode` | `"project_margins" \| "fixed_price"` | Required. `"project_margins"` prices the activity through the estimate's margin settings. `"fixed_price"` uses a published sell amount that is independent of the project margin. |
| `defaultSellMargin` | `number` (optional) | Whole-percent margin on sell (19.9 means 19.9%). Stored as `numeric(6,3)`. Meaningful when `pricingMode` is `"fixed_price"`. |
| `publishedSellAmount` | `number` (optional) | Published sell price for the activity (cents). Set when the activity has been published with a fixed sell. |
| `publishedCostAmount` | `number` (optional) | Cost basis used when the published sell was calculated (cents). |
| `publishedAt` | `string` (optional) | ISO 8601 timestamp recording when the sell amount was last published. |
| `optionalCost` | `number` | Sum of `totalCost` across all components whose `optionality` is `"optional"`. Always present; zero when no optional components exist. |
| `optionalComponentCount` | `number` | Count of components whose `optionality` is `"optional"`. |

### `ActivityComponent` optionality fields

| Field | Type | Notes |
| - | - | - |
| `optionality` | `"included" \| "optional"` | Whether this component is always included in the activity cost or is an add-on that can be selected when the activity is placed in an estimate. |
| `optionGroup` | `string` (optional) | Groups optional components into named sets. Components in the same group are alternatives; at most one per group should be selected. |

### `WorkItemComponent` package snapshot fields

These fields are written when a fixed-price activity is materialised into a work item. They snapshot the agreed pricing at the time the package is confirmed and are not recomputed from the current activity state.

| Field | Type | Notes |
| - | - | - |
| `packageSellUnitPrice` | `number` (optional) | Sell price per unit of the activity (cents), snapshotted from `publishedSellAmount` when the package is materialised. |
| `packageSellMargin` | `number` (optional) | Whole-percent margin on sell at materialisation time, snapshotted from `defaultSellMargin`. |
| `packageAddons` | `Array` (optional) | Selected optional add-ons for this package instance. Each entry carries `optionalComponentId`, `name`, `addonSellAmount`, `addonCost`, and `sellBasis`. |
| `packageAddonSourceId` | `EntityId` (optional) | When this component is itself a selected optional add-on, this field records the source `optionalComponentId` it originates from. |

#### `packageAddons` entry shape

| Field | Type | Notes |
| - | - | - |
| `optionalComponentId` | `EntityId` | ID of the `ActivityComponent` this add-on was selected from. |
| `name` | `string` | Display name snapshotted from the component at materialisation time. |
| `addonSellAmount` | `number` | Sell amount for this add-on (cents). |
| `addonCost` | `number` | Cost basis for this add-on (cents). |
| `sellBasis` | `"published" \| "parent_margin"` | `"published"` means the add-on sell is taken from the activity's published sell. `"parent_margin"` means the margin from the parent package sell is applied to the add-on cost. |

### `WorkItemComponent` day-rate fields

`WorkItemComponent.type` accepts three values (domain 3.10.0):

| Value | Meaning |
| - | - |
| `"rate_item"` | A standard rate-library line. |
| `"activity"` | An activity container; cost lives in its `activityComponents` children. |
| `"day_rate"` | A line picked from a day-rate schedule. Files under the `day_rates` build-up group and is never marked up. |

A component with `type: "day_rate"` carries a `dayRate` snapshot field (absent or `null` on every other type).

#### `WorkItemComponentDayRate` shape

| Field | Type | Notes |
| - | - | - |
| `lineId` | `EntityId` | The picked schedule line ID (api-rates). |
| `lineKey` | `EntityId` | Stable line identity across revisions — used to match the line when updating to a newer revision. |
| `lineCode` | `string \| null` | The line's display code on the schedule, if any. |
| `scheduleId` | `EntityId` | The day-rate schedule this line belongs to. |
| `scheduleName` | `string` | Schedule display name, snapshotted at pick time. |
| `clientName` | `string \| null` | Client name for a client schedule; `null` for an organisation schedule. |
| `revisionId` | `EntityId` | The revision the line was picked from. |
| `revisionNumber` | `number` | Revision number at pick time, for display. |
| `effectiveFrom` | `string` | ISO date (YYYY-MM-DD) the revision came into force. |
| `kind` | `DayRateKind` | `"flat"` for a per-unit rate; `"fixed"` for a one-off set price. |
| `schedulePrice` | `number` | The schedule's published rate, snapshotted at pick. Never changed by a hand edit; `unitCost` on the component may differ. |
| `pickedAt` | `string` | ISO 8601 instant of the pick or most recent update. |
| `inProjectSet` | `boolean` | `false` when the schedule has left the project's set, or the project is pinned to a different revision. |

`DayRateKind` is `"flat" | "fixed"`. Cost-plus lines exist on schedules but are never picked into a build-up.

#### Pricing behaviour

A day-rate line's cost is summed like any other line, but it is added to the sell price after the item's markup or margin, not through it. See [Pricing module — Work-item totals](https://help.stralos.app/internal/pricing-module.md#work-item-totals) for the formula and the `dayRateCostCents` field on `WorkItemTotals`.

A fixed day-rate line (`kind: "fixed"`) is written with `quantity: 1` and `quantityIsTotal: true`, which charges it once at its listed price regardless of the work-item quantity.

## Workbook mapping

The original estimate workbook remains a useful field-discovery source, but workbook sheets are not one-to-one product modules. Post-tender sheets such as Dayworks and VO workflows are future scope unless explicitly promoted.


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