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

# Organisation settings frontend

> The organisation micro-frontend remote (stralos-feature-organisation)

`stralos-feature-organisation` is the Module Federation remote that owns organisation administration under `/settings`. It is a thin-host M3 deliverable (PRD §9.1 and §9.2) that extracts the settings surface from `stralos-shell` into its own independently deployable remote.

## Status

The organisation remote owns the authenticated `/settings` surface. The shell loads `organisation/routes` from the runtime manifest, and `stralos-core` includes the `organisation` descriptor in the manifest seed and dev reconciliation set. The shell no longer carries a second copy of the settings surface.

The authenticated settings tree is exported from `./routes`. The personal-security surface (`/settings/security` and `/settings/security/callback`) is no longer served from this remote. It moved to `stralos-login-web`, which runs on the login host. The region serves neither path. See [Frontend architecture — Personal security settings](https://help.stralos.app/internal/frontend-architecture.md#personal-security-settings).

## Payment method card removal

`PaymentMethods` renders a **Remove** button for each card only when the card is removable or when the server supplies no reason it cannot be removed. When the server marks `canRemove: false` and supplies a `removeBlockedReason` string, the button is omitted and the reason sentence is displayed in its place. This prevents a live-looking **Remove** from appearing directly above a sentence explaining why removal is not possible.

Error messages shown in the panel come from `userFacingError` (`src/errorText.ts`), which extracts the server's own sentence and reference ID from the error. Screens never display the API URL or the HTTP status line — only the server's sentence plus `Reference: <id>` when one is present.

A GET request that receives HTTP 503 with a `Retry-After` header of 10 seconds or less is retried once automatically. Writes (non-GET requests) are never retried on 503.

## Billing access modes and blocked account recovery

`BillingPanel` renders three access states when billing is enforced and access is not full:

* `read_only` — the organisation is in the payment grace period. The panel shows a warning that includes the date access will be blocked (`access.readOnlyEndsAt`), and the **Manage subscription** button remains visible so the admin can correct the payment method.
* `blocked`: the grace period has ended. The notice reads "Access is blocked because the payment grace period has ended. Update your payment method to restore access." When the summary carries `beta` and there is no subscription, it reads "Your free beta has ended. Please add a credit card profile to keep using Stralos." instead. **Manage subscription** stays visible whenever `canManagePaymentDetails` (or, without it, a subscription) allows it; a beta company without one subscribes with **Subscribe**.

**Free beta.** When `GET /api/billing/summary` carries `beta: { endsAt, daysLeft, seats }` and there is no subscription, the panel replaces the trial banner with a status notice: "Free beta: N days left", or "Your free beta ended on" and the end date once `daysLeft` is `0`, plus "Up to N users during the beta." The "X of N paid seats used" line is hidden, the trial offer is suppressed (`trialDays` is treated as `0`, so no trial seat cap applies), the seat helper reads "Billing starts as soon as you subscribe.", and the checkout button is labelled **Subscribe**. A `beta` that is `null` or absent, or any summary with a subscription, renders exactly as before. The seat count comes from api-billing (`BETA_SEATS` there), not from the frontend.

`GlobalSettingsPage` handles blocked account recovery by short-circuiting the full settings page when `?billing=required` is in the query string and the current user is an organisation admin. In that case, only `BillingPanel` is rendered, without loading the rest of the settings data. This is intentional: product reads (which back the full settings page) are blocked after the grace deadline, so the recovery path must not depend on them. The `?billing=required` param is set by the shell when it detects the blocked state and redirects the admin.

`canManagePaymentDetails` on `BillingSummary` controls whether the portal button and the inline `PaymentMethods` and `InvoiceHistory` sections render when there is no active subscription — for example, after a subscription has been cancelled but the Stripe customer record still exists and the admin may need to retrieve invoices or update stored payment details.

## Inline billing details

`BillingPanel` now renders `PaymentMethods` and `InvoiceHistory` (both from `BillingDetails.tsx`) inline below the subscription actions when `canManagePaymentDetails ?? Boolean(subscription)` is true. These components call the following billing API endpoints directly:

| Component | Endpoints used |
| - | - |
| `PaymentMethods` | `GET /api/billing/payment-methods`, `POST /api/billing/payment-methods/{id}/default`, `POST /api/billing/payment-methods/{id}/remove`, `POST /api/billing/payment-methods/setup-intent` (via `AddBillingCard`) |
| `InvoiceHistory` | `GET /api/billing/invoices?limit=20[&startingAfter=…]` |

`AddBillingCard` mounts a Stripe card element using `mountBillingCardElement` (`stripeCardElement.ts`). A `requestId` (UUID v4) is generated once per component mount and sent with the setup-intent request to make the creation idempotent.

The **Manage subscription** portal button (previously labelled **Payment details & invoices**) now serves as an escape hatch to Stripe's customer portal for subscription management tasks not surfaced inline.

## Settings section URLs

Each rail entry has a dedicated URL under `/settings/<section>`. Switching sections is a history entry: the browser's **Back** action steps back through previously visited sections, and a section can be bookmarked or opened in a new tab. `/settings` alone defaults to the first available section.

The slug mapping is defined in `src/settingsSections.ts`:

| Rail entry | URL |
| - | - |
| Company defaults | `/settings/organisation` |
| Users | `/settings/users` |
| Billing | `/settings/billing` |
| Client companies (contractor admins only) | `/settings/client-companies` |
| Contractors (a company's own admins only) | `/settings/contractors` |
| Project access (link, not a section) | `/projects?view=access` |
| Projects | `/settings/projects` |
| Resource categories | `/settings/item-categories` |
| Resource groups | `/settings/rate-groups` |
| Disposal substances | `/settings/disposal-substances` |
| Shared drives | `/settings/shared-drives` |
| Assistant activity | `/settings/assistant-activity` |
| Takeoff categories | `/settings/takeoff-categories` |
| Departures and clarifications | `/settings/departures-clarifications` |

**Project access** is a navigation link in the Organisation group, visible to admins only. It links to `/projects?view=access` rather than a settings section — it navigates to the projects list with the access view pre-selected. It is not a slug-mapped settings section and does not appear in `FIXED_SECTION_SLUGS`.

The `organisationRoutes` export exposes both `{ path: "/settings" }` and `{ path: "/settings/:section" }` pointing at the same `GlobalSettingsPage` component. The shell mounts this remote at `/settings` with `end: false`, so the nested segment reaches the second route without a shell change.

A section the user is not offered (for example, the Billing section for a member) and an unrecognised segment both redirect to `/settings` with a replace, so the URL never names a section other than the one displayed.

The Stripe customer portal's return URL is `settingsSectionPath("billing")` — `/settings/billing` — so returning from the portal lands on the Billing section rather than the default. The legacy `?billing=…` query string from Stripe Checkout still opens Billing for admins as well.

## Settings data loading

`GlobalSettingsPage` divides its reads into two groups (NET-02).

**Upfront reads** happen on every page load and provide the rail counts and the default section:

* Organisation settings (`GET /organisation-settings`)
* Rail counts: org users (see Users badge below), projects, categories
* Drive sources (`GET /drive-sources`) and drive identities (`GET /drive-identities`) — loaded independently so a documents-service outage degrades only the Shared drives section

Organisation settings, org users and drive sources go through the cross-remote shared-read store, so a read the onboarding widget made moments earlier is reused rather than repeated; reloads after the first load pass `force: true`. See [Cross-remote shared reads](#cross-remote-shared-reads).

**Section-scoped reads** load the first time a section that needs them is opened and are kept for the lifetime of the page visit. The `sectionDataFor` helper maps each section key to its `SectionData` dependencies:

| `SectionData` kind | Loaded by |
| - | - |
| `rateGroups` | Resource categories (`item_categories`), Resource groups (`rate_group_labels`) |
| `unitCatalogue` | Resource categories (`item_categories`) — the category editor's unit field |
| `projects` | Shared drives (`shared_drives`) — the mapping sheet's project picker |
| `takeoffTagPalette` | Takeoff tags list editor — the Colour field |

Rate groups are also re-read after each save once they have been requested, so two reads may be in flight at once while a save settles.

**Users badge.** The badge count (`GET /org/users`) is not fetched while the Users section is open (the panel reports its count via `onCountChange`) or once a count is already known. On leaving the Users section the panel's count persists so a revisit does not trigger a new read.

**Drive-sync switch.** `GET /drive-sources/onboarding-config` is only read when `GET /drive-identities` could not report the drive-sync switch (`identitiesSyncEnabled` remains `undefined` after the identities request settles). Both endpoints answer from the same `isDriveSyncEnabled()` in api-documents.

**Own-locations read.** `OrgUsersPanel` shares one own-locations read between the self-assign and default-office loaders. If that read is refused or fails, a subsequent save or refresh re-reads it.

**First-load blank.** Only the first `loadSettings` call blanks the page. A reload after a save keeps every panel mounted, so an open panel's own messages survive it and an organisation with no projects or categories does not see the loading state replace the page on refresh.

## Setting list tabs

`settingListConfigs` in `src/settingsShared.tsx` lists the org-scoped setting lists served by the generic list panel: Takeoff categories and Takeoff tags. Four platform-managed lists that previously appeared as read-only tabs — Project Statuses, Activity Statuses, Activity Basis, and Work Item Statuses — were removed from the catalogue . Their database rows are not deleted: `project_statuses` and `work_item_statuses` are still read at runtime by other services; the removal is four fewer GET requests and four fewer tabs.

Allowances and assumptions and Tags and clarifications were removed from `settingListConfigs` . They were merged into the single **Departures and clarifications** section, which is served from api-global's `/api/global/departures-clarifications` rather than `setting_list_items`. The old slugs `/settings/allowances-assumptions` and `/settings/tags-clarifications` redirect to `/settings/departures-clarifications` via `RETIRED_SECTION_SLUGS` in `src/settingsSections.ts`. The `departures_clarifications` key is a fixed section in `SettingsMenuKey`, not a list key, and is handled by `DeparturesClarificationsPanel`.

## What the remote owns

| Area | Source files |
| - | - |
| Page and editors | `GlobalSettingsPage`, `SettingsEditorModals` (item-category modal and setting-list-value sheet), `settingsShared` |
| People | `OrgUsersPanel`, `InviteOrgUsersDialog`, `orgUsersBulk`, `orgUsersApi` |
| Pricing | `PricingDefaultsPanel`, `RateGroupMarginsPanel`, `RateGroupLabelsPanel`, `rateGroupLabels`, `marginBasis`, `marginLadder` |
| Organisation settings | `OrganisationNamePanel`, `CompanyContactDetailsPanel`, `JobNumberDefaultsPanel` (quote and job numbering, see [Quote and job numbering cards](#quote-and-job-numbering-cards)), `BillingPanel` + `billingApi` + `BillingDetails` (`InvoiceHistory`, `PaymentMethods`, `AddBillingCard`) + `billingPresentation` + `stripeCardElement` + `useBillingScope` (renders subscription status, inline invoices, and inline payment methods via the shared `billing` domain in `@stralos-co-nz/ui`), `DisposalSubstancesPanel`, `DeparturesClarificationsPanel` + `departuresClarifications` |
| Shared drives | `DriveSyncSettings`, `DriveConnections` + `drivePlatform`, `DriveSourceRow`, `RegisterDriveSheet`, `SharePointGrantScript`, `sharePointGrant`, `driveSyncShared`, `documentsSyncApi`. The mapping sheet and row come from `@stralos-co-nz/drive-sync-ui` |
| Agent activity | `assistant/AssistantActivityPanel` + `assistantActivityApi` |
| Host lifecycle seam | Thin bridge reads (`host/session.ts`) remain; no security-surface bridge members are present in this remote |
| API client | `api.ts`, `jsonResponse`, `sameOriginBase`, `host/session` |
| Copies (not moves) | `KeyCombobox` — workspace bundles its own copy per §9.1. The `geocode` and `projectIdentity` copies had no callers here and were deleted. |

### Quote and job numbering cards

`OrganisationDefaultsForm` renders `JobNumberDefaultsPanel` twice: first with `kind="quote"` (the **Quote numbers** card), then with the default `kind="job"` (the **Job numbers** card). The quote card renders only when `GET /organisation-settings` returns both `nextQuoteNumber` and `quoteNumberPrefix`, so an api-global without them never receives quote keys on its `.strict()` PUT.

| | **Quote numbers** (`kind="quote"`) | **Job numbers** (`kind="job"`) |
| - | - | - |
| Fields written | `nextQuoteNumber`, `quoteNumberPrefix` | `nextProjectNumber`, `jobNumberPrefix` |
| Prefix shown when none is stored | `Q-` | `JOB-` |
| Empty prefix | Invalid; the card cannot save | Saved as `null`, so api-global uses `JOB-` |
| Next number helper | "Advances automatically for each new quote." | "Advances automatically when an estimate is marked Won." |

Each card registers its own save scope (`quote-numbers`, `job-numbers`) and sends only the fields that changed. Both are merged into the page's single `PUT /organisation-settings`, which supplies `defaultMarkupPercent` itself, so neither card can reset pricing. A field left alone is omitted, so saving cannot rewind a counter that allocation advanced while the page was open.

### Overlay and confirmation patterns

The settings remote uses sheets and inline editing for long-lived forms, and shared confirmation dialogs for destructive actions:

* `AddBillingCard` — moved from `Dialog` to `SideSheet` (size `lg`). A dirty-close guard fires from the first keystroke in the Stripe hosted field (`started`) or when the storage-consent checkbox is ticked.
* `SettingListItemEditorModal` — moved from `Dialog` to `SideSheet` (size `lg`).
* `DriveSyncSettings`: the Shared drives page shows state and lists only: the Connections cards, the registered drives, and each drive's mappings. Connecting a provider, adding a drive and adding a mapping each open a `SideSheet` at size `lg`. Errors are shown inside the sheet rather than as toasts, because a toast raised under an open sheet is not announced. A finished sheet closes, the list refreshes, and the page announces the outcome through a live region that is mounted empty. Disconnect (a centred `ConfirmDialog`) and Reconnect (one call) stay on the connection card.
* `RegisterDriveSheet`: the **Add shared drive** `SideSheet`. `confirmOnDirtyClose` asks before a typed URL is discarded, and `suppressDismiss` holds only while a call is in flight. A failed Verify is reported inside the sheet with Cancel still available. At the proof step the drive is already registered, so the cancel action reads **Finish later** and the drive row carries its own Verify.
* `OrgUsersPanel` — the `RenameModal` (a `Dialog`) is replaced by an inline row edit. Clicking **Edit** expands the name column to a text input; **Enter** or **Save name** commits, **Escape** or **Cancel** discards. The `commitRename` handler lives on the panel (not the removed component), so no `mounted` ref is needed.
* Mapping sheet and row: `DriveSourceRow` renders `DriveMappingSheet` and `DriveMappingRow` from `@stralos-co-nz/drive-sync-ui`, which `stralos-feature-project-settings` also uses, so both entry points share the same wording (`driveSyncCopy`). The sheet is presentational: `loadFolders`, `createMapping` and `describeError` are props wired by the row to `documentsSyncApi`. `driveSyncUiWiring.test.ts` checks at compile time that this remote's domain-typed values satisfy the package's structural props.
* Six hand-built confirm `Dialog`s — moved to the `ConfirmDialog` component from `@stralos-co-nz/ui` with `subject`, `message`, `consequence`, `reversibility`, `confirmLabel`, `confirmLoading`, and `destructive`. Retiring a disposal substance uses the neutral (non-destructive) variant because it is undoable via **Restore**. Role changes claim reversibility; removals do not.
* The unused `ClientEditorModal` and `ProjectEditorValues` were removed from `SettingsEditorModals` / `settingsShared`; the live editors are owned by `stralos-feature-workspace`.

### Settings layout and copy

* `CategoryEditorModal` — delegates to `CategoryEditorModal` from `@stralos-co-nz/ui-reference-data` (0.2.1), rendered as a `SideSheet` (size `lg`). The title is `"Edit item category"` on edit and `"Add item category"` on create. The unit grid uses two columns at the `md` breakpoint (`repeat(2, minmax(0, 1fr))`).
* `RateGroupsPanel` — the wire token (e.g. `quarry_materials`) is not shown beneath the display name. The column header is `&lt;noun&gt; used` (e.g. "Margin used" or "Markup used"). A blank field reads "Organisation default · X%", and the status chip reads "Default \<noun>". The `DataTable` and read-only footnote use `Box px="6px"` to align with the card content. The percentage input carries `aria-invalid` when its value fails validation and `aria-describedby` pointing at the relevant row error id while invalid, or at the negative-markup warning id when that warning is showing; `aria-describedby` is omitted entirely when neither element is present.
* `OrgUsersPanel` — seat summary and multi-org explanation text use `maxW="100ch"`. The `DataTable` uses the same `Box px="6px"` inset as the rate groups table.
* `SettingsPanel` — description text widened from `maxW="70ch"` to `maxW="100ch"`. The `SectionCard` receives the panel's `title` string as its `scrollLabel` so that assistive technologies announce the section name rather than the generic "List" label.
* `GlobalSettingsPage` — settings rail and item-category group headings no longer apply `textTransform="uppercase"` or `letterSpacing="wide"`. The item-category delete `ConfirmDialog` consequence copy is updated: the old "Deletion is blocked while active rate items use it. Current usage is unavailable." is replaced by "If rates still use this category it can't be deleted. Move them to another category first." (SH-12). The setting-list delete `ConfirmDialog` removes the "Current usage is unavailable." consequence sentence. Since P2-2, the setting-list delete `ConfirmDialog` uses title `"Delete list value?"`, names the list in its `subject`, and passes `reversibility: { reversible: false, note: "It's kept for history, and entries already copied into tenders keep their saved text." }` so the default "This cannot be undone." copy is suppressed by the component.
* `DisposalSubstancesPanel` — the retire `ConfirmDialog` removes the "Current usage is unavailable." sentence; the consequence field now renders only the error text when a retire request fails, or `undefined` otherwise.
* `DisposalSubstancesPanel`: add and edit open `DisposalSubstanceSheet`, a `SideSheet` at size `lg` titled "Add disposal substance" or "Edit disposal substance", with `confirmOnDirtyClose` and `suppressDismiss` while a save is in flight; the table has no entry row or inline editing. **In-situ density (t/m³)** is a read-only `output` in the sheet and a table column, derived in the browser as loose density × (1 + bulking ÷ 100) by `inSituDensity` (worked in whole thousandths so the 2 dp rounding is exact half-up) and `draftInSituDensity` (blank while either input is invalid). It is never typed or stored. Percentages show as stored without padding. Column order: Excavated material, In-situ density, Bulking, Loose density, Reinstatement, Compaction from loose; a narrow screen shows only name, in-situ density and actions. Active rows are listed before retired rows in one flat list; no section bands are used.
* `billingPresentation` — `billingMoney` no longer appends the currency symbol twice. The numeric figure is extracted from `formatMoney`'s output (stripping the locale marker) and the currency code is appended once: `"354.89 USD"` rather than `"US$354.89 USD"`.
* `BillingDetails` — `InvoiceHistory` suppresses the billing period range when the period start and end dates are the same calendar day.
* `InvoiceHistory` — uses `DataTable` with a `pagination` prop (`hasMore`, `loading`, `onLoadMore`) to load further invoice pages on demand. Scrolling to the bottom of the table triggers `onLoadMore`, which appends the next cursor-paged batch. Duplicate items across pages are de-duplicated by invoice `id` before the state is updated.
* `AssistantActivityPanel`: the activity list uses `InfiniteList` from `@stralos-co-nz/ui`. Items are appended as the user scrolls to the bottom; the component uses an `IntersectionObserver` (`data-scroll-sentinel`) to detect when the sentinel element enters the viewport and calls `onLoadMore`. Once every page has loaded, the list shows a count line: "Showing all n actions" with no date filter, or "Filtered to n actions" when a date bound is set. The count is withheld while loading and when the list is empty.
* The current `@stralos-co-nz/ui` pin is `0.73.1`.

Still in the shell on purpose:

* The `__STRALOS_AUTH__` bridge. This is a host lifecycle concern; this remote delegates refresh, logout, and session actions through the bridge rather than owning the session itself.
* `organisationNames.ts` — §9.2 finding 13: it read-modify-writes the session blob, so its destination is the host session service. This remote requests the refresh through the bridge (`refreshOrganisationLabels`) and never performs the write.
* `navigation.ts` and the route catalogue — the shell composes and validates navigation while descriptor fields supply each route's label, group, order, and visibility.

### Account, drive, billing, and error states

* **`OrgUsersPanel` — Multi-org edit explanation (TABLE7-12).** The per-row "Only they can edit" hint in the Actions cell was removed. The `MULTI_ORG_EXPLANATION` sentence now appears once above the table whenever any row has `editable: false`. The Edit button is absent rather than disabled or replaced with inline text for those rows.
* **`OrgUsersPanel` — Awaiting first sign-in (SH2-02).** `invitationState` now checks `userStatus === "invited"` rather than `lastLoginAt === null` to assign the `never_signed_in` chip. `last_login_at` is only stamped for invited users, so an identity created active (e.g. via signup) was never stamped and read "Awaiting first sign-in" permanently. The check also excludes the signed-in person's own row (`isSelf`), which is always signed in by definition.
* **`RegisterDriveSheet`: provider-off state (SH-33).** Until the onboarding config endpoint reports the chosen provider as configured, **Add drive** stays disabled, and once it reports the provider as unconfigured the URL field and **Add drive** are not rendered. The sheet shows a warning to contact Stralos support instead. A provider without an active identity shows "Connect … under Connections first" instead.
* **Connections strip (per-organisation drive credentials).** `DriveConnections` renders one card per provider (**Google Drive**, **Microsoft 365**) with a state of Not connected, Setting up, Connected, Needs attention, or Disconnected, and the one next action. Each connect flow opens a `SideSheet` rendered from server state, so closing it loses nothing but a typed domain, and **Continue setup** reopens it at the next step. A Google card that is `pending` polls until provisioning lands. The Microsoft sign-in leaves the page and returns as `?microsoft=<code>`, which the card turns into a notice and then strips from the URL. Members see each card read-only. `DriveSyncPanel` always shows the strip; the PostHog flag `organisation-drive-per-org` and `driveConnectionsFlag.ts` were retired in stralos-feature-organisation PR 122. The server's drive-sync switch and admin checks are the gate. `RegisterDriveSheet` always receives the providers with an active identity and the organisation's own Google address, so it never shows the organisation-wide admin-consent step for SharePoint. `drivePlatform.ts` reads the `platform` block returned by `GET /drive-identities` and derives a `ProviderAvailability` for each provider (`available`, `not_configured`, or `switched_off`). When a provider is not available, the connection card replaces the action button with an `UnavailableNotice` so admins see a clear explanation rather than a Connect that the server would refuse with a 409. `googleNotVisibleMessage` in the same module produces the error sentence for a 422 from register or verify (the drive isn't visible to the Stralos service account), pointing the admin to the help article at `GOOGLE_SHARING_HELP_URL`.
* **`SharePointGrantScript` and `sharePointGrant`.** For SharePoint the **Library URL** field comes first and the grant script is generated from it. `parseSharePointSiteUrl` (`src/sharePointGrant.ts`) reduces a pasted URL to a site and refuses non-https URLs, hosts other than `<name>.sharepoint.com`, personal OneDrive, the admin centre, embedded credentials, dot-segment path steps, tab or newline characters, malformed percent-encoding, and site names containing path or query characters. It mirrors the server's parser. `grantScript` builds a one-paste PowerShell block with every interpolated value escaped by `powerShellDoubleQuoted`: a Process-scope execution-policy bypass, `Import-Module -ErrorAction Stop`, a check that skips an existing grant (in `grantedToIdentitiesV2` or `grantedToIdentities`), and a clean disconnect. The Copy button stays disabled until the URL resolves. **Check access** calls `POST /api/documents/drive-sources/check-access`; each answer is stored with the URL it was asked for, so editing the URL retires it and a late answer for an old URL is ignored. The result shows in a status region below the script (mounted empty): ready with the site's name, a refusal message from `ACCESS_REFUSAL_TEXT`, or a failure message keyed on the HTTP status via `httpStatusOf`.
* **`BillingDetails` — Remove button on non-removable cards (SH-20).** When `canRemove` is `false` and `removeBlockedReason` is present, the **Remove** button is omitted entirely. The reason sentence is shown in its place. When `canRemove` is `false` but no reason is present, the button renders disabled as before.
* **`AssistantActivityPanel` — Full result-set virtual list.** The panel fetches the complete activity history to exhaustion (cursor-paging through all pages before rendering) and hands the full set to `InfiniteList` for virtual rendering. There is no "Load more" button. The date-range filter triggers a fresh full fetch; an invalid range (from after to) skips the fetch entirely and clears the list.
* **Error display.** `userFacingError` (`src/errorText.ts`) is the single extraction point for user-facing error sentences. It returns the server's own sentence plus `Reference: <id>` when present, and falls back to the caller-supplied string for transport errors, routing diagnostics, and `TypeError`. Screens never show the API URL or the HTTP status line. Validation issues from api-global's zod schemas are rendered as field-named sentences — for example, "Default markup is not valid." instead of raw zod text. `validationIssueSentence` maps wire field names (e.g. `defaultMarkupPercent`, `sortOrder`) to readable labels using the `ISSUE_FIELD_LABELS` table in `src/errorText.ts`.
* **Company contact details.** `CompanyContactDetailsPanel` (phone, email, GST number for tender outputs) renders inside `OrganisationDefaultsForm`, directly after the company name card, only when `GET /organisation-settings` returns the `phone` key. An api-global without these fields would refuse the keys on its `.strict()` PUT. Current api-global returns them, so the card shows and the [user guide](https://help.stralos.app/guides/admins/organisation-settings.md#company-contact-details) covers it. Each field is omit-to-preserve; a cleared field is sent as `null`. The frontend validates: phone has 6 to 20 digits (spaces and `+()-.`, `+` first only, 50 characters max), email is `name@domain.tld` (254 max), GST number is 8 or 9 digits with optional spaces or dashes (20 max). A tender prints the project office's value for each field when it is set, otherwise the company's; api-global resolves this in `GET /projects/:id/tender-details` and api-estimate prints it on the Tender download PDF and Excel files.
* **Organisation name source of record (D8).** `OrganisationNamePanel` reads the name from admin-api (`GET /api/global/org/organisations`) rather than the projection in `GET /organisation-settings`. After a successful rename, the panel holds the name the PUT response reports as the saved value, so a reload that returns the stale projection is never treated as a new change to sync. A `pending` state shows a message while the regional projection has not yet caught up.
* **Markup band validation (V1).** `marginBasis.ts` exports `DEFAULT_MIN_PERCENT = 0` (the organisation default may not be negative on either basis). Rate-group and item-category rungs use `RUNG_MARKUP_MIN_PERCENT = -100` and `RUNG_MARKUP_MAX_PERCENT = 999.999`, matching api-global's `MARGIN_PERCENT_BANDS.markup` exactly. `NegativeMarkupWarning` renders beside any rung field that holds a negative markup value; the value is still accepted but priced below cost.
* **Position in list validation (V2).** `sortOrderInputError` and `parseSortOrderInput` (`src/SettingsEditorModals.tsx`) validate the typed position before it reaches `values.sortOrder`. An invalid entry (non-integer, fractional, negative, or above `INT4_MAX`) shows an inline error and disables the Save button; no NaN or null ever reaches the wire.
* **Busy-GET retry.** `src/host/session.ts` retries a GET once when the response is 503 and `Retry-After` is present and at most 10 seconds. Writes are never retried.

## Contractor companies

The remote owns the contractor companies screens: the plan card in Billing, the **Client companies** and **Contractors** sections, the "Create a company" flow, the refresh page and the invitation accept page. All of them sit behind the PostHog flag `contractor-companies` (`contractorCompaniesFlag.ts`, fallback off). The flag only hides screens; the APIs behind them do their own checks.

`getContractorClaims()` (`src/contractorSession.ts`) decodes `plan`, `via` and the billing claim's `coveredBy` from the access token for display only. `isContractorAdmin` (admin, contractor plan, no `via`) gates **Client companies**; `isCompanyAdmin` (admin, no `via`) gates **Contractors**.

| Path | Screen |
| - | - |
| `/settings/client-companies/new` | "Create a company", steps 1 and 2 (`CreateClientCompanyPage`). |
| `/settings/client-companies/bring` | Step 3, "What to bring" (`BringFromHomePage`), in the new company's session. |
| `/settings/client-companies/:linkId/refresh` | Refresh from home (`RefreshFromHomePage`), in the client company's session. |
| `/settings/contractor-invite/:token` | The page the invitation email opens (`ContractorInvitePage`). |

### Billing plan card

`BillingPanel` renders `ContractorPlanCard` when the flag is on. The card shows one of four states:

* **Covered company.** When the billing summary has `coveredByOrgId` or the token's billing claim has `coveredBy`, the card says the company is free while only the contractor's people work in it, and that setting up billing is what lets outsiders be invited.
* **Contractor person.** When the token has `via`, the card renders nothing. A client company's plan is its own admins' decision; the contractor person manages their plan in their home company's Billing.
* **Standard company with a subscription.** Offers **Upgrade to contractor**. The confirmation warns that a trial ends now and is charged at the contractor price, or that an active subscription is charged the difference today.
* **Contractor company.** Offers **Change to a standard company**. The confirmation lists every client company whose access the contractor's people will lose. On confirm, if the plan change reports that links must end, the card calls `POST /org/client-companies/end-all`.

`contractorPriceText()` gives the price line: "NZ\$499 per person per month, incl GST" when the billing country is `NZ`, otherwise "US\$359 per person per month". A plan change only applies once Stripe confirms it through api-billing's webhook, so a successful request shows a "requested" message and the panel re-reads.

### Create a company

Steps 1 and 2 collect the company's details and the contractor's people, then create the company. The page waits while the company is set up, then switches into it and opens step 3. Step 3 runs in the new company's session because a copy job takes its target company from the token.

`BringFromHomePage` starts with every group ticked. Its first `previewCopy` runs at once and later ones are debounced by 400 ms. Beside each home location, a picker chooses whose rates it takes: itself, another home location, or **None (no rates)**. The **Rates coming** column shows each location's count from the preview.

`CopyJobProgress` polls the job and shows its summary. Because the summary counts rows, and a rate's prices and tiered prices are rows of their own, `SUMMARY_LABELS` labels the rates line **Rates, prices and tiered prices**, so it is not confused with the picker's rate count.

### Refresh from home

`RefreshFromHomePage` calls `previewRefresh` for the diff and `startRefreshJob` to apply the ticked rows. Each group offers **Tick all changed** and **Tick all new**. When the preview returns `locationRates`, the page shows `locationRates.message` as a muted note under the rates group heading, explaining whether location prices follow the original copy's choices or are not brought across.

## Cross-remote shared reads

For an eligible admin, the onboarding widget reads `GET /organisation-settings` on every hard page load. While its checklist is open it also reads `GET /org/users` and `GET /drive-sources` (see [Onboarding frontend](https://help.stralos.app/internal/onboarding-frontend.md#cross-remote-shared-reads)). Each federated remote bundles its own HTTP client, so identical GETs from different remotes during one page load cannot coalesce at the transport layer.

`src/sharedReads.ts` addresses this with a small store on `globalThis` (key `__stralosSharedReadsV1`, contract v1). A response fetched by one remote is reused by another for up to 30 seconds. This remote shares the following three reads through the store:

| Read | Scoped by |
| - | - |
| `GET /organisation-settings` | `api.ts` |
| `GET /org/users` | `orgUsersApi.ts` |
| `GET /drive-sources` | `documentsSyncApi.ts` |

Each entry is keyed by the exact URL path (as sent, including query string) and by the session scope `${organisationId}:${userId}` from `host/session.ts` (`sharedReadScope()`). A null scope bypasses the store entirely.

**Rules for callers and maintainers:**

* Every write that can change one of the three shared resources must call `invalidateSharedReads(scope, prefix)` in a `finally` block. This applies to the settings PUT, `renameOrganisation`, every write under `/org/users`, and every write in `documentsSyncApi.ts`. Any new write to those resources must do the same, or the store will answer stale data for up to 30 seconds.
* `GlobalSettingsPage`'s reloads after the initial load pass `force: true` to always hit the network.
* Rejected reads are not stored. A caller that joins a read that then rejects re-issues its own `load`, so each participant sees its own error class.
* Values are structured-cloned per caller, so mutating a result is safe.

`src/sharedReads.ts` is copied verbatim into every participating remote (onboarding, organisation, workspace, estimate, project-settings and project-schedule). Never edit this file in isolation. To change the contract, bump the global key to `__stralosSharedReadsV2` in every copy so mixed deploys bypass each other's store instead of reading incompatible entries.

## Resource category edit flow

The category editor is opened from two affordances in `GlobalSettingsPage`: clicking the category name (as a button) and clicking the **Edit** button on the row. Both call `setCategoryEditor(toCategoryEditorValues(node.category))` and open the same `CategoryEditorModal`. The shared component owns the dirty-close guard; both entry points behave identically on save and cancel.

## Session boundary

`src/host/session.ts` is the authenticated settings tree's contact with the regional session. It is not a copy of the shell's `auth.ts` (§9.3 finding 18: never copy the store into the organisation remote — that would duplicate the refresh lock). It reads the published storage keys (`stralos.auth.accessToken`, `stralos.auth.user`) and delegates actions it cannot own to `window.__STRALOS_AUTH__`. It never writes, clears, or refreshes the regional session itself.

### Second-401 teardown

When a request receives a second `401`, this remote calls `window.__STRALOS_AUTH__.logout` when the bridge is available, then redirects to login. The shell publishes `logout` and `clearSession` as aliases of the same synchronous local teardown. This clears the refused credentials without giving the remote a second session store.

### Organisation label staleness after a rename

After an organisation is renamed in `OrganisationNamePanel`, the remote calls `refreshOrganisationLabels`, which delegates to the shell's `refreshOrganisationNames()` bridge method. The shell refreshes its organisation-name cache only if the session identity is unchanged across the asynchronous load. A failed refresh does not undo the saved name; the remote explains that other surfaces update when the session next refreshes.

## API client

`src/api.ts` is this remote's own organisation slice. Transport is copied from the shell; the catalogues, defaults, disposal register, and rate-group margins are moved. `listClients`, `listProjects`, and `upsertProject` are copies rather than moves because §9.2 row 11 forbids the organisation remote from depending on a loaded workspace remote. `upsertProject` writes to `api-global` only and does not call the estimate service; the estimate is created lazily when the estimate or documents page first opens.

## Remote provider

`OrganisationRemoteProvider` exports `RemoteProvider` — ui's `RemoteProvider` bound to the `organisation` manifest key. The `./routes` module exports this provider beside the authenticated settings routes. Token variables are rooted at `[data-stralos-remote='organisation']`; the remote's artifact carries no font file (ui 0.23.13 split fonts into a `./fonts` subpath, and a source test refuses any font import here).

## Manifest entry

The manifest entry in `stralos-core`'s schema-v2 seed is:

```json theme={null}
{
  "organisation": {
    "name": "organisation",
    "kind": "route",
    "remoteEntryUrl": "/remotes/organisation/<sha>/remoteEntry.js",
    "paths": [
      {
        "path": "/settings",
        "navLabel": "Settings",
        "end": false
      }
    ],
    "fallbackCopy": "Organisation settings are unavailable right now."
  }
}
```

`/settings` is a prefix mount (`end: false`) for the authenticated organisation settings descendants. The shell no longer loads a `./securityRoutes` module from this remote; the personal-security surface moved to the login host.

## Routes contract

The repository root carries a committed `routes-contract.json`. It is the federation routes contract for this remote under the shell-reduction federation-routes-contract PRD (Stage 2).

```json theme={null}
{
  "remoteKey": "organisation",
  "contract": 2,
  "exposes": ["./routes"],
  "routes": [
    { "path": "/settings" },
    { "path": "/settings/:section" }
  ]
}
```

### How it is generated

`scripts/emit-routes-contract.mjs` derives the contract from the real source at build time. It reads the federation `name` and `exposes` keys from `vite.config.ts` and the route paths from `src/routes.ts`, both with comments stripped first — the prose in those files names retired paths such as `/settings/security` and the removed `./securityRoutes` expose, so stripping prevents comment text from being parsed as live configuration. The derived document is validated against `scripts/federation-routes-contract.schema.json` on the way out.

`scripts/build-remote.mjs` runs `emit-routes-contract.mjs` as part of every build, writing the output to `dist-remote/routes-contract.json` alongside `built-against.json`. The beside-the-bytes workflow gate re-checks the file is present and schema-valid before upload. The dev publish step syncs it to `/remotes/organisation/<version>/routes-contract.json` for the shell to fetch (Stage 3).

### How the contract stays current

`src/routes.contract.test.tsx` asserts that the real imported `./routes` module and the committed `routes-contract.json` agree: same exposes set, same route path list in order, and the JSON is schema-valid. A byte-reproduce test proves `derive-routes-contract.mjs` regenerates the committed contract from the real code, so adding or removing a route reddens the test until `routes-contract.json` is regenerated. To update it, run `pnpm build:remote` locally and commit the resulting file.

### What the contract does not carry

The contract carries only the code facts — `remoteKey`, `contract`, `exposes`, and route paths. Presentation metadata (`navLabel`, `order`, `group`, and so on) lives in the `stralos-core` seed and reaches the shell through the served manifest. This remote does not carry the seed, so the contract emits `path` alone for each route.

## Build and deployment

This remote uses the same thin-host contract v2 shape as other feature remotes. `@stralos-co-nz/ui` is pinned at `0.73.1`, `@stralos-co-nz/domain` at `3.4.0`, `@stralos-co-nz/maps-ui` at `0.8.0`, and `@stralos-co-nz/drive-sync-ui` at `0.2.0`, all as exact pins in `package.json`. `@stralos-co-nz/domain` is not declared as a singleton; the remote bundles it from its own `pnpm-lock.yaml`.

The size regression gate reads `.ci/remote-size-baseline.json`. A build more than 10% over the baseline fails; the accepted baseline must appear in both the file and the PR body before a push, because `pull_request:` triggers on `synchronize` only and a body edit after push does not re-trigger the check.

### Known-incompatible package pair check

`scripts/check-package-compat.mjs` runs on the runner's preinstalled Node before `pnpm install`. It reads the resolved package versions from the committed lockfile and refuses any combination listed in its `INCOMPATIBLE` table.

The current rule: `@stralos-co-nz/ui >=0.57.0` must not be resolved alongside `@stralos-co-nz/maps-ui <0.3.0`. `ui 0.57.0` removed `SupplierEditorModal`, which `maps-ui` 0.1.x and 0.2.0 import directly. pnpm accepts this pair without a warning because `maps-ui` 0.1.x and 0.2.0 declare a peer range of `@stralos-co-nz/ui >=0.51.1 <1.0.0` that `ui 0.57.0` satisfies. The break surfaces only at build time, so a lockfile check is the only point where CI can catch it before the install.

When `ui` is bumped to a version that removes something a published `maps-ui` version imports, add the pair to `INCOMPATIBLE` in this file and in every other repo that carries the same script (`estimate`, `workspace`, `project-settings`, and `organisation`).

The CI and publish chain follows the same produce-gate-activate sequence as other remotes. See [Micro-frontends — Federation build and preview targets](https://help.stralos.app/internal/micro-frontends.md#federation-build-and-preview-targets) for the full pipeline.

### Release (`release.yml`)

`release.yml` copies the pre-built by-sha bundle to the semver prefix (`remotes/organisation/<X.Y.Z>/bundle.tar`) and writes a `release.json` record. It never rebuilds. The workflow triggers on a published stable `vX.Y.Z` GitHub release and is uniform across all thirteen feature remotes.

The release step calls `scripts/archive-remote.mjs release` with six options:

```
archive-remote.mjs release --bucket BUCKET --remote NAME --sha 40HEX --tag vX.Y.Z [--vite-config FILE] [--federation-contract FILE]
```

`--vite-config` (default `vite.config.ts`) and `--federation-contract` (default `.ci/shell-federation-contract.json`) are optional. Omitting either uses the default path at the checked-out tag. A release attempted before the by-sha bundle exists fails immediately; produce the commit via `deploy-dev.yml` first.


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