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

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

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

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

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