Skip to main content
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: 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

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

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.

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.

ProjectAccessRequest

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

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). 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. 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 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: 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 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 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): 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) 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): Delivery estimate statuses (DeliveryEstimateStatus): 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. 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 for the isolation model and Runbooks 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 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: 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, Pit / Structures calculator and Volume calculator 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.

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 and Estimate API — re-resolve prices for the live wire and resolution contracts.
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.

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

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

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

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

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

ActivityComponent optionality fields

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.

packageAddons entry shape

WorkItemComponent day-rate fields

WorkItemComponent.type accepts three values (domain 3.10.0): A component with type: "day_rate" carries a dayRate snapshot field (absent or null on every other type).

WorkItemComponentDayRate shape

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