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

# Project settings frontend

> Frontend implementation of the Project Settings page in stralos-feature-project-settings.

`stralos-feature-project-settings` is the Module Federation remote that owns the Project Settings page. It exposes a single route at `/projects/:projectId/settings`, registered as `projectSettings` in the shell's remote topology.

## Routes

| Path | Component | Notes |
| - | - | - |
| `/projects/:projectId/settings` | `ProjectSettingsPage` | Redirects to the default section (`details`). |
| `/projects/:projectId/settings/:section` | `ProjectSettingsPage` | Named section: `details`, `scope`, `contract-basis`, `rate-preferences`, `day-rates`, or `shared-drive`. |

Both routes render the same component at the same position, so navigating between sections keeps the page mounted. The shell mounts this remote at `/projects/:projectId/settings` with `end: false` so the nested `:section` segment reaches `src/routes.ts` without a shell change.

Routes are registered in `src/routes.ts` and exported as `projectSettingsRoutes`. The section set is defined in `src/settingsSections.ts` as `PROJECT_SETTINGS_SECTIONS`.

### Legacy address handling

Addresses formed before the rail existed are redirected to their section:

* `#rate-preferences` (fragment anchor from the estimate's rate-exceptions panel) → `rate-preferences` section.
* `?estimateId=…` (estimate-scoped rate links) → `rate-preferences` section.

`legacyProjectSettingsSection(hash, search)` in `src/settingsSections.ts` resolves these cases. An unknown `section` segment (one no section owns) redirects to the default section rather than rendering under a URL that names an unrecognised section.

## Components

### `ProjectSettingsPage`

Wraps `ProjectSettingsContent` in a `SettingsDraftProvider`. The active section is driven by the URL (`useParams().section`), not by component state. Switching sections is a history entry; the rail is a set of `<Link>` elements, one per `PROJECT_SETTINGS_SECTIONS` entry.

Each section has its own `SettingsSaveBar` (scoped to that section's `scope` name) rendered at the bottom of its pane. Only the open section can be dirty — inactive sections are unmounted when they are not the active route. The page therefore has no page-wide single save bar; the shared `SettingsDraftProvider` coordinates the navigation blocker across the whole page.

If the user navigates away while the open section has unsaved changes, `RouteDraftGuard` intercepts the transition and shows a **Save changes before leaving?** dialog with three actions: **Save changes** (saves and proceeds only if the save succeeds, otherwise stays and shows the reason), **Discard changes** (discards and proceeds), and **Keep editing** (cancels the navigation). Any change of `pathname` or `search` triggers the guard, including the browser's Back and Forward buttons.

`ProjectSettingsContent` loads the project record, organisation settings, and the client list on mount in a single `Promise.all`. The project's primary estimate is loaded separately, in a chained `loadEstimate` call. If the estimate load fails, only the **Hours per working day** field is affected: it renders as unavailable with a helper message, while every other field remains editable and saveable. The **Multiple estimates** setting has its own read and degrades the same way (see below).

Project detail fields are held in a `ProjectSettingsValues` state object alongside a `pristineValues` snapshot of the last-saved state. The `isDirty` flag compares the two; it drives the page-level dirty guard and controls whether the section can contribute to the shared **Save changes** action.

A single in-flight gate (`busy`) is shared between metadata saves (`upsertProject`) and status transitions (`transitionProject`). Only one write is in flight at a time; every mutating control disables while it is held.

Status labels are resolved through the `project_statuses` setting list so platform-level relabels (for example, "Estimating" relabelled to "Tendering") appear on the badge and action buttons without a code change.

The list is loaded via `useSettingList(listSettingListItems, "project_statuses", PROJECT_STATUS_VALUES)` from `@stralos-co-nz/ui`. The hook seeds a fallback state from `PROJECT_STATUS_VALUES` using `initialSettingListState` while the API call is in flight. When the API call fails or has not yet resolved, the returned state carries `isFallback: true`, and `resolveProjectStatusLabel` from `@stralos-co-nz/domain` humanises the raw enum values (for example, `"lead"` becomes `"Lead"`) instead of echoing them verbatim. The full state object is passed to `resolveProjectStatusLabel` unchanged so the `isFallback` flag travels with the options.

#### Project details layout

The Project details section groups its fields into seven titled groups rendered by `DetailsGroup` from `src/projectDetailsGroups.tsx`. Each group is a `<Stack as="section">` with a secondary heading:

| Group | Fields |
| - | - |
| **Project** | Client, Main contact, Status, Project name, Quote Number, Job Number, Client reference |
| **Working week and month** | Days per week, Days per month (one two-column row), each with a **Use company default** button |
| **Dates and working day** | Proposed start date, End date, Target submission (one three-column row); Time zone, Hours per working day (one two-column row) |
| **Estimates** | Multiple estimates checkbox + Manage estimates button (`MultipleEstimatesField`) |
| **Location** | Country, Office, Resourcing rule, Location picker |
| **Search distance** | Search distance picker (`SearchDistanceField`) |
| **Scheduler** | Schedule colour |

Date fields use `DateField` from `src/DateField.tsx`, which constrains width to `11.5rem` — wide enough for a date string and no wider. The **Client**, **Time zone**, **Country**, and **Office** `SelectCombobox` fields each have `maxWidth="32rem"` so they fill a two-column grid cell up to that cap rather than truncating.

#### `ClientContactField`

`ClientContactField` (`src/ClientContactField.tsx`) renders **Main contact** directly after **Client**. The page holds `clientContactId` as a string, `""` for none, seeded from `project.clientContactId`. The field receives the saved `project.clientContact` while the selected client is the saved one, so it can name a contact that is no longer in the active list.

* **Contact list.** For the selected client the field calls `GET /api/global/parties/:id/contacts` (active contacts only), keyed by client id so a slower read for a previous client is ignored. A failed read shows "The client's contacts can't be loaded right now. The rest of this page still saves."
* **Held contact.** When the saved contact is not in the active list, it stays as a choice. Once the list has loaded, or when its `status` is `inactive`, it is labelled "(removed)" and the helper asks for another contact.
* **Client change.** `update("clientId", …)` clears `clientContactId`, or restores the saved one when the client goes back to the saved client. A changed client with no contact blocks the save with `CONTACT_REQUIRED_ERROR` ("Please choose the client's main contact."). A project that has never had a contact can still save other edits.
* **Server refusals.** `422 client_contact_required` and `422 client_contact_invalid` are shown on the field as "Please choose the client's main contact." and "Please choose a contact from this client."
* **New contact.** The create row ("Add contact “name”") or **New contact…** opens `NewContactSheet`, a `SideSheet` with **Name**, **Phone**, **Email** and **Role**. A phone or an email is required, and both use api-global's contact format rules. **Add contact** calls `POST /api/global/parties/:id/contacts` and selects the new contact.
* **Wire.** `clientContactId` goes in the project PUT only when it differs from the saved value and is not empty, so an unchanged contact that has been deleted since never blocks an unrelated save.

#### `MultipleEstimatesField` and estimate settings

`MultipleEstimatesField` from `src/projectDetailsGroups.tsx` renders the **Multiple estimates** checkbox and its companion **Manage estimates** button inside the **Estimates** group.

State is held in `multipleEstimates: { projectId, saved, value } | null`. The `saved` property is the value as last written; `value` is the current draft. `multipleEstimatesDirty` is true when they differ. The field is loaded from `getProjectEstimateSettings` (GET `/api/estimate/projects/:id/estimate-settings`) alongside the estimate; if that call fails the field renders as unavailable with a helper saying "This setting can't be loaded right now. The rest of this page still saves."

When the section saves, `putProjectEstimateSettings` (PUT `/api/estimate/projects/:id/estimate-settings`) is called when `multipleEstimatesDirty` is true. Only the `multipleEstimatesEnabled` property is written. After a successful write, Stralos fires the `ESTIMATE_SETTINGS_CHANGED_EVENT` (`stralos:estimate-settings-changed`) custom event so the estimate and workspace remotes re-read the setting.

**Manage estimates** is enabled only when `setting.saved === true && setting.value === true` — that is, the setting is both turned on and already saved. Before then the button is rendered but disabled, and the helper reads "Save to turn on multiple estimates, then manage them here." Clicking it calls `openManageEstimates(projectId)`, which dispatches the `OPEN_MANAGE_ESTIMATES_EVENT` (`stralos:manage-estimates`) window event with `{ projectId }`. The workspace remote's `ManageEstimatesSheet` listens for it and opens over Project Settings, so managing estimates never leaves the page.

#### Time zone field

The **Time zone** field (PRD D6) lets a project follow its organisation's time zone or override it with any IANA zone.

`ProjectSettingsValues` holds `timezone: string`. An empty string (`""`) means follow the organisation; any non-empty string is an IANA override (e.g. `"Asia/Tokyo"`). On save, `upsertProject` sends `timezone: null` when the field is empty and the stored IANA identifier when it is not — the wire uses the same nullable-clear pattern as `endDate` and `colour`.

The organisation's time zone is read from `organisation-settings.timezone` via `getOrganisationSettings`. The saved value (`organisationSettings.timezone`) is used directly as `organisationTimezone` state. If the organisation has no saved time zone, `resolveEffectiveTimeZone(null, organisationSettings.timezone)` applies the `DEFAULT_TIME_ZONE` fallback from `@stralos-co-nz/base`; only that fallback resolution is used, not the projected (UTC-offset-resolved) form. `organisationTimezone` therefore always names the zone as saved by the organisation, not as projected at the current moment.

The dropdown is a `SelectCombobox` driven by `timeZoneChoices(organisationTimezone, currentTimezone)`:

* The first choice has `value: ""` and label `"Company default — <zone> (<UTC offset>)"`.
* Every IANA zone returned by `listTimeZones()` from `@stralos-co-nz/base` follows, each with its current UTC offset via `timeZoneOffsetLabel`.
* If the project carries a stored override that `listTimeZones()` does not include (for example, a legacy alias), `timeZoneChoices` prepends it so the field never renders blank over a real value.

Helper text shown below the field:

* No override set: `"Time zone: Company default — <orgZone> (<UTC offset>). Choose another to override it for this project."`
* Override set: `"Time zone: <override> (<UTC offset>), overriding the company default — <orgZone> (<UTC offset>). Choose the company default to follow it again."`

The `listTimeZones`, `resolveEffectiveTimeZone`, and `timeZoneOffsetLabel` helpers come from `@stralos-co-nz/base` 1.5.0 or later.

#### Quote Number and Job Number fields

Both fields are read-only `Text` inside a `FormField`, rendered in a monospace font when a value is present and in body text otherwise.

| Field | Value | Empty text | Helper |
| - | - | - | - |
| **Quote Number** | `project.quoteNumber` | Not assigned | "The project's initial quote. Each estimate version and alternative has its own Quote Number." |
| **Job Number** | `project.jobNumber` | No job yet | "Assigned when an estimate is marked Won." |

The helpers are fixed text. The page no longer reads the organisation's `jobNumberPrefix` to explain a missing number, because every project now has a quote number and a job number exists only after a win.

#### Working week and month

The **Working week and month** group holds the project's optional overrides of the organisation's working-day defaults, used to convert weekly and monthly rates.

`ProjectSettingsValues` carries `daysPerWeek` and `daysPerMonth` as strings. An empty string follows the organisation default. On load, `organisationPeriods` is set from `organisationSettings.defaultDaysPerWeek ?? 5` and `organisationSettings.defaultDaysPerMonth ?? 20`. Each field shows that value as its `placeholder` and in its helper: "Company default: N days. Leave blank to inherit; a weekly rate can override this." ("a monthly rate" for the month field).

`validPeriod(value, max)` accepts a blank value, or a finite number greater than 0, at most `max` (7 or 31), with up to two decimal places. A failing field shows "Enter more than 0 and no more than 7 days, with up to 2 decimals." (31 for the month field), and the section's save bar is blocked with "Enter valid working days per week and month, or leave blank to use company defaults."

Each field has a **Use company default** button (`xs`, ghost, teal) that sets the field to `""`. It is disabled while the field is blank or a write is in flight. On save, `upsertProject` sends `daysPerWeek` or `daysPerMonth` only when it differs from `pristineValues`: `null` for a blank field (clearing the override) and the parsed number otherwise. Saving an unrelated detail therefore never clears a stored override.

#### Office and resourcing rule

The **Office** field (`officeLocationId` in `ProjectSettingsValues`) is the office the project runs out of. Its choices come from `listOfficeLocations()`, which reads every page of `GET /api/global/locations?owner=ours&status=active` and keeps `{ id, name }`. Only the organisation's own active locations appear; supplier yards belong to the pricing catalogue, not here. The helper text says the field is not the pricing location under Rate preferences. `withSelectedOffice` appends the project's current `officeLocation` when it is no longer in the active list, so the field never renders blank over a real value.

The office list loads with the page's other reads but its failure is caught separately. When it fails, `offices` is `null`, the helper reads "Your offices can't be loaded right now. The rest of this page still saves.", and the rest of the page is unaffected.

There is no wire-level clear. The field is marked required once the project has an office, and a metadata save with no office chosen is blocked with `OFFICE_REQUIRED_ERROR` ("Choose the office this project runs out of before saving."). `officeLocationId` is sent to `upsertProject` only when it differs from `pristineValues` and is not empty, so saving unrelated fields never re-asserts an office that has since been retired. A `422 office_location_invalid` shows `OFFICE_INVALID_ERROR` ("That office can't be used any more. Choose another of your offices.") on the field.

Below the field, a **Resourcing rule** box holds a `Switch` for `preferResourcesFromOffice`, headed "Prefer resources from \{office}". `officeName` resolves the office from the loaded list or the project record, falling back to "the project's office". The rule is advisory: it is stored and shown, but nothing allocates resources from it yet, and the description says so ("Recorded for the team; nothing is allocated automatically yet."). The value is sent only when it changes, and a project with no stored value loads as `true`.

#### Site location

The project details section includes a site location sub-form backed by `LocationPicker` from `@stralos-co-nz/maps-ui` 0.9.1. The address search field is labelled **Address search**. This is a separate package from `@stralos-co-nz/ui`; it is listed as a direct dependency in `package.json` and bundled into this remote.

`LocationPicker` is mounted with `mapDisplay="collapsible"` and `entityLabel="project"`. The **Country** field (a `SelectCombobox` driven by `COUNTRY_OPTIONS` from `src/countryOptions.ts`) controls the `countryCodes` prop: the selected ISO 3166-1 alpha-2 code is passed in lowercase, restricting geocoding search results to that country. When the user picks a geocoded result, the returned `countryCode` overwrites the **Country** field (uppercased). Country options are rendered using `Intl.DisplayNames` with the `en-NZ` locale and sorted alphabetically.

The **Country** field defaults to `NZ` for new projects (sourced from `project.geoTag.countryCode ?? "NZ"` on load).

The location fields are stored on the project's `geoTag` and written to `upsertProject` with the other metadata. Validation rejects coordinates outside ±90° latitude or ±180° longitude; both fields must be finite numbers before save enables.

`@stralos-co-nz/maps-ui` is bundled inline alongside `@stralos-co-nz/ui` and `@stralos-co-nz/domain`. The Google Maps API key is supplied by the shell at runtime; it is not a build-time environment variable. Vitest inlines the package (see `server.deps.inline` in `vite.config.ts`) so tests load it through the bundler path rather than Node's ESM loader.

The **Client** and **Project name** fields are both required and carry the required asterisk and native `required` attribute. The **Client reference** field is optional and carries no "Optional." prefix (required fields are marked; optional fields are not). The **Client**, **Country**, and **Contract** `SelectCombobox` fields each receive `required` passed through to the combobox input so the attribute reaches assistive technology (a `FormField required` alone sets `data-required` on the wrapper but not `required` on the underlying input).

The **Override status** control is a `SideSheet` (not a centred dialog). It carries `confirmOnDirtyClose` and reads `isDirty` from `adminReason.trim().length > 0` at event time, so a stray Escape or an outside-click that occurs after the admin has typed a reason prompts for confirmation rather than silently discarding the audit text. While the write is in flight, `suppressDismiss` prevents closing the sheet. The `reason` field is marked `required` on the `FormField` component.

#### Search distance

`SearchDistanceField` (`src/SearchDistanceField.tsx`) renders the **Search distance** group: how far from the project pricing looks for suppliers. The counting, default and wording live in the pure module `src/searchDistance.ts` (no React, no fetch). `src/searchDistanceApi.ts` owns the reads and the one write. The same rules are written separately in stralos-feature-workspace's post-create **Suppliers for this project** step; there is no shared package, so change both.

**Picker.** A radio group offers `RADIUS_PRESETS` (5, 10, 20, 35, 50, 100, 150 km) and **Other** with a free-text input (accessible name "Other distance in km"). `parseOtherRadius` accepts 1 to 2000 km (`OTHER_RADIUS_MIN_KM`, `OTHER_RADIUS_MAX_KM`) with at most one decimal place; anything else shows `OTHER_RADIUS_ERROR` ("Enter a distance from 1 to 2000 km, with at most one decimal place.") below the input and as the save bar's blocked reason. api-global accepts any value greater than 0, so the 1 km floor is the UI's.

**Default.** `displayedChoice(draft, saved, measured)` shows the person's pick, else the saved radius, else `suggestedRadius(measured)`: 50 km if any measured site is within 50 km, else 100 if any is within 100, else 150; 50 when nothing could be measured. Only the person's pick is a draft to save. Each preset radio fires on click as well as change, so clicking the already-selected suggestion chooses it. With nothing saved, the description reads `NOT_CHOSEN_TEXT` ("Not chosen yet. Pricing looks within 50 km, then 100, then 150."); otherwise "Saved: N km."

**Measuring.** When the saved project coordinates are usable (`coordinatesUsable`; `0, 0` is the editor's empty value and is not a place), the field calls `loadSearchDistance(projectId)`:

1. `GET /api/rates/rate-sites` lists the distinct supplier and site pairs with live rates, across every rate group. A row with a null `locationId` is an All locations price.
2. `measureTargets` splits them into `locationIds` and, for All locations prices, `partyIds`.
3. `POST /api/global/projects/:id/location-routes/resolve` runs with `partial: true`, `straightLineKm: 150` (`MEASURE_STRAIGHT_LINE_KM`) and `alsoNearest: 3` (`MEASURE_ALSO_NEAREST`), in batches of at most 200 IDs of each kind (`RESOLVE_BATCH_SIZE`), one after another. `partyIds` expands each All locations supplier to all its active sites. Routes are cached server-side for 90 days.
4. `GET /api/global/locations?owner=theirs` and `GET /api/global/parties?role=supplier` supply the site and company names.

`buildSearchDistance` turns the results into `measured` sites (road distance, nearest first), `noAddress` sites (`unroutable` reason `no_coordinates`) and a `routingUnavailable` flag. The 150 km straight-line pre-filter is fixed and does not use the saved radius.

**What it shows.** A polite live region carries one sentence from `summarySentence`: the distinct supplier count within the chosen radius (`supplierCount` counts suppliers, not sites), or "No suppliers within N km of this project. The nearest is …", or `NO_RATES_TEXT` when there are no supplier rates. A list named "Sites within reach" shows each site within the radius as "Company — Site · N km" (`siteLine`). Sites without an address are counted in `noAddressSentence` and named when there are at most `NO_ADDRESS_NAME_LIMIT` (6).

**Address states.** With no usable form address the field shows `ADD_ADDRESS_TEXT` ("Add the project's address first to see the suppliers near it."). With a form address but no usable saved one it shows "Save the project to see the suppliers near this address." When the form address differs from the saved one it adds "These distances are from the saved address. Save the project to measure from the new one."

**Failures.** A failed load, or any `routing_unavailable` site, shows `MEASURE_FAILED_TEXT` ("We couldn't measure the distances just now. You can still choose a distance.") and a **Try again** button that re-runs the load.

**Draft and save.** `searchRadius` state in `ProjectSettingsPage.tsx` holds `{ projectId, saved, draft }`, scoped to the project ID so it never carries across projects. `draftChangesSaved(draft, saved)` makes the section dirty (an unusable **Other** counts, so it must be fixed). On save, `putSearchRadius(projectId, km)` writes `PUT /api/global/projects/:id/search-radius` before `upsertProject`, after the Multiple estimates write, so a failed details save never leaves the distance half-applied. A failed distance write returns "The search distance could not be saved." (or the server's sentence) and the details are not sent. `putSearchRadius` always calls `forgetSharedProject`, because the project DTO carries `searchRadiusKm`. After a save that moves the project, the page bumps `searchMeasureRun`, which the field takes as `measureRun` and measures again.

#### Hours per working day

The **Hours per working day** field reads its value from the project's primary estimate via `api-estimate`, loaded independently of the main project record through `useProjectReadiness<ProjectEstimate>` from `@stralos-co-nz/ui`. The estimate load runs in parallel with the rest of the page so an `api-estimate` outage costs only this field, never the page load or saves.

The field is always read-only on this page (PRD 210 §12.2). Each estimate owns its own working day; the value is changed on the estimate, not here. When the estimate has loaded (`workingDayLoaded`), the field shows the primary estimate's `hoursPerDay` value alongside an **Edit on the estimate** button. The button links to `/projects/:projectId/estimates/:estimateId?edit=settings`, which opens the estimate's settings sheet focused on the hours-per-working-day field. The field contributes no dirty state to the save bar.

The display moves through these states based on `estimateReadiness.state`:

| State | What the user sees |
| - | - |
| `preparing` | `ProjectPreparing` renders inline ("Preparing the estimate for this project…") with an automatic re-check every 5 seconds. After 30 seconds the check slows; after 2 minutes it stalls and shows a **Retry** button. The value and **Edit on the estimate** button are hidden. |
| `unavailable` | The value and **Edit on the estimate** button are hidden. The field helper reads "The working day can't be loaded right now. The rest of this page still saves." The `ProjectPreparing` component handles the **Retry** action. |
| `ready` | The primary estimate's hours value is shown in bold alongside the **Edit on the estimate** button. The field helper reads "Set on the \{estimateName} estimate. Company default: N hours." |

The `preparing` state is triggered by a `409 project_not_provisioned` response. `unavailable` covers network errors, `5xx` responses, and estimate-level `404`/`403` responses. A `404` or `403` from `api-estimate` is treated as `unavailable` rather than surfaced as "Project not found", because the page's own project load is the authority on access.

`classifyEstimateRead` in `ProjectSettingsPage.tsx` wraps `classifyProjectReadinessError` from `@stralos-co-nz/domain` and remaps `not-found` and `no-access` outcomes to `unavailable`.

`@stralos-co-nz/ui` 0.73.1 changed `SideSheet` to open non-modal on `md` screens and wider when the AI assistant is open, so both panels are usable side by side. On smaller screens the sheet continues to open as a modal overlay. This affects all three sheets on this page: Override status, `EditRulesSheet`, and `ChangeReviewSheet`.

`getProjectEstimate` in `src/api.ts` accepts an optional `AbortSignal` so `useProjectReadiness` can abort a stale polling check on unmount or when a new `projectId` is set.

The `ProjectPreparing` component is mounted in every state including `ready`. This keeps the component's ARIA live region in the DOM from the first render; a region mounted late loses its first announcement to assistive technology.

### `ProjectScopeCard`

`src/ProjectScopeCard.tsx` (lazy-loaded) renders the **Project scope** section (`scope`) and registers with `useSettingsSection` under scope `"Project scope"`. `ProjectSettingsPage` renders a `SettingsSaveBar` for that scope below the card.

**Saving.** The scope is sent in the project's own `PUT /api/global/projects/:id` body. `savedProjectBody(project)` builds that body from the saved project and returns `null` when the client, name or coordinates are missing, which blocks the save with `PROJECT_SCOPE_NEEDS_DETAILS`. A scope longer than `RICH_TEXT_MAX_CHARACTERS` (50,000) blocks with `PROJECT_SCOPE_TOO_LONG`. The server's `422 project_scope_too_long` and `422 project_scope_invalid` map to `PROJECT_SCOPE_TOO_LONG` and `PROJECT_SCOPE_INVALID`; anything else falls back to "The project scope could not be saved."

**Create scope with AI.** The page passes `estimateId` and `estimateName` from the estimate it uses for rate preferences. With no `estimateId` the button is disabled and the helper reads "The project's estimate must be available to create a scope with AI." With unsaved edits, a small `SideSheet` ("Replace your unsaved scope?", primary **Create AI draft**) confirms first. `generateProjectScopeDraft` in `src/projectScopeAi.ts`:

1. Reads every work item and section of the estimate (`GET /api/estimate/estimates/:id/work-items` and `/sections`, 200 a page, in parallel). No items throws "Add estimate items before creating a scope with AI."
2. Calls `POST /api/documents/projects/:projectId/tender-scope-draft` with `{ projectName, items: [{ description, section, quantity, unit }] }`. Quantities are numbers or `null`; no prices are sent.

The response is `{ scope, documentsUsed, documentsArePartial }`. `scope` becomes the editor draft, and `documentsUsed` drives the status line ("Draft based on estimate items and partial excerpts from: …" or "Draft based on estimate items only; …"). While generating, the editor is read-only and the save bar is blocked with "Please wait for the AI scope draft, or cancel generation." **Cancel generation**, a project or estimate change, unmount, and **Discard** abort the request; an aborted result is dropped and sets no error. A failure shows `userFacingError` with "The AI couldn't create a scope draft. Please try again." as the fallback, and the editor keeps its content.

### `ContractBasisCard`

Loads and saves the contract profile for a project (`GET`/`PUT /api/global/projects/:id/contract-profile`). The card loads the existing profile before rendering any inputs; if the load fails, it shows an error and a **Retry** button — the form is not editable until the load succeeds, to prevent an uninformed save from clearing stored data.

Contract form options are defined in `src/contractBasis.ts` as `CONTRACT_FORM_GROUPS` and are searchable. The "Custom / heavily amended form" option requires a non-empty label (`contractFormLabelError` validates this). Overhead percentages are validated with `parseOverheadPercent` (0–100, up to three decimal places, blank clears).

The card registers its dirty state with the page-level `SettingsDraftProvider` via `useSettingsSection`. The card has no footer of its own; a `SettingsSaveBar` scoped to `"Contract basis"` is rendered in the contract-basis pane by `ProjectSettingsPage`, outside and below the card.

#### Validation error timing for contract form

The "Select the contract form." inline error and the matching `blockedReason` passed to `SettingsSaveBar` are gated on the section's dirty state. The `ContractBasisFields` component accepts a `showRequired` prop (default `true`); `ContractBasisCard` passes its own `isDirty` flag as that prop. On a new project with no saved contract profile, the card starts clean, so `showRequired` is `false` and the error does not appear until the user edits the section. Once any field in the section is changed, `isDirty` becomes `true`, `showRequired` flips to `true`, and the missing contract form is treated as a blocking error in the usual way.

### `RatePreferencesCard`

`RatePreferencesCard` loads `GET /api/rates/project-rate-settings?projectId=…&estimateId=…` and the supplier, location, category, and rate-item catalogues. Each estimate keeps its own preference list. The card works on one estimate at a time; which one is controlled by the `?estimateId=` query-string parameter set by `ProjectSettingsContent`.

**Estimate picker (PRD 210 phase 2).** `RatePreferencesCard` receives `estimateId`, `estimateName`, and `estimateChoices` as props. When `estimateChoices` is non-null (the project has more than one live estimate), a `SelectCombobox` (accessible name "Estimate") appears at the top of the card. Selecting a different estimate updates the URL parameter (`setSearchParams`, `replace: true`) and remounts the card via a `key` change, which re-reads the list for that estimate. A single-estimate project passes `estimateChoices: null` and shows no picker. With a picker, the card's description names the estimate: "Where the \{estimateName} estimate prices from and which sources its lines prefer, in order. Each estimate keeps its own list."

Any estimate's list can be edited here, not only the primary's. Every write (`saveProjectRuleSet`, `deriveProjectRateContext`) sends `estimateId` to api-rates unconditionally. There is no fallback for a server that refuses the key.

The supplier, location, category, and rate-item catalogues are read once per page visit, not on every card mount. `ProjectSettingsPage` creates one `ratePreferenceCatalogueCache` (`src/api.ts`) around `getRatePreferenceCatalogues`, and the card reuses that read when it remounts on a section or estimate switch. The kept read is tied to the signed-in organisation and user (`sharedReadScope`), so a different user starts a fresh read. A failed read is not kept, so a retry issues a new request.

It renders the full ordered preference list in a read-only `DataTable`: the pricing-location entry at its stored `context.position`, followed or preceded by rules according to their positions. The first matching entry with an eligible option prices a line. **Edit rules** opens `EditRulesSheet`; all changes to the location or rules go through that sheet.

The `DataTable` includes a **Distance** column populated by `evidenceSentence(rule)` from `src/ruleText.ts` for each rule row (location entries always show `"—"`). The function reads `rule.source` and `rule.evidence` from the Rates API payload:

* **Source rule pointing to a specific place** (`source.locationId` non-null): if `evidence` is present, shows `"<km> km · <drive time>"` (e.g. `"4.9 km · 12 min"` or `"212 km · 2 h 38 min"`). If `evidence` is absent, shows `"— no route to this place yet"`. If `evidence.stale` is `true`, the string is suffixed with `" · stale"`.
* **Source rule pointing to an owner's All locations** (`source.locationId` null): shows `"Available at all locations"`. An All locations price applies everywhere, so it has no single distance.
* Policy rules (`rule.source` null) show `"—"`.

Drive times come from `formatDriveTime(minutes)` in `src/ruleText.ts`, which rounds to whole minutes: under 60 minutes renders `"N min"`, and 60 minutes or more renders `"N h"` or `"N h M min"`. The pricing-location summary uses the same formatter.

The card suppresses its drift check for a frozen estimate. For an active estimate, it calls `previewRuleSet` with the stored entries and `detail: "counts"` once per project and estimate. A count of out-of-date lines, or a pending re-price marker in `localStorage` (`stralos.projectSettings.pendingRuleReprice.<projectId>.<estimateId>`), shows **Review and re-price**. The legacy key `stralos.projectSettings.pendingRuleReprice.<projectId>` (which stored the estimate id as its value) is also read on load and cleared when the matching estimate's re-price completes, so a pending marker in the older format is not lost. That action previews the stored list with `detail: "lines"` and opens `ChangeReviewSheet` without a rule-set write.

Saving a draft with no estimate or a frozen estimate writes the list directly. With an active estimate, `previewRuleSet(estimateId, entries, "lines")` runs before any write. If `groups` contains affected lines, `ChangeReviewSheet` opens; cancellation leaves the draft unsaved. If no line changes price, the list saves directly. A quiet re-stamp may then use `rule-change-apply` with empty `apply`, `pin`, and `unpin` arrays under the preview digest. The rule-set write is the commit point; re-pricing follows it. The pending marker remains when re-pricing fails or its completion is uncertain.

**Re-apply rate preferences…** routes to `/projects/:projectId/estimates/:estimateId/builder/rates/re-apply` when an estimate is named, or `/projects/:projectId/estimate/builder/rates/re-apply` when none is set. It is unavailable when there is no estimate or a write is in flight.

### `EditRulesSheet`

`src/EditRulesSheet.tsx` renders a `SideSheet` (`size="xl"`) containing a `DraftEntry[]`. `draftFromPayload` inserts the pricing-location entry at `context.position` (position 1 by default) among rules sorted by their stored positions. The location entry is reorderable but cannot be deleted; `source: "none"` removes its pricing effect. Rules can be added, edited, removed from the draft, and reordered by drag or **Move up** / **Move down**. `insertionIndex` places a new rule by specificity as a starting position, subject to user reordering. `entriesFromDraft` validates and serialises the complete ordered list used by this frontend. Rule kind is either `source` (whose price and where, picked via `RuleTargetControl` using `RateLocationPicker` from `@stralos-co-nz/ui-reference-data` 0.3.0) or `policy` (cheapest within a radius in km). Both kinds save and re-price end to end via the Rates and Estimate services.

The sheet previews the draft after a 400 ms debounce with `previewRuleSet(estimateId, entries, "counts")`. It shows the projected line count and per-entry `PreferenceNote` where a prior entry shadows a rule. A rule form updates dependent fields when its scope or target type changes and announces a live sentence explaining the draft. **Save** is the only write action. Closing a dirty sheet prompts before discarding it.

### `ChangeReviewSheet` and `ReviewList`

`ChangeReviewSheet` is the shared `SideSheet` (`size="xl"`) for an edited rule list or a pending re-price. `ReviewList` flattens the hierarchy rate group → rate item → estimate schedule line into a virtualised list. Its tri-state group selection is computed from the complete data set, including rows outside the viewport. Large reviews start with groups collapsed.

Re-priced lines start ticked. Unticking a line pins it to its current option, or to an eligible alternative selected for its rate item. Existing pinned lines start unticked; ticking one unpins it and takes the rules' default. Managed companion removals follow their parent line. `decisionsFor` builds the atomic apply body as `apply`, `pin`, and `unpin` arrays; there is no `componentIds` or `keepPinned` wire field.

The sheet saves the ordered list with `PUT /api/rates/project-rate-settings/rule-set`, then calls `POST /api/estimate/estimates/:id/rule-change-apply` using the preview `impactDigest` and the three decision arrays. A `409 stale_rule_change_preview` refreshes the review instead of applying stale decisions. A failed or uncertain post-save apply keeps the pending re-price visible. A `RuleSaveUnconfirmedError` triggers read-back before the UI claims the list was saved.

### `DayRatesCard`

`src/DayRatesCard.tsx` renders the **Day rates** section (`day-rates`). It lists the day-rate schedules the project's estimates pick from, each pinned to one revision. Add, Remove, and Update apply as soon as they are confirmed: the card has no save bar, is not a settings section, and does not register with `SettingsDraftProvider` (the 2026-10-04 settings ruling for panes that save their own way).

**Loading.** On mount the card loads, in parallel, the project's set (`getProjectDayRateSet`, `GET /api/rates/projects/:projectId/day-rate-set`) and the organisation's schedules (`listOrganisationDayRateSchedules`, `GET /api/rates/day-rate-schedules` with today as the in-force date). Both share a load token, so stale responses from a previous project ID or an earlier load are dropped. If the set fails to load, the card shows an error and a **Retry** button. If only the schedules fail, the set still renders and the schedules error is shown separately.

`sortSet` in `src/dayRateSet.ts` orders the set: client schedules before organisation schedules, then alphabetically by client and schedule name. Each row shows **Automatic** (`origin === "auto"`) or **Added**, an **Archived** badge when archived, the owner, and the pinned revision.

**Access.** The gate is advisory: any project member may attempt a write. A `403` from api-rates on any write turns the card read-only for the session, with "You can view this project's day rates, but not change them."

**Writes.** All writes run through a shared `write` helper: one write in flight at a time (`busy`), `actionError` cleared at the start, an error toast on failure, and a `reload()` of the set afterwards.

* **Add.** `addableChoices` builds the combobox (accessible name "Add schedule", placeholder "Choose a schedule to add"): active schedules with a revision in force that are not already in the set. The project's own client's schedules come first (rank 0), then other clients' (rank 1), then organisation-wide (rank 2). When nothing is left it shows "Every active schedule is already in this project." Add calls `POST /api/rates/projects/:projectId/day-rate-set` with `{ scheduleId }`. A `409 day_rate_schedule_already_in_set` is swallowed (the outcome asked for already holds). A `422 day_rate_schedule_not_in_force` surfaces as "That schedule has no revision in force today, so it can't be added yet." and a `422 day_rate_schedule_archived` as "That schedule has been archived, so it can't be added." Other failures use `userFacingError` with the caller's fallback.
* **Remove.** Opens a `ConfirmDialog` naming the schedule. While it is open, `getDayRateUsage` (`GET /api/estimate/day-rate-usage?scheduleId=…&revisionId=…&projectId=…`) counts the build-up lines in this project's in-progress estimates that use the pinned revision; a failed count shows a fallback sentence. Confirming calls `DELETE /api/rates/projects/:projectId/day-rate-set/:scheduleId`. A `404` is treated as success (the row was already gone).
* **Update.** When a row's `newerRevision` is non-null, an **Update** button opens `DayRateUpdateDialog` with the schedule ID, name, and revision reference. The card reloads the set when the dialog closes with `changed: true`.

**Client-changed prompt (R16).** `detectClientChange` in `src/dayRateSet.ts` treats a schedule the set took in automatically (`origin === "auto"`) for a client other than the project's current client as evidence that the client changed. A schedule a user added from another client (for example a head contractor's) never prompts. The card passes the project's **saved** `clientId` and `clientName`, not a partly edited draft from the details form. The prompt is an orange-bordered **Client changed** section whose heading depends on the state:

* New client has schedules not yet in the set: "The client changed. Add \{client}'s schedules?" — with an **Add** button that adds each of them (`addProjectDayRateSchedule`) in one write action.
* All of the new client's schedules are already in the set (`newClientInSet`): "The client changed. All of \{client}'s day-rate schedules are in this project."
* New client has no schedules in force: "The client changed. \{client} has no day-rate schedules in force."

The previous client's schedules remain in the set and are listed with individual **Remove** buttons.

### `DayRateUpdateDialog`

`src/DayRateUpdateDialog.tsx` is the "Update estimates using \{schedule}?" dialog (PRD §4.5), scoped to one project (R17). feature-rates opens the same dialog across every project after an admin publishes a revision; this copy is self-contained in this remote. On open it calls `getDayRateUsage` for the project and schedule and lists this project's in-progress estimates. Each row shows the estimate's name, type, status, line count, current total, new total, and difference.

Each tick box's accessible name comes from `tickLabels(estimates)`: "Update \{name} (\{type})", where type is "Tender" or "Delivery". The type is always included because a Delivery estimate is copied from the won tender and may share its name. If two rows still produce the same label, each is suffixed with its position among its duplicates ("…, 1 of 2"), so no two tick boxes share an accessible name.

Nothing is ticked on open, and **Skip** writes nothing (R3, R15). **Update** is disabled until at least one estimate is ticked, except when the project has no in-progress estimates; then it is enabled straight away and moves only the project's revision pin. Ticking an estimate with no lines from the schedule also moves only the pin, so future picks use the new rates. An edited line keeps its edited rate.

**Update** calls `applyDayRateUpdates` → `POST /api/estimate/day-rate-updates` with `{ revisionId, estimateIds }` for the ticked estimates, or `{ revisionId, estimateIds: [], projectIds: [projectId] }` for a project with no in-progress estimates. The dialog then shows each estimate's outcome with `resultLabel` (`"updated"` or, for example, `"skipped: it was submitted in the meantime"`) and `resultDetail` (lines moved, kept edited, and no longer in the schedule). Closing calls `onClose(results != null)`, so the card reloads the set only when an update ran.

If the revision's `effectiveFrom` is in the future, the dialog says that updated estimates take the new rates straight away rather than waiting for the effective date.

### `SharedDriveCard`

`src/SharedDriveCard.tsx` renders the **Shared drive** section (`shared-drive`), the last entry in the rail. It lists this project's drive mappings and, for an organisation admin, maps a drive folder to this project. Rows, empty-state sentences, and the mapping sheet come from `@stralos-co-nz/drive-sync-ui` (`DriveMappingRow`, `DriveMappingSheet`, `driveSyncCopy`), the same package Global Settings uses, so the two entry points match.

On load the card reads the drive-sync switch (`readDriveSyncEnabled`) alongside `listDriveSources` and `listProjectConnections`. It renders nothing until the first read settles, and nothing at all when the switch returns `false`. An unknown switch (a failed read, or a server that predates the switch) keeps the card. A failed list read shows an error with **Retry**.

| State | Admin sees | Member sees |
| - | - | - |
| No active drive source | `driveSyncCopy.empty.noDriveAdmin`, linking to **Settings → Shared drives** (`/settings/shared-drives`) | `driveSyncCopy.empty.noDriveMember` |
| Active source, no mappings | **Map a folder** in the card header | `driveSyncCopy.empty.notMappedMember` |
| Mappings present | A `DriveMappingRow` per mapping with **Sync now**, **Pause** or **Resume**, and **Remove** | Rows with **Sync now** only |

**Map a folder** opens `ProjectMappingSheet`, a thin wrapper around `DriveMappingSheet` with `projectId` fixed, so the project is shown read-only. It uses `satisfies DriveMappingSheetProps` so drift between this client's domain shapes and the package's props fails at the call site.

Row actions run through `runRowAction`, which marks the row busy, calls the API, reloads the lists, and posts a sentence ("Sync requested", "Mapping paused", "Mapping resumed", "Mapping removed") to the page's status region through `onAnnounce`. A failure renders as a `role="alert"` line below the list.

`isAdmin` is an advisory gate: it hides the mapping, pause, resume, and remove controls, while api-documents enforces them with `requireAdmin`.

### `SettingsDraftGuard`

`SettingsDraftProvider` maintains two registries, both keyed by a stable React `useId`. The provider coordinates the navigation blocker; it does not render a save bar — each section's pane renders its own `SettingsSaveBar`.

* **Section registry** — full-page sections that participate in the shared save bar. A section registers via `useSettingsSection`, supplying its `scope` name, `dirty` flag, an optional `blockedReason` sentence (prevents saving), and `save`/`discard` callbacks. The save bar reads this registry.
* **Draft registry** — editors that save their own way and only need the navigation blocker, not the save bar. They register via `useSettingsDraft(scope, dirty)`. The one user today is `EditRulesSheet` (scope `"Rate preferences"`), which has its own **Save**.

The provider computes `dirtyScopes` from the union of both registries. When at least one scope is dirty, it attaches a `beforeunload` handler and (when a data-router is present) mounts `RouteDraftGuard`.

`RouteDraftGuard` calls `useBlocker` from `react-router-dom` to intercept same-app navigations. The blocker fires when `dirtyScopes` is non-empty and either `pathname` or `search` changes — this covers rail section switches, cross-page navigations, and browser Back/Forward. A `Dialog` opens with three actions:

* **Save changes** (primary): calls `saveSections` for the dirty sections in registration order, then calls `blocker.proceed()` only if all saves succeeded. If a save fails, the dialog stays open with the failure message; nothing is lost.
* **Discard changes** (destructive): calls each dirty section's `discard()` callback, then `blocker.proceed()`.
* **Keep editing** (cancel): calls `blocker.reset()`.

When a dirty scope is a plain draft rather than a page section (the **Edit rules** sheet), **Save changes** is disabled with the reason "Save or cancel the rules you are editing first." A `blockedReason` on a dirty section also disables **Save changes**, prefixed with the section name.

Only `blocker.proceed()` / `blocker.reset()` from the dialog callbacks advance or cancel the navigation — the dialog never navigates itself.

### `SettingsSaveBar`

`SettingsSaveBar` is a **Discard + Save changes** pair scoped to one section. It takes a `scope` prop (the section's display name) and reads only the sections whose `scope` matches from the section registry.

* Shows **Unsaved changes** when the section is dirty, and **No unsaved changes** when it is not.
* Disables **Discard** and **Save changes** when the section is not dirty.
* Disables **Save changes** (but not **Discard**) when a dirty section reports a `blockedReason`. The reason is prefixed with the section name (e.g. `Project details: {reason}`).
* On **Save changes**, calls each dirty entry's `save` callback in registration order. On full success, posts a toast: `{scope} saved`. On failure, shows the result inline (e.g. "Nothing was saved. {reason}." or "Saved {X}. Not saved: {Y}. {reason}").
* The alert region that holds the outcome message is mounted from the first render so assistive technology announces it when it first receives text.

`SettingsSaveBar` is rendered at the bottom of the **Project details** pane (scope `"Project details"`), the **Project scope** pane (scope `"Project scope"`), and the **Contract basis** pane (scope `"Contract basis"`). **Rate preferences** (saved from `EditRulesSheet`), **Day rates**, and **Shared drive** have no section save bar.

## API layer

### Cross-remote shared reads (NET-01/02)

`getProject` and `getOrganisationSettings` in `src/api.ts` route their reads through `sharedRead` from `src/sharedReads.ts`. This keeps a small in-memory store on `globalThis` under the key `__stralosSharedReadsV1`, using the same pattern as `__stralosPosthog`, so a read issued by one remote (for example, the workspace entry-check or the onboarding widget) answers this remote's mount read without a second network request.

The store is keyed by `${organisationId}:${userId}` (the scope, derived from `src/sharedReadScope.ts`) and the request path including query string. Entries expire after 30 seconds. Behaviour details:

* A scope of `null` (no signed-in user) bypasses the store entirely; the load function is called directly.
* A successful read is stored. A rejected load is never stored; a caller that joined a read that then rejects re-issues its own load, so every participant sees its own error class and HTTP status.
* Pass `force: true` (or call `getProject(id, { fresh: true })`) for a read that must hit the network — after a write, or to resync on a failure. A forced read still refreshes the store.
* After a write, call `invalidateSharedReads(scope, urlPrefix)` for the affected URL prefix. `forgetSharedProject` in `src/api.ts` calls this after `upsertProject` and `transitionProject`.
* Values are `structuredClone`d per caller, so mutating a result is safe.

The store key is versioned (`V1`). To change the contract, bump the key to `V2` in every participating remote so mixed deploys bypass each other's entries rather than reading mismatched shapes.

`src/sharedReads.ts` is **copied verbatim** into each participating remote. Never edit one copy in isolation. Each remote also exports `resetSharedReadsForTests` as a test seam; every test file starts with an empty store.

### Runtime URL resolution

Each service module resolves its base URL through the following precedence chain (highest to lowest):

1. Vite environment variable (`VITE_STRALOS_GLOBAL_API_URL` / `VITE_STRALOS_API_URL`, `VITE_STRALOS_RATES_API_URL`, `VITE_STRALOS_ESTIMATE_API_URL`, `VITE_STRALOS_DOCUMENTS_API_URL`).
2. `window.__STRALOS_API_URLS__` — a runtime seam the shell host injects with `{ global, rates, estimate, documents }` URLs.
3. `localStorage.getItem("stralos.<module>ApiUrl")` — a per-module override for local development.
4. `sameOriginFallback(defaultApiUrls[module])` — in a production build, returns `window.location.origin`; under Vite dev it returns the hard-coded localhost default (`global: 3300`, `rates: 3301`, `estimate: 3303`, `documents: 3304`).

### Endpoints

**Global service (`api-global`, port 3300)**

| Operation | Method | Path |
| - | - | - |
| Get project | `GET` | `/api/global/projects/:id` |
| Upsert project | `POST` / `PUT` | `/api/global/projects` / `/api/global/projects/:id` |
| Transition project status | `POST` | `/api/global/projects/:id/transition` |
| Get contract profile | `GET` | `/api/global/projects/:id/contract-profile` |
| Save contract profile | `PUT` | `/api/global/projects/:id/contract-profile` |
| List clients (parties, role=client) | `GET` | `/api/global/parties?role=client` |
| List all parties (catalogue) | `GET` | `/api/global/parties` |
| List a client's contacts | `GET` | `/api/global/parties/:id/contacts` |
| Add a contact to a client | `POST` | `/api/global/parties/:id/contacts` |
| List all locations (catalogue) | `GET` | `/api/global/locations` |
| List office locations | `GET` | `/api/global/locations?owner=ours&status=active` |
| List supplier sites (search distance names) | `GET` | `/api/global/locations?owner=theirs` |
| List suppliers (search distance names) | `GET` | `/api/global/parties?role=supplier` |
| Resolve road distances (search distance) | `POST` | `/api/global/projects/:id/location-routes/resolve` |
| Save search distance | `PUT` | `/api/global/projects/:id/search-radius` |
| List setting list items | `GET` | `/api/global/setting-list-items?listKey=…` |
| Get organisation settings | `GET` | `/api/global/organisation-settings` |

The contract profile `GET` endpoint is 404-free: when no profile exists for a project it returns a default payload with `exists: false`. A `PUT` upserts.

**Rates service (`api-rates`, port 3301)**

| Operation | Method | Path |
| - | - | - |
| List rate sites (search distance) | `GET` | `/api/rates/rate-sites` |
| Get project rate settings | `GET` | `/api/rates/project-rate-settings?projectId=…[&estimateId=…]` |
| Save ordered rule set (location + rules) | `PUT` | `/api/rates/project-rate-settings/rule-set` |
| Derive rate context (dry run) | `POST` | `/api/rates/project-rate-settings/derive-context` |

`PUT /api/rates/project-rate-settings/rule-set` accepts `{ projectId, estimateId?, expectedSettingsUpdatedAt, entries: RuleSetEntry[] }`. `estimateId` names the estimate whose list is being saved; omitting it targets the primary. The `entries` array is the full ordered list: a single `{ kind: "location", source, locationId }` entry plus any number of `{ kind: "rule", id?, scopeKind, scopeRef, ...selection }` entries. A rule with an `id` is updated; one without an `id` is created; a live rule absent from the array is soft-deleted. Returns the full updated `ProjectRateSettingsPayload`.

`POST /api/rates/project-rate-settings/derive-context` similarly accepts `{ projectId, estimateId?, dryRun: true }`.

`GET /api/rates/project-rate-settings` accepts `estimateId` as an optional query parameter; omitting it returns the primary estimate's settings.

Error responses from the rule-set write: `409 settings_changed` (another save landed since the read, so nothing was written), `409 derived_context_changed` (the auto-derived location changed; the body carries fresh `settings`).

**Rates service (`api-rates`, port 3301) — day-rate set**

| Operation | Method | Path |
| - | - | - |
| Get project day-rate set | `GET` | `/api/rates/projects/:projectId/day-rate-set` |
| Add schedule to set | `POST` | `/api/rates/projects/:projectId/day-rate-set` |
| Remove schedule from set | `DELETE` | `/api/rates/projects/:projectId/day-rate-set/:scheduleId` |
| List organisation schedules | `GET` | `/api/rates/day-rate-schedules` |

**Estimate service (`api-estimate`, port 3303)**

| Operation | Method | Path |
| - | - | - |
| Get project estimate (primary) | `GET` | `/api/estimate/projects/:id/estimate` |
| List estimate work items (scope AI) | `GET` | `/api/estimate/estimates/:id/work-items` |
| List estimate sections (scope AI) | `GET` | `/api/estimate/estimates/:id/sections` |
| List project estimates | `GET` | `/api/estimate/projects/:id/estimates` |
| Get estimate settings | `GET` | `/api/estimate/projects/:id/estimate-settings` |
| Save estimate settings | `PUT` | `/api/estimate/projects/:id/estimate-settings` |
| Preview rule set | `POST` | `/api/estimate/estimates/:id/rule-change-preview` |
| Apply rule set decisions | `POST` | `/api/estimate/estimates/:id/rule-change-apply` |
| Get day-rate usage (lines + estimates) | `GET` | `/api/estimate/day-rate-usage` |
| Apply day-rate revision updates | `POST` | `/api/estimate/day-rate-updates` |

`GET /api/estimate/projects/:id/estimates` returns the project's live estimates (tender and delivery; not saved versions and not estimates hidden at award). The response envelope may be a page `{ items: [...] }` or a bare array — `listProjectEstimates` in `src/api.ts` handles both forms. A 404 from this endpoint (api-estimate older than PRD 210) is caught silently; the page falls back to working on the primary estimate alone.

**Documents service (`api-documents`, port 3304)**

| Operation | Method | Path |
| - | - | - |
| List project documents | `GET` | `/api/documents/?projectId=…` |
| Create tender scope draft | `POST` | `/api/documents/projects/:projectId/tender-scope-draft` |
| Read drive-sync switch | `GET` | `/api/documents/drive-identities` |
| List drive sources | `GET` | `/api/documents/drive-sources` |
| List a project's mappings | `GET` | `/api/documents/sync-connections?projectId=…` |
| Browse source folders | `GET` | `/api/documents/drive-sources/:id/folders` |
| Create mapping | `POST` | `/api/documents/sync-connections` |
| Pause or resume mapping | `PATCH` | `/api/documents/sync-connections/:id` |
| Remove mapping | `DELETE` | `/api/documents/sync-connections/:id` |
| Request immediate sync | `POST` | `/api/documents/sync-connections/:id/sync-now` |

The drive-sync calls live in `src/documentsSyncApi.ts` and use the same `apiGet` / `apiSend` helpers as `src/api.ts`. Reads and sync-now are open to any organisation member; folder browse and the mapping writes require an organisation admin (`requireAdmin` in api-documents' `syncAdmin` routes).

### Error handling

API failures surface an `ApiError` instance carrying the HTTP status and the server's `code` field. The `ProjectSettingsPage` catches the `end_date_before_start_date` code (HTTP 422) from `upsertProject` and renders the message inline on the **End date** field in addition to the toast. All other errors fall back to `error.message` or a static string.

Stale-status conflicts on status transitions (`409 stale_status`) trigger a refetch before toasting, so the badge and action bar reflect the server's current state before the user sees the error.

#### User-facing error text (`userFacingError`)

Error messages shown on screen are produced by `userFacingError(cause, fallback)` in `src/errorText.ts`. A person sees the server's own sentence, never the API URL or HTTP status line.

The function distinguishes three cases:

* **Not an `Error`, or a `TypeError`** (network-level failure such as "Failed to fetch") — returns the caller's `fallback` string.
* **An error whose message does not match the transport prefix** — the message is already a human-readable sentence (one the remote itself authored); it passes through unchanged.
* **A transport-shaped message** (matches `API request failed with status \d+(?: for \S+)?(?: - …)?`) — extracts the server's sentence from the message tail. If the `ApiError` carries a `body.reference` string, the returned text is `"<sentence> Reference: <id>"`.

When the server sends nothing after the status line, the fallback string is used.

#### 503 busy-GET retry

When a `GET` request returns `503` with a `Retry-After` header containing a whole-number value of 10 seconds or fewer, `fetchHonouringRetryAfter` in `src/api.ts` waits that many seconds and issues one automatic retry. The retry is silent — no loading-state change is visible to the user.

Writes (`POST`, `PUT`, `DELETE`) are never retried automatically. A `503` on a write means the server may or may not have committed the operation; replaying it is not safe. A `Retry-After` value greater than 10 seconds is ignored and the 503 is surfaced as an error.

### Auth

The access token is read from `localStorage` (`stralos.auth.accessToken`). On a 401 the client calls `window.__STRALOS_AUTH__.refreshAccessToken()` (the shell auth bridge) and retries once. If the bridge is absent (standalone mode without the shell), it re-reads the stored token. A still-401 after the retry ends the dead session and redirects to `/login?reason=expired&from=…`.

Ending the dead session is required before the redirect. The shell's `/login` route considers a session live on presence alone — if `stralos.auth.accessToken` and `stralos.auth.user` are both non-null in `localStorage`, the login page sends the visitor straight to the default route, ignoring `reason=expired`. Without clearing first, the user lands back on the page that just failed and the redirect loops. `endDeadSession()` calls `window.__STRALOS_AUTH__.clearSession()` when the host bridge is available; if the bridge is absent or throws, it removes the three `stralos.auth.*` keys directly before the redirect runs.

The first-401 path (where token refresh itself fails) is left alone. The shell's `performRefresh` clears the session before resolving null, so this remote does not duplicate that step.

Admin-only controls (the **Override status** button) are gated by reading `localStorage["stralos.auth.user"]` and checking `user.role`. This check is advisory; the server enforces the admin restriction with a `403`.

### Project request events

`authedFetch` passes responses to `observeProjectRequest`, which fires a `stralos:project-request` `CustomEvent` for a successful GET or HEAD, a successful non-OPTIONS mutation, or a `403`/`404` response. Other outcomes do not emit an event. The workspace shell listens for eligible outcomes to revalidate project access without waiting for the user to navigate. No response body, bearer token, or project payload is carried in the event.

The event detail is versioned:

```ts theme={null}
{ version: 1, path: string, kind: "refused" | "edited" | "read" }
```

`path` is `window.location.pathname` at the time the request was made. The event is only dispatched when the path has not changed by the time the response arrives — a navigation that happened mid-flight suppresses it. The `kind` values are:

| Kind | Trigger |
| - | - |
| `"refused"` | Response status is `403` or `404`. |
| `"edited"` | Response is successful and the method is not `GET`, `HEAD`, or `OPTIONS`. |
| `"read"` | Response is successful and the method is `GET` or `HEAD`. |

Successful reads are reported because RLS-filtered list endpoints may return `200` with no rows after access is removed. The workspace shell uses all three kinds to decide when to revalidate project access.

The event does not replace the existing 401 flow. A `403` or `404` fires `"refused"` and throws an `ApiError`; the workspace shell handles revalidation. A `401` still triggers the token-refresh and session-clear path described above.

## CI workflows

### Package compatibility check

`build-remote.yml` runs `scripts/check-package-compat.mjs` before `pnpm install` as the step "Refuse a known-incompatible package pair". It reads the resolved versions from the committed lockfile (`.ci/workspace/pnpm-lock.yaml`) and fails the build if a known-incompatible pair is present.

The current rules refuse a lockfile that resolves `@stralos-co-nz/ui >=0.57.0` alongside `@stralos-co-nz/maps-ui <0.3.0`, or `ui >=0.73.0` alongside `maps-ui <0.5.0`. `ui` 0.57.0 removed `SupplierEditorModal`, which `maps-ui` 0.1.x and 0.2.0 import; `ui` 0.73.0 removed rate-reference exports that `maps-ui` 0.4.0 and earlier import. Because `ui` is not a Module Federation singleton (each remote bundles it whole), pnpm's peer resolution does not catch this pairing at install time; the lockfile is the only place to enforce it.

The check runs on the runner's pre-installed Node before any install step. When a new `ui` release removes something a published `maps-ui` version imports, add an entry to the `INCOMPATIBLE` array in `scripts/check-package-compat.mjs`. The file is byte-identical across the estimate, workspace, project-settings, and organisation remotes — update all four.

To resolve a failure: move `maps-ui` forward to a version that is compatible with the pinned `ui` (or hold `ui` back), then regenerate `.ci/workspace/pnpm-lock.yaml`.

### Produce for dev (`deploy-dev.yml`)

`deploy-dev.yml` runs automatically on pushes to `main` and also supports manual dispatch for an explicit ref. It calls `build-remote.yml` with `deploy_dev: true`, then runs two publishing jobs:

1. `publish-by-sha` — writes the deterministic `tar-v1` component archive to the durable artefact store using `putWriteOnce`. A re-dispatch for a sha that is already stored is safe: identical bytes are resumed; byte-different bytes fail closed.
2. `publish-remote-dev` — synchronises `dist-remote/` to the by-SHA prefix in the dev remotes bucket. It does not update the live manifest.

`gate-dev` in `stralos-core` verifies a compatible remote set and activates it. A successful produce run makes the commit available for activation and stable release; it does not prove that dev is serving it.

`build-remote.yml` also runs the size regression gate (step "Refuse a size regression over 10 % (thin-host D7)"). It judges the artifact's JS gzip total against the base branch's `.ci/remote-size-baseline.json`. A PR that grows the bundle by more than 10 % is blocked unless the pull request body contains `remote-size-baseline: <new-value>` and the committed baseline is updated to match. Run `node scripts/remote-size.mjs measure` locally for a per-file breakdown. The build itself sets `ROLLDOWN_WORKER_THREADS=1` to serialise graph traversal for a reproducible size measurement.

#### `archive-remote.mjs` CLI

The produce step is driven by `scripts/archive-remote.mjs produce`. Its accepted options are:

```
node scripts/archive-remote.mjs produce \
  --directory dist-remote \
  --bucket BUCKET \
  --remote NAME \
  --sha 40HEX \
  [--built-against-ui V] \
  [--built-against-domain V] \
  [--vite-config FILE] \
  [--federation-contract FILE]
```

`--directory`, `--bucket`, `--remote`, and `--sha` are required. All four options in brackets are optional: `--built-against-ui` and `--built-against-domain` are ignored under federation contract v2 (the record is derived from the contract file instead); `--vite-config` defaults to `vite.config.ts` and `--federation-contract` defaults to `.ci/shell-federation-contract.json`. If a required option is missing, the error message lists every option the subcommand accepts.

The `release` subcommand is retired in this repository. Do not use it.

### Test production

`deploy-test.yml` publishes the `projectSettings` remote at `remotes/projectSettings/<version>/` on a prerelease event, manual dispatch, or when called by the central release orchestrator via `workflow_call`. It accepts a `version` input in either stable `X.Y.Z` or release-candidate `X.Y.Z-rc.N` form. It uses a write-once claim to check repeated builds and occupied objects. Publication is separate from activation. See [Remote test production and promotion](https://help.stralos.app/internal/release-model.md#remote-test-production-and-promotion) for source selection, claim verification, and recovery.

**Already-released check** — Before the build starts, the `already-released` job reads `remotes/projectSettings/by-sha/<commit>/test-version.json` from the test bucket. If that pointer exists and its companion completion marker (`remotes/projectSettings/<version>/complete.json`) also exists, the run fails with an `already-released` error naming the previously produced version. Refusing before the build means the cheapest produce is one that does not happen.

A pointer without a marker means a produce died between writing the two records. That half-finished produce is logged and allowed through so a re-run can legitimately finish it.

At the end of a successful produce, two write-once records are written in order:

1. `remotes/projectSettings/<version>/complete.json` — the completion marker, written only after the destination key set is verified against the claim. It records the verified keys and sizes (not an end-to-end digest, because a multipart upload's ETag is not the file's SHA-256).
2. `remotes/projectSettings/by-sha/<commit>/test-version.json` — the by-commit pointer, written last. It is only true if the marker is present, and the marker is only true if the version is whole.

To produce a second test version from the same commit — for example, when a version number must change due to a renumbering or an `occupied-by-different-bytes` error — set the `allow_second_version_for_commit` dispatch input to `true`. The by-commit pointer is write-once and continues to name the first version.

### Central release (`release-create.yml`)

`release-create.yml` is the entry point for the central release orchestrator in `stralos-core`. The orchestrator dispatches it at the release tag with six standard inputs: `environment`, `version`, `sha`, `correlation_id`, `source_version`, and `promotion_digest`. All six are declared even if unused, because the orchestrator sends the same set to every member remote and a missing declaration causes a 422 refusal.

The workflow behaves differently depending on `environment`:

* **`test`** — calls `deploy-test.yml` via `workflow_call` with the candidate version (which may be `X.Y.Z-rc.N`). This is the only produce step for this environment; no rebuild happens at promotion time.
* **`prod`** — calls `stralos-core`'s `frontend-release-promote.yml` with `profile: remote` and `name: projectSettings`. Promotion re-keys the test bytes from `X.Y.Z-rc.N` to `X.Y.Z` without rebuilding.

A `result` job runs after both branches (using `if: always()`) and calls `stralos-core`'s `release-create-result.yml` to report the outcome back to the orchestrator. The orchestrator reads the `release-create-result` artifact the final job leaves on success and failure.

Do not dispatch `release-create.yml` by hand. It does not make anything live; a create that nobody activates is only clutter. Use the central release orchestrator in `stralos-core` instead.

### Release

The per-repository `release.yml` workflow and `scripts/archive-remote.mjs release` command are retired. Use the central release orchestrator in `stralos-core`. Test creation reuses the archived dev bundle with `from_dev: true`; production promotes the test bytes without rebuilding.

## Federation contract

This remote operates under federation contract v2 (`stralos-shell/federation/v2`). Under v2, `@stralos-co-nz/ui` and `@stralos-co-nz/domain` are bundled directly into this remote rather than provided as host singletons — each remote bundles Chakra whole. The only shared singletons at the host level are `react`, `react-dom`, `react-router-dom`, and `react/jsx-runtime`, each at an exact version pin with `import: false` and `strictVersion: true`.

The singleton set is defined in `.ci/shell-federation-contract.json`, which is the single source of truth. `vite.config.ts` passes `contract.shared` directly to the federation plugin, and `scripts/federation-contract.mjs` derives all other artefacts from the same file.

### Share guard

`src/routes.ts` carries one specifier-less import for each `import: false` entry in the contract file:

```ts theme={null}
import "react";
import "react-dom";
import "react-router-dom";
import "react/jsx-runtime";
```

These four lines keep the federation plugin's export analysis open. Without them, `@module-federation/vite` finalises each share's virtual module from a usage scan that ends on a 10 ms settle timer. On a cold file cache the scan can call itself complete before the full export list is known, causing Rolldown to die on `MISSING_EXPORT`. The specifier-less imports mark each share unsafe to narrow, so the virtual module retains its complete export surface regardless of scan timing. This adds roughly 1 KB gzip. `src/shareGuard.test.ts` fails if the guard imports and the contract entries ever drift.

See [Micro-frontends](https://help.stralos.app/internal/micro-frontends.md) for the full singleton policy.


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