Skip to main content
Stralos uses Module Federation for feature composition. The shell always loads feature remotes from its runtime manifest. There is no static-composition build path.

Current state

  • Every shell build is a federated host. pnpm build and pnpm build:remotes produce the same output; neither compiles feature source.
  • stralos-shell contains 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/workspace contains 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:
Multi-path descriptor:
A descriptor may use 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 from paths[0]; a version-1 reader of the same bytes takes it from navLabel. A half-done rename to paths[0].navLabel mislabels the rail item temporarily but does not remove the feature from the site.
The core manifest writer refuses both forms, so a correctly assembled manifest cannot carry either disagreement. The shell’s guards are the second line for manifests that were not written by the assembler. Each entry in 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 — when true, the nav item is only highlighted on an exact match; when false, it highlights on any descendant path.
Each entry also accepts these optional fields:
  • 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 means true. A path with auth: false must also appear in the shell’s UNAUTHENTICATED_PATH_PREFIXES allow-list; values outside that list are overridden to true at parse time.
  • chrome — whether the mount renders inside AppFrame. Absent means true. Independent of auth: 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.
Both 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:
  1. window.__STRALOS_REMOTES__ — inline manifest object injected into the page, no fetch.
  2. window.__STRALOS_REMOTES_MANIFEST_URL__ — release-pinned URL written into index.html.
  3. The URL in localStorage["stralos.remotesManifestUrl"] — local development override.
  4. VITE_STRALOS_REMOTES_MANIFEST_URL — build-time override.
  5. /remotes-manifest.json — same-origin development fallback.
Produced shell documents resolve the same-origin /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 versions 1 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 no basePath or navLabel.
The shell registers 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 loads assistant/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-1 persistentChrome 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_SINGLETONS contains the React-family package declarations that feed the release contract (react, react-dom, react-router-dom).
  • FEDERATION_SHARED spreads those declarations and adds react/jsx-runtime with the same singleton, strictness, and exact version as react. 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’s built-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 builds remoteEntry.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 } — requires records the declared singleton floor (caret stripped) for all four singletons; builtAgainst records the exact resolved versions of @stralos-co-nz/ui and @stralos-co-nz/domain.
  • Contract v2: { version, artifact: { format: "tar-v1", uri, digest, directoryDigest, versionId }, contract: 2, requires } — requires contains only the host singleton caret ranges (react, react-dom, react-router-dom); no builtAgainst block, because ui and domain are bundled by the remote.
In both cases, 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:
  1. ARTIFACTS_BUCKET in the dev GitHub Environment, naming the versioned, Object Lock-enabled artefact store.
  2. ARTIFACTS_WRITE_ROLE_ARN in the dev GitHub Environment, naming the OIDC role assumed by the producer.
  3. The same role ARN in the bucket policy’s artifact_writer_role_arns list.
  4. WORKSPACE_CHECKOUT_TOKEN with read access to the private workspace and shared packages.
The producer validates both variables before AWS authentication and fails closed when either is missing. Stable releases read the artefact variables from the separate 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 with resolve-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 run deploy-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:
  1. Build and publish the deterministic archive to the durable artefact store.
  2. Synchronise dist-remote/ to remotes/<feature>/<sha>/ in the dev remotes bucket. Content-hashed assets use immutable caching; remoteEntry.js uses must-revalidate.
  3. Stop without changing the live manifest. gate-dev in stralos-core verifies and activates a compatible composition (produce → gate → activate).
The publication job reads 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-ui and stralos-domain are 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 declared basePath. 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) — workforce remote, whose primary path is /people. A Crews nav item appears immediately after People.
  • Compliance (/compliance) — workforce remote. A visible Resources nav item at order: 65, between Crews and Plant.
  • Variations (/projects/:projectId/commercial/variations) — estimate remote.
  • Documents (/projects/:projectId/documents) — estimate remote. The path entry uses end: false, so M4 review descendants remain titled Documents.
  • Programme (/projects/:projectId/delivery/programme) — projectSchedule remote, whose primary path is /projects/:projectId/delivery/schedule.
Each path’s 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. The GLOBAL_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 a group is a top-level item.
  • navLabel — the displayed label.
  • end — determines active matching. true highlights only on the exact path; false highlights on any descendant.
The global navigation order (Dashboard · Projects · Resources · Settings) is expressed through 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.json has no effect on nav order. Reordering manifest keys does not change what users see.
  • Rail membership is controlled by the nav field on each paths entry. An entry is included by default; set nav: "hidden" to register a route without a nav item.
  • Rail position is controlled by the order field. Entries without order sort after all ordered entries.
  • The displayed label comes from navLabel in the descriptor; there is no shell-side override map.
  • Adding a new remote basePath or a new paths entry also requires a matching entry in ROUTE_CATALOG (src/routeCatalog.ts) and a regenerated routeCatalog.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. The workspace 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 declare kind: "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

The 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.
The shell passes caller-supplied props through the 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 an AppFrame 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:
  1. The onboarding-checklist PostHog feature flag is enabled and ready (not just enabled — ready must be true before the item appears, so an absent or loading flag keeps the item hidden).
  2. The signed-in user has the admin role.
  3. The onboarding checklist widget is mounted (the chromed authenticated layout, not billing-recovery or chromeless routes).
When the item is clicked, 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 caused parseManifest 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 through React.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):
  1. The 64-hex-character content hash in __STRALOS_REMOTES_MANIFEST_URL__ — used when a promoted index.html pins a content-addressed manifest path. No boot-time capture is required.
  2. 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.tsx captures this key after the manifest resolves and passes it to setCompositionKey.
Both sources answer the same question: which composition is this shell running? A deploy that changes the manifest mints a new key and allows one more reload. The guard refuses to reload in these circumstances:
  • 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).
  • sessionStorage is unavailable or the mark cannot be written — without a persisted bound on the reload, the guard cannot prevent a loop.
When a reload is issued, the loader suspends indefinitely (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 standalone NewVersionNotice.

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:
  • setInterval at the five-minute interval — the steady-state sweep.
  • visibilitychange — the moment a backgrounded tab is focused again.
  • pageshow with persisted: true — a bfcache restore, which freezes timers.
An immediate poll runs at mount so the tab’s baseline is confirmed against the server within seconds rather than waiting for the first interval.

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’s routes.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 RemoteProvider for 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.