> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Micro-frontends

> Feature package composition in Stralos

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

```json theme={null}
{
  "name": "rates",
  "remoteEntryUrl": "/remotes/rates/1.2.3/remoteEntry.js",
  "version": "1.2.3",
  "kind": "route",
  "basePath": "/rates",
  "navLabel": "Rates Library",
  "fallbackCopy": "The rates library is unavailable right now."
}
```

**Multi-path descriptor**:

```json theme={null}
{
  "name": "workspace",
  "remoteEntryUrl": "/remotes/workspace/1.0.0/remoteEntry.js",
  "version": "1.0.0",
  "kind": "route",
  "paths": [
    { "path": "/projects",  "navLabel": "Projects",  "end": true  },
    { "path": "/companies", "navLabel": "Companies", "end": false },
    { "path": "/locations", "navLabel": "Locations", "end": false }
  ],
  "fallbackCopy": "Projects, companies and locations are unavailable right now."
}
```

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](https://help.stralos.app/internal/release-model.md) 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`.

```json theme={null}
{
  "kind": "widget",
  "slot": "appframe-aside",
  "name": "assistant",
  "remoteEntryUrl": "https://…/remoteEntry.js",
  "fallbackCopy": "Assistant unavailable"
}
```

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](https://help.stralos.app/internal/assistant-shell-draft-controller.md) 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

| Remote key | Repository | Preview port | Base path |
| - | - | -: | - |
| `dashboard` | `stralos-feature-dashboard` | `4174` | `/dashboard` |
| `rates` | `stralos-feature-rates` | `4175` | `/rates`, `/projects/:projectId/estimate/builder/rates/import` |
| `activities` | `stralos-feature-activities` | `4176` | `/activities` |
| `scheduler` | `stralos-feature-scheduler` | `4179` | `/scheduler` |
| `workforce` | `stralos-feature-workforce` | `4181` | `/people` |
| `fleet` | `stralos-feature-fleet` | `4182` | `/plant` |
| `estimate` | `stralos-feature-estimate` | `4177` | `/projects/:projectId/estimate` |
| `projectSchedule` | `stralos-feature-project-schedule` | `4180` | `/projects/:projectId/delivery/schedule` |
| `projectSettings` | `stralos-feature-project-settings` | `4178` | `/projects/:projectId/settings` |
| `workspace` | `stralos-feature-workspace` | `4183` | `/projects`, `/companies`, `/locations`, and legacy redirects |
| `organisation` | `stralos-feature-organisation` | `4184` | `/settings` |

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](https://help.stralos.app/internal/frontend-architecture.md#project-rail-slots-shell).

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.

| Slot | Remote key | Purpose |
| - | - | - |
| `appframe-aside` | `assistant` | AI copilot panel and conversation management |
| `floating-end` | `onboarding` | Admin setup checklist, gated on the resolved `onboarding-checklist` flag |
| `floating-end` | `userSettings` | Personal settings, available to every signed-in member without a feature flag |
| `floating-end` | `tours` | Guided page tours host (`stralos-feature-tours`, preview port `4188`). Shown unless the PostHog `guided-tours` flag evaluates to `false`; see [Guided page tours](https://help.stralos.app/internal/guided-tours-frontend.md). |

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

| Package | Required version | Pin style | Runtime share object |
| - | - | - | - |
| `react` | `19.2.8` | Exact | `SHARED_SINGLETONS` and `FEDERATION_SHARED` |
| `react-dom` | `19.2.8` | Exact | `SHARED_SINGLETONS` and `FEDERATION_SHARED` |
| `react/jsx-runtime` | `19.2.8` | Exact | `FEDERATION_SHARED` only |
| `react-router-dom` | `7.18.3` | Exact | `SHARED_SINGLETONS` and `FEDERATION_SHARED` when imported directly |

`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](https://help.stralos.app/internal/release-model.md).

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](https://help.stralos.app/internal/release-model.md).

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](https://help.stralos.app/internal/frontend-architecture.md#api-base-url-resolution).

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](https://help.stralos.app/internal/frontend-architecture.md#route-catalog).

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

| Legacy path | Canonical path |
| - | - |
| `/projects/:projectId/plan` | `/projects/:projectId/delivery/programme` |
| `/projects/:projectId/schedule` | `/projects/:projectId/delivery/schedule` |
| `/projects/:projectId/variations` | `/projects/:projectId/commercial/variations` |

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 `id`s 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.

| Legacy path | Canonical path |
| - | - |
| `/clients` | `/companies` |
| `/clients/:id` | `/companies` |
| `/suppliers` | `/companies` |
| `/suppliers/:id` | `/companies` |

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

```json theme={null}
{
  "name": "assistant",
  "remoteEntryUrl": "/remotes/assistant/1.0.0/remoteEntry.js",
  "version": "1.0.0",
  "kind": "widget",
  "slot": "appframe-aside",
  "fallbackCopy": "The assistant is unavailable right now."
}
```

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.

| Slot | Location |
| - | - |
| `appframe-aside` | Sibling of `AppFrame`; occupied by the assistant panel. |
| `floating-end` | Bottom-right overlay in the authenticated chromed layout. Mounted eagerly — not gated on `assistantLoaded`. The host passes `assistantOpen` so the widget can reposition when the panel is open. |

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.

```ts theme={null}
// What <name>/widget must export
export default function AssistantWidget(props: Record<string, unknown>): ReactElement { ... }
// Optional — a v2 remote that bundles its own Chakra context
export function RemoteProvider({ remoteKey, children }: RemoteProviderProps): ReactElement { ... }
```

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:

```tsx theme={null}
<RemoteWidgetSlot
  slot="appframe-aside"
  manifest={manifest}
  degraded={degraded}
  widgetProps={{ open: isPanelOpen }}
/>
```

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

| Oracle | What it detects |
| - | - |
| `index.html` | The deployed shell's git SHA, extracted from versioned asset URLs (`/shell/<40hex>/assets/…`). Changes when the shell is activated. |
| `/remotes-manifest.json` | A composition key derived from the manifest's ETag or, when no ETag is served, a 64-bit body digest. Changes when a remote is deployed and the manifest is updated. |

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

| Environment | Banner | Auto-reload on next navigation |
| - | - | - |
| Production, staging, unknown, absent | Hidden | Yes |
| Development (`environment: "development"`) | Shown | Yes |
| Test (`environment: "test"`) | Shown | Yes |

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

```ts theme={null}
// Routes contract v2: exported beside estimateRoutes so the shell can mount it around all of them.
export { RemoteProvider } from "./remoteProvider";
```

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


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