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 carriesbetaand 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 whenevercanManagePaymentDetails(or, without it, a subscription) allows it; a beta company without one subscribes with Subscribe.
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
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 fromDialogtoSideSheet(sizelg). 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 fromDialogtoSideSheet(sizelg).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 aSideSheetat sizelg. 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 centredConfirmDialog) and Reconnect (one call) stay on the connection card.RegisterDriveSheet: the Add shared driveSideSheet.confirmOnDirtyCloseasks before a typed URL is discarded, andsuppressDismissholds 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— theRenameModal(aDialog) 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. ThecommitRenamehandler lives on the panel (not the removed component), so nomountedref is needed.- Mapping sheet and row:
DriveSourceRowrendersDriveMappingSheetandDriveMappingRowfrom@stralos-co-nz/drive-sync-ui, whichstralos-feature-project-settingsalso uses, so both entry points share the same wording (driveSyncCopy). The sheet is presentational:loadFolders,createMappinganddescribeErrorare props wired by the row todocumentsSyncApi.driveSyncUiWiring.test.tschecks at compile time that this remote’s domain-typed values satisfy the package’s structural props. - Six hand-built confirm
Dialogs — moved to theConfirmDialogcomponent from@stralos-co-nz/uiwithsubject,message,consequence,reversibility,confirmLabel,confirmLoading, anddestructive. 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
ClientEditorModalandProjectEditorValueswere removed fromSettingsEditorModals/settingsShared; the live editors are owned bystralos-feature-workspace.
Settings layout and copy
CategoryEditorModal— delegates toCategoryEditorModalfrom@stralos-co-nz/ui-reference-data(0.2.1), rendered as aSideSheet(sizelg). The title is"Edit item category"on edit and"Add item category"on create. The unit grid uses two columns at themdbreakpoint (repeat(2, minmax(0, 1fr))).RateGroupsPanel— the wire token (e.g.quarry_materials) is not shown beneath the display name. The column header is<noun> used(e.g. “Margin used” or “Markup used”). A blank field reads “Organisation default · X%”, and the status chip reads “Default <noun>”. TheDataTableand read-only footnote useBox px="6px"to align with the card content. The percentage input carriesaria-invalidwhen its value fails validation andaria-describedbypointing at the relevant row error id while invalid, or at the negative-markup warning id when that warning is showing;aria-describedbyis omitted entirely when neither element is present.OrgUsersPanel— seat summary and multi-org explanation text usemaxW="100ch". TheDataTableuses the sameBox px="6px"inset as the rate groups table.SettingsPanel— description text widened frommaxW="70ch"tomaxW="100ch". TheSectionCardreceives the panel’stitlestring as itsscrollLabelso that assistive technologies announce the section name rather than the generic “List” label.GlobalSettingsPage— settings rail and item-category group headings no longer applytextTransform="uppercase"orletterSpacing="wide". The item-category deleteConfirmDialogconsequence 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 deleteConfirmDialogremoves the “Current usage is unavailable.” consequence sentence. Since P2-2, the setting-list deleteConfirmDialoguses title"Delete list value?", names the list in itssubject, and passesreversibility: { 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 retireConfirmDialogremoves the “Current usage is unavailable.” sentence; the consequence field now renders only the error text when a retire request fails, orundefinedotherwise.DisposalSubstancesPanel: add and edit openDisposalSubstanceSheet, aSideSheetat sizelgtitled “Add disposal substance” or “Edit disposal substance”, withconfirmOnDirtyCloseandsuppressDismisswhile a save is in flight; the table has no entry row or inline editing. In-situ density (t/m³) is a read-onlyoutputin the sheet and a table column, derived in the browser as loose density × (1 + bulking ÷ 100) byinSituDensity(worked in whole thousandths so the 2 dp rounding is exact half-up) anddraftInSituDensity(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—billingMoneyno longer appends the currency symbol twice. The numeric figure is extracted fromformatMoney’s output (stripping the locale marker) and the currency code is appended once:"354.89 USD"rather than"US$354.89 USD".BillingDetails—InvoiceHistorysuppresses the billing period range when the period start and end dates are the same calendar day.InvoiceHistory— usesDataTablewith apaginationprop (hasMore,loading,onLoadMore) to load further invoice pages on demand. Scrolling to the bottom of the table triggersonLoadMore, which appends the next cursor-paged batch. Duplicate items across pages are de-duplicated by invoiceidbefore the state is updated.AssistantActivityPanel: the activity list usesInfiniteListfrom@stralos-co-nz/ui. Items are appended as the user scrolls to the bottom; the component uses anIntersectionObserver(data-scroll-sentinel) to detect when the sentinel element enters the viewport and callsonLoadMore. 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/uipin is0.73.1.
- 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.tsand 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. TheMULTI_ORG_EXPLANATIONsentence now appears once above the table whenever any row haseditable: false. The Edit button is absent rather than disabled or replaced with inline text for those rows.OrgUsersPanel— Awaiting first sign-in (SH2-02).invitationStatenow checksuserStatus === "invited"rather thanlastLoginAt === nullto assign thenever_signed_inchip.last_login_atis 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).
DriveConnectionsrenders 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 aSideSheetrendered 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 ispendingpolls 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.DriveSyncPanelalways shows the strip; the PostHog flagorganisation-drive-per-organddriveConnectionsFlag.tswere retired in stralos-feature-organisation PR 122. The server’s drive-sync switch and admin checks are the gate.RegisterDriveSheetalways 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.tsreads theplatformblock returned byGET /drive-identitiesand derives aProviderAvailabilityfor each provider (available,not_configured, orswitched_off). When a provider is not available, the connection card replaces the action button with anUnavailableNoticeso admins see a clear explanation rather than a Connect that the server would refuse with a 409.googleNotVisibleMessagein 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 atGOOGLE_SHARING_HELP_URL. SharePointGrantScriptandsharePointGrant. 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.grantScriptbuilds a one-paste PowerShell block with every interpolated value escaped bypowerShellDoubleQuoted: a Process-scope execution-policy bypass,Import-Module -ErrorAction Stop, a check that skips an existing grant (ingrantedToIdentitiesV2orgrantedToIdentities), and a clean disconnect. The Copy button stays disabled until the URL resolves. Check access callsPOST /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 fromACCESS_REFUSAL_TEXT, or a failure message keyed on the HTTP status viahttpStatusOf.BillingDetails— Remove button on non-removable cards (SH-20). WhencanRemoveisfalseandremoveBlockedReasonis present, the Remove button is omitted entirely. The reason sentence is shown in its place. WhencanRemoveisfalsebut 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 toInfiniteListfor 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 plusReference: <id>when present, and falls back to the caller-supplied string for transport errors, routing diagnostics, andTypeError. 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.validationIssueSentencemaps wire field names (e.g.defaultMarkupPercent,sortOrder) to readable labels using theISSUE_FIELD_LABELStable insrc/errorText.ts. - Company contact details.
CompanyContactDetailsPanel(phone, email, GST number for tender outputs) renders insideOrganisationDefaultsForm, directly after the company name card, only whenGET /organisation-settingsreturns thephonekey. 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 asnull. The frontend validates: phone has 6 to 20 digits (spaces and+()-.,+first only, 50 characters max), email isname@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 inGET /projects/:id/tender-detailsand api-estimate prints it on the Tender download PDF and Excel files. - Organisation name source of record (D8).
OrganisationNamePanelreads the name from admin-api (GET /api/global/org/organisations) rather than the projection inGET /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. Apendingstate shows a message while the regional projection has not yet caught up. - Markup band validation (V1).
marginBasis.tsexportsDEFAULT_MIN_PERCENT = 0(the organisation default may not be negative on either basis). Rate-group and item-category rungs useRUNG_MARKUP_MIN_PERCENT = -100andRUNG_MARKUP_MAX_PERCENT = 999.999, matching api-global’sMARGIN_PERCENT_BANDS.markupexactly.NegativeMarkupWarningrenders beside any rung field that holds a negative markup value; the value is still accepted but priced below cost. - Position in list validation (V2).
sortOrderInputErrorandparseSortOrderInput(src/SettingsEditorModals.tsx) validate the typed position before it reachesvalues.sortOrder. An invalid entry (non-integer, fractional, negative, or aboveINT4_MAX) shows an inline error and disables the Save button; no NaN or null ever reaches the wire. - Busy-GET retry.
src/host/session.tsretries a GET once when the response is 503 andRetry-Afteris 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 flagcontractor-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
coveredByOrgIdor the token’s billing claim hascoveredBy, 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 readsGET /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 afinallyblock. This applies to the settings PUT,renameOrganisation, every write under/org/users, and every write indocumentsSyncApi.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 passforce: trueto 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 inGlobalSettingsPage: 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 second401, 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 inOrganisationNamePanel, 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 instralos-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 committedroutes-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.
