Current state
- Every shell build is a federated host.
pnpm buildandpnpm build:remotesproduce the same output; neither compiles feature source. stralos-shellcontains a built-in fallback remote manifest for every registered feature. When the runtime manifest is unavailable, the shell renders nav and feature-unavailable fallback cards.- Shared packages are consumed through the
@stralos-co-nz/*workspace/package scope. - The production build workspace under
.ci/workspacecontains only the shell and installs its frozen package dependencies.
Remote descriptor shapes
A route remote descriptor in the manifest takes one of two forms. Single-path descriptor:paths, the legacy basePath/navLabel pair, or both during a version-1 to version-2 manifest migration. When both shapes are present, basePath must be paths[0] — the first entry in the list — and navLabel must be present. The shell validates the pair, normalises the descriptor to paths, and drops the legacy fields. A navLabel without basePath remains invalid.
When both shapes are present, the shell applies two guards with deliberately different outcomes:
basePath !== paths[0].path→ the entry is skipped and a warning is logged. This is a structural disagreement: the wrong path would become the primary mount, affecting routing and which feature a failure card names.navLabel !== paths[0].navLabel→ a warning is logged but the entry is kept and composed normally. This is a presentational disagreement: this shell reader takes the label frompaths[0]; a version-1 reader of the same bytes takes it fromnavLabel. A half-done rename topaths[0].navLabelmislabels the rail item temporarily but does not remove the feature from the site.
paths requires:
path— an absolute, concrete path prefix. Must start with/, must not end with/, and must contain only alphanumeric segments or named parameters (:paramName). Root catch-alls, splats, query strings, fragments, and relative paths are rejected.navLabel— the nav label and page title to show for this prefix.end— whentrue, the nav item is only highlighted on an exact match; whenfalse, it highlights on any descendant path.
nav—"primary"(default) or"hidden". A hidden path is a real route with no nav item.auth— whether the mount sits behind the authentication guard. Absent meanstrue. A path withauth: falsemust also appear in the shell’sUNAUTHENTICATED_PATH_PREFIXESallow-list; values outside that list are overridden totrueat parse time.chrome— whether the mount renders insideAppFrame. Absent meanstrue. Independent ofauth: a path can be chromeless and authenticated, or chromeless and unauthenticated.aliases— retired paths that redirect permanently to this entry while its remote is present.routeId— stable catalogue id for this mount, in the/^[a-z][a-z0-9-]*$/namespace.group— places the entry under a named rail section, such as"Resources"or"Delivery".order— finite-number rail position. Lower values sort first; entries without one follow ordered entries in manifest order.
auth and chrome default to true. A shell that predates these fields ignores them and keeps mounts authenticated and inside AppFrame; no manifest version bump is required to preserve that safe fallback.
The chrome: false field is the sole source for chromeless mounts. The shell reads all chrome: false paths from the manifest and mounts them outside AppFrame; no hard-coded route constants are used. The Contractor Portal (/contractor-portal/:portalToken) and Work Items Export (/projects/:projectId/estimate/builder/work-items/export) are declared with chrome: false in the estimate descriptor. Their failure cards use the path’s own navLabel — “Contractor Portal” and “Work Items Export” respectively. A cached manifest that predates these fields carries no chrome: false entries, so degraded composition serves the contractor portal as a redirect to /login; the shell’s built-in fallback manifest declares both paths explicitly to prevent this.
If any entry in paths fails validation, the entire descriptor is discarded and a warning is logged. The shell retains all other remotes in the manifest.
The kind field defaults to "route" when absent, so existing manifests without it continue to parse correctly.
Remotes manifest resolution
The shell resolves the runtime manifest in this order:window.__STRALOS_REMOTES__— inline manifest object injected into the page, no fetch.window.__STRALOS_REMOTES_MANIFEST_URL__— release-pinned URL written intoindex.html.- The URL in
localStorage["stralos.remotesManifestUrl"]— local development override. VITE_STRALOS_REMOTES_MANIFEST_URL— build-time override./remotes-manifest.json— same-origin development fallback.
/remotes-manifest.json. stralos-core’s environment gate validates a selected remote set, then updates this manifest through its single-writer assembler. The shell’s separate activation workflow checks the candidate host against that live manifest before it replaces the root index.html.
If a fetched manifest is unavailable or invalid, the shell tries the last valid cached manifest. Without a valid cache, the shell retains shell navigation but shows feature-unavailable fallbacks for every remote route.
Publishing a remote creates immutable bytes without rebuilding or activating the shell. Activating or rolling back a release set is a separate core gate followed by shell activation. See Release model for the produce, compatibility-gate, and activation sequence.
Built-in fallback manifests
The shell ships two built-in manifests used when no runtime manifest is available.DEFAULT_MANIFEST (version 1) covers the nine original route owners: dashboard, rates, activities, scheduler, workforce, fleet, estimate, projectSchedule, and projectSettings. It does not include workspace or organisation. A degraded or unconfigured shell that falls back to this manifest shows nav entries for those nine features only; Projects, Companies, Locations, and Settings are absent.
FALLBACK_MANIFEST (version 2) extends DEFAULT_MANIFEST with two additional route owners: workspace (supplying /projects, /companies, /locations, and the legacy company-register redirect paths) and organisation (supplying /settings at order: 110). This is the manifest buildDefaultNavigation() and buildDefaultTitles() use in tests and local development, producing the full four-item top-level rail: Dashboard · Projects · Resources · Settings.
The /settings path in FALLBACK_MANIFEST carries no auth: false override and no pre-auth exception. It sits fully behind the shell’s authentication guard — an unauthenticated visitor at any /settings URL is redirected to /login, and no organisation module is loaded until a session exists.
Manifest schema versions
The shell recognises manifest versions1 and 2. Both versions share the same remotes block structure. Version 2 adds sibling keys (planned, manifestVersion, _comment) that the parser ignores by design.
An unrecognised version no longer causes the parser to refuse the entire document. Instead, the shell logs a warning naming the unknown version, then validates every entry individually using the same per-entry rules it applies to known versions. Entries that fail validation are skipped; entries that pass are used. The manifest is rejected only if it has no numeric version field, no remotes object, or no parseable entries at all.
The version field must still be an integer. A non-integer version means the document is not a remotes manifest in any revision, and nothing is salvageable.
The per-entry federation contract check (built-against.json, contract v1 vs v2) is separate and unchanged. That check guards a different failure: a contract-2 remote under a contract-1 host white-screens, and per-entry skipping cannot help.
The assembler seed in stralos-core selects the emitted manifest version per environment. Dev and test emit v2 so they can express multi-path and widget descriptors. Production keeps the default v1 until its served shell accepts v2; the assembler refuses to fold a v2-only descriptor into a v1 manifest.
Widget remotes
In addition to route remotes, the manifest supports widget remotes — entries that the shell mounts into a named slot rather than at a URL path. Widget remotes carry nobasePath or navLabel.
appframe-aside for the assistant and floating-end for onboarding, personal settings and guided tours. Unknown slots are skipped and reported to telemetry. Reports are deduplicated by remote key, name, and slot for the lifetime of the page.
Route remotes omit kind (or carry kind: "route"). An entry with an unknown kind value is
skipped rather than mounted at a path on the strength of a guess.
Lazy boundary for the assistant widget
The shell loadsassistant/availability eagerly and assistant/widget (the panel) only on first
open. Both entries share one store. Loading the panel entry eagerly would
pull the panel on every page load and silently remove a lazy boundary the shell has always had.
See AI copilot shell responsibilities for the
availability bridge and toggle persistence detail.
Registered feature topology
The shell registers these features in its runtime manifest. Each feature’s./routes module is loaded via Module Federation.
Route remotes
The
organisation remote owns the organisation settings panels, including Users, Billing, and Assistant activity. The shell loads organisation/routes inside the authenticated route tree, wrapped in the remote’s exported RemoteProvider. The personal-security surface (/settings/security and /settings/security/callback) moved to stralos-login-web on the login host; the region no longer serves either path, and the user menu links to the login host instead.
The workspace and organisation remotes are in the planned block of the assembler seed (stralos-core) rather than the remotes block. The live manifest’s remotes object does not include them until each is brought live by moving its entry from planned to remotes, adding it to reconcile-dev.mjs REMOTES and deploy-dev-suite.sh WAVE2, and verifying the target environment’s manifestVersion can express it. Until then, the shell’s built-in FALLBACK_MANIFEST (see below) provides their descriptors for local development.
Persistent workspace chrome
The workspace remote also exports a version-1persistentChrome capability from workspace/routes. The shell exposes it as RemoteRuntime.PersistentChrome and mounts it once inside the authenticated layout but outside the host’s <Routes>. Its provider persists across route changes and owns five portal surfaces: the header project selector, workspace notices, the active workspace route outlet, the project rail’s top block (project and client name), and the first row of the Estimate rail section, beneath its label (the estimate switcher). See Frontend architecture — Project rail slots.
The shell supplies a non-credential session identity and host-owned DOM targets. It does not load the capability without an authenticated session and committed header target. Workspace outlet routes register their target and base path; other feature routes remain outside the workspace provider.
The provider receives a HostChromeInput with a per-location requestKey. It may publish only a remembered project path and a missing-project boolean. The shell validates this OwnerChromeSnapshot, scopes it to the current runtime, session, complete location, and request lease, and rejects late or malformed publications. The workspace remote owns the scoped local-storage record; the shell’s old stralos.nav.lastProjectPath key is retired.
The initial module load is shared. While it loads, the header slot shows a placeholder Projects button with the real trigger’s size, padding and active colours, so the header does not shift or show loading text. The placeholder is hidden from assistive technology; screen readers hear a visually hidden “Loading projects” status instead. Capability-level failures expose Retry controls in the header and, when a valid active outlet is registered, in the route outlet. A missing descriptor cannot validate an outlet, so only its header fallback renders. Once the provider has loaded, each leaf has its own render boundary: the header fallback says Project selector unavailable, the notice fallback says Workspace status unavailable, and the route fallback uses the manifest copy.
Widget remotes
Widget remotes mount into named slots rather than at URL paths.
The shell loads
assistant/availability eagerly and mounts assistant/widget on first open. The floating-end slot mounts eagerly in the authenticated chromed layout; it is absent from chromeless and billing-recovery pages. Each widget has its own error boundary and owns its positioning.
The user menu dispatches stralos:onboarding-open for Setup guide and stralos:user-settings-open for Your settings. These event names must match their respective widget listeners. The user-settings widget renders no DOM and makes no requests until the first open, when it dynamically loads its settings sheet.
Shell shared singleton scope
The shell is the Module Federation host and owns the shared singleton scope. Under federation contract 2 (stralos-shell/federation/v2), the shared scope contains only the React family — @stralos-co-nz/ui and @stralos-co-nz/domain are bundled by the shell and each remote separately, not shared. scripts/verify-federation-contract.mjs refuses a build that attempts to share either.
Each current remote declares the four React-family runtime entries (react, react-dom, react-router-dom, and react/jsx-runtime) with strict compatible ranges; the federation runtime refuses a declared mismatch.
The activities remote runs under contract v2 and no longer participates in this sharing model for @stralos-co-nz/ui or @stralos-co-nz/domain. Both packages are bundled into the remote’s own artifact. The activities remote still shares react, react-dom, react-router-dom, and react/jsx-runtime with the host, each with import: false and a caret range the host’s exact version must satisfy. Under v2 the remote also exports RemoteProvider from its ./routes module; the shell mounts it as a pathless layout route around all of the remote’s routes so the activities feature has its own Chakra provider scope from the bundled @stralos-co-nz/ui.
The scheduler remote operates under contract v2 and declares only react, react-dom, react-router-dom, and react/jsx-runtime as shared singletons (caret ranges). It bundles @stralos-co-nz/ui and @stralos-co-nz/domain at exact pins and does not declare them in the federation share map.
Current pinned versions (stralos-shell vite.config.ts, contract v2):
react, react-dom, and react-router-dom use exact pins. react-dom is shared at React’s exact pin because a remote’s bundled Chakra needs the one react-dom that belongs to the one react.
@stralos-co-nz/ui and @stralos-co-nz/domain are no longer shared singletons under contract 2. Each remote bundles its own copy from exact dependency pins. A package release reaches a remote after that remote bumps its pin and regenerates its one-member lockfile; no shell rebuild is required. The coupling is replaced by the declaration contract in federation-contract.json.
Patch skew between host and remotes. Because each party bundles its own copy, a version disagreement no longer bricks the host at the federation layer. Each remote builds from its exact ui/domain dependency pins and one-member lockfile. Contract-v2 workflows reject producer SHA override inputs.
Host compatibility contract
stralos-shell/federation-contract.json is the versioned, machine-readable host contract. The current document has schema version 2, id stralos-shell/federation/v2, and declares the React family (react, react-dom, react-router-dom) at exact pins. A host block states which packages the shell provides and at what version. @stralos-co-nz/ui and @stralos-co-nz/domain are absent — they are bundled, not shared.
The shell’s publish.yml runs scripts/verify-federation-contract.mjs before dependency installation or AWS authentication. Each remote carries the same snapshot at .ci/shell-federation-contract.json; its remote-build workflow runs the same structural check against that repository’s vite.config.ts and package.json. A remote only has to declare packages it imports directly, but every declaration it does carry must match the host contract exactly.
For a breaking host contract change, publish a new contract id instead of silently changing the meaning of stralos-shell/federation/v2, then update each remote’s snapshot and declaration as one coordinated wave.
SHARED_SINGLETONS and FEDERATION_SHARED
The shell separates the package-level release contract from the full Module Federation share map:
SHARED_SINGLETONScontains the React-family package declarations that feed the release contract (react,react-dom,react-router-dom).FEDERATION_SHAREDspreads those declarations and addsreact/jsx-runtimewith the same singleton, strictness, and exact version asreact. This is the object passed to the federation plugin.
react/jsx-runtime stays outside SHARED_SINGLETONS because it is a subpath whose version is already represented by React, while the release-record schema admits only the package-level singleton keys. Declaring the subpath explicitly prevents the federation plugin from inferring different share maps for otherwise identical builds.
@stralos-co-nz/ui and @stralos-co-nz/domain are absent from both objects. Under contract 2 they are bundled; scripts/verify-federation-contract.mjs refuses a build that adds either to the share map.
What the artifact was built against
Under contract 2, each remote’sbuilt-against.json sidecar records { contract: 2, requires }. The bundled @stralos-co-nz/ui and @stralos-co-nz/domain versions come from the remote’s exact dependency pins and lockfile rather than a builtAgainst field. The release record likewise carries contract: 2 and requires; release tooling fails closed if the sidecar is absent.
Consuming new shared-package exports
Under contract 2,@stralos-co-nz/ui and @stralos-co-nz/domain are bundled by each consumer independently. A remote that starts using a new export from either package can publish and deploy without waiting for a shell rebuild or coordinating a singleton range change.
A local workspace can expose an unreleased export through workspace:*. Before publication, update the consumer’s exact package pin and one-member lockfile. Prefer additive exports and optional props. A breaking shared-package change still requires every consumer to update, but there is no host-level federation refusal to coordinate.
Federation build and preview targets
Runtime federation is always enabled. Each feature’s remote workflow buildsremoteEntry.js and uploads an immutable GitHub Actions artifact.
Every current remote implements the release-model R5/R13 publication pipeline. A push to main triggers deploy-dev.yml, which builds the remote, packs a deterministic component archive, writes the durable bundle at remotes/<remote>/by-sha/<sha>/bundle.tar, and publishes the by-SHA files to the dev remotes bucket. This produces the bytes but does not activate them. stralos-core’s gate-dev workflow composes a compatible set and updates the live dev manifest.
The central release dispatches each member’s release-create.yml at its release tag. Test creation calls deploy-test.yml with from_dev: true to publish the archived dev bundle for that source SHA. Production verifies and promotes the test bytes to the final version without rebuilding. GitHub release events no longer trigger production publication. See Release model.
The component archive is a deterministic POSIX ustar tar file (format tar-v1). Files are sorted, file modes are fixed at 0644, and uid, gid, and mtime values are zeroed. Paths use the ustar name and prefix fields when they fit; canonical PAX headers are added only when a path cannot fit those fields.
The shape of release.json depends on the contract generation:
- Contract v1:
{ version, artifact: { format: "tar-v1", uri, digest, directoryDigest, versionId }, requires, builtAgainst }—requiresrecords the declared singleton floor (caret stripped) for all four singletons;builtAgainstrecords the exact resolved versions of@stralos-co-nz/uiand@stralos-co-nz/domain. - Contract v2:
{ version, artifact: { format: "tar-v1", uri, digest, directoryDigest, versionId }, contract: 2, requires }—requirescontains only the host singleton caret ranges (react, react-dom, react-router-dom); nobuiltAgainstblock, because ui and domain are bundled by the remote.
artifact.digest is the SHA-256 of the archive bytes; artifact.directoryDigest is the SHA-256 over the sorted <path>\t<file-sha>\n manifest and matches the content regardless of tar framing.
The produce step writes a built-against.json sidecar at remotes/<remote>/by-sha/<sha>/built-against.json alongside the bundle. The release step reads it back. Under v1, the sidecar carries the resolved ui and domain versions and the release gate copies them into builtAgainst. Under v2, the sidecar carries { contract: 2, requires: { ... } } derived from the vendored contract, and the gate checks that it agrees with the contract before writing any bytes. A release refuses to proceed if no sidecar is present. Produce the commit before releasing it. A repeated produce run may resume only when its bytes match the stored artifact; otherwise create and produce a new commit.
Publication alone does not activate a remote in an environment; the coordinator must promote a manifest that pins it.
Enabling a producer requires all of the following:
ARTIFACTS_BUCKETin thedevGitHub Environment, naming the versioned, Object Lock-enabled artefact store.ARTIFACTS_WRITE_ROLE_ARNin thedevGitHub Environment, naming the OIDC role assumed by the producer.- The same role ARN in the bucket policy’s
artifact_writer_role_arnslist. WORKSPACE_CHECKOUT_TOKENwith read access to the private workspace and shared packages.
release GitHub Environment and require the checkout token to load the canonical shell contract.
Integration suite contracts
The shell’s integration suite (integration/**, run with pnpm test:integration) no longer clones owner repositories or pins their source SHAs. Each remote publishes a routes-contract.json derived from its own src/routes.ts and validated by that remote’s own CI. The shell commits each published contract under integration/__contracts__/, validates the committed copies against the vendored schema, and tests its composition and hand-off against contract-shaped doubles built from them. A remote’s route change fails the remote’s pull request, not a stale host pin.
The build and integration jobs both install from the one-member .ci/workspace/ lockfile; the byte-identical .ci/integration/ copy was removed on 2026-10-11 (shell #405). The suite proves the shell’s composition against each remote’s published contract, not the remotes’ rendered pages, so served-artifact and browser checks are still needed for deployed bundles.
Reproducible remote builds
For v1 remotes, each CI build resolves satisfying UI and domain release tags to immutable SHAs withresolve-singleton-sha.mjs before building. The script emits both the SHA (as BUILD_UI_SHA / BUILD_DOMAIN_SHA) and the resolved version number (as BUILD_UI_VERSION / BUILD_DOMAIN_VERSION). Explicit revisions are available for deliberate debugging, but release builds use the catalogue resolution path. A local build can therefore pass while CI rejects an input that cannot be resolved from the release catalogue. The script emits the resolved version alongside the sha; a ::notice:: annotation records the version actually compiled against for each singleton. An override build emits a ::warning:: annotation and records no version.
For v2 remotes, resolve-singleton-sha.mjs reads the contract, logs the bundled pins, emits contract=2, and resolves nothing. A BUILD_UI_SHA_OVERRIDE or BUILD_DOMAIN_SHA_OVERRIDE dispatch input under v2 fails the build rather than being silently inert. ui and domain are installed from GitHub Packages using the one-member lockfile in .ci/workspace/.
After the build, the producer writes the tar-v1 artifact described above to
remotes/<remote>/by-sha/<sha>/bundle.tar and a built-against.json sidecar to
remotes/<remote>/by-sha/<sha>/built-against.json. For v1 remotes the sidecar records the exact ui and domain versions the artifact linked against. For v2 remotes the sidecar records the contract generation and the required singleton caret ranges derived from the vendored contract. A re-run can resume only when the bytes are
identical; it cannot overwrite a different artifact.
Dashboard, Workforce, Project Schedule, and Scheduler set ROLLDOWN_WORKER_THREADS=1 to serialise graph traversal. Release workflows copy the pre-built by-SHA bundle; they do not rebuild it for a tag.
Dev publication
All registered feature remotes rundeploy-dev.yml automatically on pushes to main and also support manual dispatch for an explicit ref. The workflow has the same boundary in every remote:
- Build and publish the deterministic archive to the durable artefact store.
- Synchronise
dist-remote/toremotes/<feature>/<sha>/in the dev remotes bucket. Content-hashed assets use immutable caching;remoteEntry.jsusesmust-revalidate. - Stop without changing the live manifest.
gate-devinstralos-coreverifies and activates a compatible composition (produce → gate → activate).
REMOTES_BUCKET and AWS_REMOTE_DEPLOY_ROLE_ARN from the dev GitHub Environment and fails before AWS authentication if either is missing. It also needs WORKSPACE_CHECKOUT_TOKEN to build from the private workspace. Test and prod publish version-addressed candidates, then use the matching core gate and shell activation workflows described in Release model.
The ports listed in the topology table are fixed local preview targets. A runtime manifest can override the shell’s built-in fallback URLs.
Phase 0 decisions already reflected in codebase
@stralos-co-nz/*scope is in use.- Root pnpm workspace exists for local linking.
stralos-uiandstralos-domainare the shared package surfaces.- Per-feature API clients remain the architecture of record.
Split-mount nav pattern
A feature remote can serve routes at a URL path outside its declaredbasePath. The stralos-core seed now expresses all five of the shell’s historic sibling paths as descriptor paths entries, so the generic mount builder produces them rather than bespoke per-path shell constants.
The five paths and their host remotes are:
- Crews (
/crews) —workforceremote, whose primary path is/people. A Crews nav item appears immediately after People. - Compliance (
/compliance) —workforceremote. A visible Resources nav item atorder: 65, between Crews and Plant. - Variations (
/projects/:projectId/commercial/variations) —estimateremote. - Documents (
/projects/:projectId/documents) —estimateremote. The path entry usesend: false, so M4 review descendants remain titled Documents. - Programme (
/projects/:projectId/delivery/programme) —projectScheduleremote, whose primary path is/projects/:projectId/delivery/schedule.
featureName in the error boundary now comes from its own navLabel, not from the remote’s primary label. A load failure on the Variations page says “Something went wrong in Variations” rather than “Something went wrong in Estimate”.
An unmatched descendant under any of these mounts renders “Page not found”. Before this change the shell registered no catch-all for multi-path mounts and those paths rendered nothing.
The rates remote also declares /projects/:projectId/estimate/builder/rates/import in its paths array with nav: "hidden" and end: true. This path serves the project-scoped rate import flow from within the project workspace. It does not appear in any navigation rail; the workspace active-marking logic highlights Estimate while this path is open, not Rates Library. The route is loaded by the rates remote directly rather than passing through the estimate mount, so the estimate remote is not loaded on a direct link to this path.
The Contractor Portal (/contractor-portal/:portalToken) and Work Items Export (/projects/:projectId/estimate/builder/work-items/export) are not sibling mounts in the pattern above. They are declared inside the estimate descriptor’s paths array with chrome: false and are mounted outside AppFrame by the generic chromeless-mount builder. The shell no longer uses hard-coded constants for those two paths; the descriptor is the only source.
The fleet remote needs no sibling mount: /plant/subcategories was a sub-path of /plant until R6, when it was retired and replaced with a redirect to /plant.
The projectSchedule remote (stralos-feature-project-schedule, dev port 4180) owns /projects/:projectId/delivery/schedule as its primary mount and /projects/:projectId/delivery/programme as a sibling path in its descriptor (plan M1; renamed and re-pathed at navigation-migration M3, migrated from shell constant to descriptor path at step 4.13). The remote communicates with Scheduler, Global, Estimate, and Rates through the common build-time, runtime, support-override, then same-origin/localhost fallback chain documented in Frontend architecture.
New remotes that serve routes at several unrelated paths should declare them in the descriptor’s paths array.
Remote navigation ordering
Since shell-reduction §4.7 act 7, the shell composes remote destinations in both the global rail and the project workspace drawer from route descriptor fields. TheGLOBAL_NAV, WORKSPACE_NAV, NAV_VISIBILITY, WORKSPACE_VISIBILITY, and REMOTE_DISPLAY_OVERRIDES declarations have been removed. The shell still contributes Overview to each project workspace. All Projects is no longer a shell-contributed nav item in the project drawer; the header’s Projects entry is the way back to the project register.
The shell reads the following fields from each paths entry to build the rail:
nav— determines membership. Absent or"primary"entries appear in the rail;"hidden"entries are registered as routes but not shown.order— determines position. Lower numbers sort first.group— determines section grouping. An entry without agroupis a top-level item.navLabel— the displayed label.end— determines active matching.truehighlights only on the exact path;falsehighlights on any descendant.
order values in stralos-core’s seed and requires the full FALLBACK_MANIFEST (v2) composition — DEFAULT_MANIFEST (v1) lacks the workspace and organisation descriptors that supply Projects and Settings. Within the Resources group the item order is: Rates Library (rates), Construction Activities (activities), People (workforce), Crews (workforce sibling), Compliance (workforce sibling, order: 65), Plant (fleet), Companies (workspace), Locations (workspace), Company scheduler (scheduler).
Project workspace navigation (Overview · Estimate · Delivery [Programme, Schedule] · Commercial [Variations] · Documents · Project Settings) combines the shell-owned Overview entry with remote destinations composed from descriptor order and group values.
The shell no longer has a unilateral veto over section names or membership. A deployed rename — a new navLabel in a released manifest — takes effect immediately with no shell release. A remote that ships with an order field gains a rail entry with no shell change.
Rules for manifest authors:
- The order of keys in
remotes-manifest.jsonhas no effect on nav order. Reordering manifest keys does not change what users see. - Rail membership is controlled by the
navfield on eachpathsentry. An entry is included by default; setnav: "hidden"to register a route without a nav item. - Rail position is controlled by the
orderfield. Entries withoutordersort after all ordered entries. - The displayed label comes from
navLabelin the descriptor; there is no shell-side override map. - Adding a new remote
basePathor a newpathsentry also requires a matching entry inROUTE_CATALOG(src/routeCatalog.ts) and a regeneratedrouteCatalog.generated.json. See Frontend architecture — Route catalogue.
Legacy route redirects (M3)
At navigation-migration M3, three project workspace routes moved to canonical paths under/delivery and /commercial. The shell registers permanent redirects for the previous paths:
Since §4.7 act 7, these redirects are driven by the
aliases field on each canonical paths entry rather than a shell-side map. The shell reads aliases from the descriptor and registers a permanent redirect for each one when the remote is present in the active composition. An absent remote causes both the old and new paths to 404 rather than loop.
The redirect preserves the full URL — project id, deeper path segments, query string, and fragment are all carried across.
Route catalogue ids for these three entries (plan, schedule, variations) are unchanged. An id is a stable join key for help-page routeId frontmatter; renaming one on a path move would break every reference to a section that did not itself change.
Legacy company-register redirects (M5)
The Companies register replaced the separate Clients and Suppliers and Locations pages. Theworkspace remote now owns Companies, the organisation’s Locations page, and the legacy redirects.
The detail forms redirect to the list because a legacy client or supplier ID is not a unified party ID.
Widget slot remotes (thin-host PRD §9.8)
A remote can declarekind: "widget" in the manifest to be placed in a named shell slot rather than mounted at a URL path. Widget remotes have no basePath and no navLabel; they are not navigation destinations.
Manifest entry shape
kind field defaults to "route" when absent, so all existing manifest entries continue to parse without change. An entry with an unknown kind value is skipped rather than causing the entire manifest to fail. An entry with kind: "widget" but no slot is also skipped.
Known slots
The shell accepts an allow-list of slot names. A widget naming a slot not on this list is skipped and an error is reported to telemetry rather than silently dropped — silence would be indistinguishable from the widget being absent.
The assistant key is in
stralos-core’s active remote seed and reconciliation set.
Module contract
A widget remote must expose its component at<name>/widget. The shell loads this module through React.lazy and calls its default export. A module that loads but exposes no default component is treated as a contract failure and the error boundary shows the manifest’s fallbackCopy prose.
widgetProps record. The widget receives the shell’s Router context (react-router-dom is a strict shared singleton) and can call router hooks directly.
Rendering — RemoteWidgetSlot
RemoteWidgetSlot in remote-host.tsx renders every widget the manifest places in one slot. Call it inside the router with the resolved manifest and the target slot name:
App.tsx mounts RemoteWidgetSlot for appframe-aside after the user first opens the assistant. It keeps the widget mounted across route changes and panel closes.
App.tsx mounts a second RemoteWidgetSlot for floating-end eagerly in the authenticated chromed layout, without waiting for the assistant to be opened. This slot does not appear on chromeless pages or on the billing recovery page. The host passes { assistantOpen } as widget props so the widget can step aside when the panel is visible.
Isolation and mount behaviour
Widget isolation is stricter than route isolation. The widget is anAppFrame sibling, so an unhandled throw propagates to the entire authenticated layout rather than to a single page. Each widget is wrapped in its own ErrorBoundary with the same telemetry shape as route mounts.
The widget is mounted once and kept mounted. The assistant panel holds its conversation in component state; a remount loses the transcript. The lazy component lives in a module-level cache keyed by the remote’s identity (name, remoteEntryUrl, version) so re-rendering the slot does not mint a new element type and does not remount the panel.
When the user clicks Retry on the error boundary, the shell evicts the settled lazy from the cache and takes the next attempt from the monotonic global retry generation — the same store route mounts use — so a retry after a remount cannot collide with a cache key a previous mount already poisoned.
Onboarding checklist — shell contract
The onboarding checklist widget (floating-end slot) can be reopened from the user menu’s Setup guide item. Because the shell cannot call directly into the widget remote, the menu item dispatches a custom window event instead; the widget listens for it.
Event name: stralos:onboarding-open
This name is a cross-repository contract between stralos-shell and stralos-feature-onboarding. The widget listens for this exact string. Do not rename it on either side without updating the other.
The Setup guide item appears in the user menu only when all three conditions are true:
- The
onboarding-checklistPostHog feature flag is enabled and ready (not just enabled —readymust betruebefore the item appears, so an absent or loading flag keeps the item hidden). - The signed-in user has the
adminrole. - The onboarding checklist widget is mounted (the chromed authenticated layout, not billing-recovery or chromeless routes).
requestOnboardingOpen() in src/setupGuide.ts dispatches the stralos:onboarding-open event. The widget receives it, un-dismisses any previously dismissed checklist, and opens its panel.
Parser behaviour for unknown entries
Before this change, one unparseable manifest entry causedparseManifest to return null for the whole manifest. A null result is read as “no manifest”, degrading all nine feature remotes to their unavailable cards. The parser now skips individual entries that fail to parse (unknown kind, widget without slot, route without basePath/navLabel) and logs a warning for each skipped key. The surrounding entries are unaffected.
Federated lazy route loading
Each remote route bundle is loaded throughReact.lazy in remote-host.tsx. lazyRemoteRoutesCache stores the lazy components at module scope, keyed by a JSON-encoded tuple containing the identity fields listed below. Module scope is essential: React 19 may discard uncommitted hook state when a suspended transition aborts, while the cache preserves the same lazy component and in-flight promise.
Rejected lazy components remain cached until the user selects Retry from the committed error fallback. The retry handler deletes that attempt’s cache entry and assigns a fresh module-global generation before rendering again. Keeping failure eviction in the retry handler avoids repeatedly creating rejected loaders during a suspended render.
The cache key uses JSON.stringify([name, remoteEntryUrl, version, basePath, degraded, fallbackCopy, isMultiPath, attempt]), preventing collisions between different descriptor combinations. The isMultiPath flag distinguishes a single-path mount from a multi-path descriptor mounted at the same path.
Stale-chunk reload guard
When a remote chunk fails to load,chunkReloadGuard.ts checks whether the failure is a fetch error that a single reload can fix — for example, a browser holding an index.html the server has since replaced, whose manifest points to a version whose chunks have expired under the D5 retention window.
If the error matches the chunk-load classifier, the guard reloads the page once. The reload is keyed by a composition key stored in sessionStorage under stralos.chunk-reload. Subsequent failures under the same composition key are not reloaded; the failure surfaces to the per-feature fallback card as normal.
Composition key sources (first match wins):
- The 64-hex-character content hash in
__STRALOS_REMOTES_MANIFEST_URL__— used when a promotedindex.htmlpins a content-addressed manifest path. No boot-time capture is required. - A key captured at boot from the mutable
/remotes-manifest.json— used in every environment that serves the mutable manifest (dev, test, and production cohort pointers). The key is the manifest’s ETag when the server sends one, or a 64-bit FNV-1a-shaped body digest when it does not.main.tsxcaptures this key after the manifest resolves and passes it tosetCompositionKey.
- The error is not a chunk-load failure (federation init errors, missing-export errors, and render errors are excluded — a reload cannot fix an absent symbol).
- No composition key is available (the manifest resolution failed, or the shell is running in a context where neither source is present).
sessionStorageis unavailable or the mark cannot be written — without a persisted bound on the reload, the guard cannot prevent a loop.
new Promise<never>(() => {})) so the fallback card is not shown while the page reloads.
Bootstrap chunk failures (shell entry chunks such as ./remotes or ./remote-host that 404 before the manifest resolves) are handled by a separate reloadOnceForStaleBootstrapChunk path. Because setCompositionKey has not run yet when these chunks fail, the guard asks the server directly — one no-store request to the cohort’s mutable manifest — to derive the composition key before applying the same one-reload-per-key logic.
New version notification
The shell watches for newer deployments while a tab is open. In development and test, it shows a non-blocking notification when one is detected. The notification reads New version available — Reload to pick up the latest release and offers Reload and Dismiss buttons. Dismissing hides it for the current page load but does not prevent the automatic reload at the next completed in-app navigation. On development and test hostnames, the notification appears inside the environment banner. If the runtime environment is development or test but the hostname has no environment banner, it appears as a standaloneNewVersionNotice.
Version detection
Two oracles are polled in parallel every five minutes:
Either oracle changing counts as a new version. Polling only
index.html would miss remote-only deploys.
Polls use cache: "no-cache" and credentials: "omit". The no-cache directive makes each request a conditional GET (one 304 in the steady state when the server sends ETags), so there is no cache-busting query string. The credentials: "omit" ensures a staff user’s cohort cookie does not cause the staff manifest to be compared against the standard baseline.
Three triggers drive polls:
setIntervalat the five-minute interval — the steady-state sweep.visibilitychange— the moment a backgrounded tab is focused again.pageshowwithpersisted: true— a bfcache restore, which freezes timers.
Behaviour by environment
The
environment value from runtime-config.json controls banner visibility only. A detected update triggers automatic reload after the next completed in-app navigation in every environment.
Auto-reload does not fire at detection time. Dismiss does not cancel it.
Implementation notes
versionAvailability.ts owns detection and calls back at most once per tab lifetime. NewVersionNotice.tsx owns the standalone banner and the useNewVersionAvailable hook, which coordinates detection, dismiss state, and the navigation-triggered reload. ShellNotices.tsx embeds a visible update in EnvironmentNotice on development and test hostnames, or renders standalone NewVersionNotice on other hosts when the runtime environment allows a banner.
Routes contract v2 and RemoteProvider
Under routes contract v1, a remote’sroutes.ts exports a RouteDefinition[] only. The shell mounts each route individually without a wrapping provider.
Under routes contract v2, a remote’s routes.ts also exports a RemoteProvider component beside its routes array. The shell mounts RemoteProvider once as a pathless layout route wrapping every route the remote exposes, so provider state (including the Chakra UI system and the Radix portal scope) survives navigation within the remote without re-mounting between pages.
Contract-v2 route remotes export RemoteProvider from src/routes.ts:
RemoteProvider wraps @stralos-co-nz/ui’s provider and binds it to that remote’s manifest key. Because contract-v2 remotes bundle their own copy of ui, the provider comes from the remote’s bundle rather than a shell ui singleton.
A v1 remote exports no RemoteProvider. The shell treats an absent export as “no wrapper” and mounts its routes directly.
Contract to preserve
- Features expose
RouteDefinition[]. - Contract v2 features also export
RemoteProviderfor the shell to mount as a layout wrapper. - Project context comes from URL params.
- Shell owns app frame, auth, and project selection.
- Features own their own API calls and user workflows.

