stralos-feature-project-settings is the Module Federation remote that owns the Project Settings page. It exposes a single route at /projects/:projectId/settings, registered as projectSettings in the shell’s remote topology.
Routes
Both routes render the same component at the same position, so navigating between sections keeps the page mounted. The shell mounts this remote at
/projects/:projectId/settings with end: false so the nested :section segment reaches src/routes.ts without a shell change.
Routes are registered in src/routes.ts and exported as projectSettingsRoutes. The section set is defined in src/settingsSections.ts as PROJECT_SETTINGS_SECTIONS.
Legacy address handling
Addresses formed before the rail existed are redirected to their section:#rate-preferences(fragment anchor from the estimate’s rate-exceptions panel) →rate-preferencessection.?estimateId=…(estimate-scoped rate links) →rate-preferencessection.
legacyProjectSettingsSection(hash, search) in src/settingsSections.ts resolves these cases. An unknown section segment (one no section owns) redirects to the default section rather than rendering under a URL that names an unrecognised section.
Components
ProjectSettingsPage
Wraps ProjectSettingsContent in a SettingsDraftProvider. The active section is driven by the URL (useParams().section), not by component state. Switching sections is a history entry; the rail is a set of <Link> elements, one per PROJECT_SETTINGS_SECTIONS entry.
Each section has its own SettingsSaveBar (scoped to that section’s scope name) rendered at the bottom of its pane. Only the open section can be dirty — inactive sections are unmounted when they are not the active route. The page therefore has no page-wide single save bar; the shared SettingsDraftProvider coordinates the navigation blocker across the whole page.
If the user navigates away while the open section has unsaved changes, RouteDraftGuard intercepts the transition and shows a Save changes before leaving? dialog with three actions: Save changes (saves and proceeds only if the save succeeds, otherwise stays and shows the reason), Discard changes (discards and proceeds), and Keep editing (cancels the navigation). Any change of pathname or search triggers the guard, including the browser’s Back and Forward buttons.
ProjectSettingsContent loads the project record, organisation settings, and the client list on mount in a single Promise.all. The project’s primary estimate is loaded separately, in a chained loadEstimate call. If the estimate load fails, only the Hours per working day field is affected: it renders as unavailable with a helper message, while every other field remains editable and saveable. The Multiple estimates setting has its own read and degrades the same way (see below).
Project detail fields are held in a ProjectSettingsValues state object alongside a pristineValues snapshot of the last-saved state. The isDirty flag compares the two; it drives the page-level dirty guard and controls whether the section can contribute to the shared Save changes action.
A single in-flight gate (busy) is shared between metadata saves (upsertProject) and status transitions (transitionProject). Only one write is in flight at a time; every mutating control disables while it is held.
Status labels are resolved through the project_statuses setting list so platform-level relabels (for example, “Estimating” relabelled to “Tendering”) appear on the badge and action buttons without a code change.
The list is loaded via useSettingList(listSettingListItems, "project_statuses", PROJECT_STATUS_VALUES) from @stralos-co-nz/ui. The hook seeds a fallback state from PROJECT_STATUS_VALUES using initialSettingListState while the API call is in flight. When the API call fails or has not yet resolved, the returned state carries isFallback: true, and resolveProjectStatusLabel from @stralos-co-nz/domain humanises the raw enum values (for example, "lead" becomes "Lead") instead of echoing them verbatim. The full state object is passed to resolveProjectStatusLabel unchanged so the isFallback flag travels with the options.
Project details layout
The Project details section groups its fields into seven titled groups rendered byDetailsGroup from src/projectDetailsGroups.tsx. Each group is a <Stack as="section"> with a secondary heading:
Date fields use
DateField from src/DateField.tsx, which constrains width to 11.5rem — wide enough for a date string and no wider. The Client, Time zone, Country, and Office SelectCombobox fields each have maxWidth="32rem" so they fill a two-column grid cell up to that cap rather than truncating.
ClientContactField
ClientContactField (src/ClientContactField.tsx) renders Main contact directly after Client. The page holds clientContactId as a string, "" for none, seeded from project.clientContactId. The field receives the saved project.clientContact while the selected client is the saved one, so it can name a contact that is no longer in the active list.
- Contact list. For the selected client the field calls
GET /api/global/parties/:id/contacts(active contacts only), keyed by client id so a slower read for a previous client is ignored. A failed read shows “The client’s contacts can’t be loaded right now. The rest of this page still saves.” - Held contact. When the saved contact is not in the active list, it stays as a choice. Once the list has loaded, or when its
statusisinactive, it is labelled “(removed)” and the helper asks for another contact. - Client change.
update("clientId", …)clearsclientContactId, or restores the saved one when the client goes back to the saved client. A changed client with no contact blocks the save withCONTACT_REQUIRED_ERROR(“Please choose the client’s main contact.”). A project that has never had a contact can still save other edits. - Server refusals.
422 client_contact_requiredand422 client_contact_invalidare shown on the field as “Please choose the client’s main contact.” and “Please choose a contact from this client.” - New contact. The create row (“Add contact “name””) or New contact… opens
NewContactSheet, aSideSheetwith Name, Phone, Email and Role. A phone or an email is required, and both use api-global’s contact format rules. Add contact callsPOST /api/global/parties/:id/contactsand selects the new contact. - Wire.
clientContactIdgoes in the project PUT only when it differs from the saved value and is not empty, so an unchanged contact that has been deleted since never blocks an unrelated save.
MultipleEstimatesField and estimate settings
MultipleEstimatesField from src/projectDetailsGroups.tsx renders the Multiple estimates checkbox and its companion Manage estimates button inside the Estimates group.
State is held in multipleEstimates: { projectId, saved, value } | null. The saved property is the value as last written; value is the current draft. multipleEstimatesDirty is true when they differ. The field is loaded from getProjectEstimateSettings (GET /api/estimate/projects/:id/estimate-settings) alongside the estimate; if that call fails the field renders as unavailable with a helper saying “This setting can’t be loaded right now. The rest of this page still saves.”
When the section saves, putProjectEstimateSettings (PUT /api/estimate/projects/:id/estimate-settings) is called when multipleEstimatesDirty is true. Only the multipleEstimatesEnabled property is written. After a successful write, Stralos fires the ESTIMATE_SETTINGS_CHANGED_EVENT (stralos:estimate-settings-changed) custom event so the estimate and workspace remotes re-read the setting.
Manage estimates is enabled only when setting.saved === true && setting.value === true — that is, the setting is both turned on and already saved. Before then the button is rendered but disabled, and the helper reads “Save to turn on multiple estimates, then manage them here.” Clicking it calls openManageEstimates(projectId), which dispatches the OPEN_MANAGE_ESTIMATES_EVENT (stralos:manage-estimates) window event with { projectId }. The workspace remote’s ManageEstimatesSheet listens for it and opens over Project Settings, so managing estimates never leaves the page.
Time zone field
The Time zone field (PRD D6) lets a project follow its organisation’s time zone or override it with any IANA zone.ProjectSettingsValues holds timezone: string. An empty string ("") means follow the organisation; any non-empty string is an IANA override (e.g. "Asia/Tokyo"). On save, upsertProject sends timezone: null when the field is empty and the stored IANA identifier when it is not — the wire uses the same nullable-clear pattern as endDate and colour.
The organisation’s time zone is read from organisation-settings.timezone via getOrganisationSettings. The saved value (organisationSettings.timezone) is used directly as organisationTimezone state. If the organisation has no saved time zone, resolveEffectiveTimeZone(null, organisationSettings.timezone) applies the DEFAULT_TIME_ZONE fallback from @stralos-co-nz/base; only that fallback resolution is used, not the projected (UTC-offset-resolved) form. organisationTimezone therefore always names the zone as saved by the organisation, not as projected at the current moment.
The dropdown is a SelectCombobox driven by timeZoneChoices(organisationTimezone, currentTimezone):
- The first choice has
value: ""and label"Company default — <zone> (<UTC offset>)". - Every IANA zone returned by
listTimeZones()from@stralos-co-nz/basefollows, each with its current UTC offset viatimeZoneOffsetLabel. - If the project carries a stored override that
listTimeZones()does not include (for example, a legacy alias),timeZoneChoicesprepends it so the field never renders blank over a real value.
- No override set:
"Time zone: Company default — <orgZone> (<UTC offset>). Choose another to override it for this project." - Override set:
"Time zone: <override> (<UTC offset>), overriding the company default — <orgZone> (<UTC offset>). Choose the company default to follow it again."
listTimeZones, resolveEffectiveTimeZone, and timeZoneOffsetLabel helpers come from @stralos-co-nz/base 1.5.0 or later.
Quote Number and Job Number fields
Both fields are read-onlyText inside a FormField, rendered in a monospace font when a value is present and in body text otherwise.
The helpers are fixed text. The page no longer reads the organisation’s
jobNumberPrefix to explain a missing number, because every project now has a quote number and a job number exists only after a win.
Working week and month
The Working week and month group holds the project’s optional overrides of the organisation’s working-day defaults, used to convert weekly and monthly rates.ProjectSettingsValues carries daysPerWeek and daysPerMonth as strings. An empty string follows the organisation default. On load, organisationPeriods is set from organisationSettings.defaultDaysPerWeek ?? 5 and organisationSettings.defaultDaysPerMonth ?? 20. Each field shows that value as its placeholder and in its helper: “Company default: N days. Leave blank to inherit; a weekly rate can override this.” (“a monthly rate” for the month field).
validPeriod(value, max) accepts a blank value, or a finite number greater than 0, at most max (7 or 31), with up to two decimal places. A failing field shows “Enter more than 0 and no more than 7 days, with up to 2 decimals.” (31 for the month field), and the section’s save bar is blocked with “Enter valid working days per week and month, or leave blank to use company defaults.”
Each field has a Use company default button (xs, ghost, teal) that sets the field to "". It is disabled while the field is blank or a write is in flight. On save, upsertProject sends daysPerWeek or daysPerMonth only when it differs from pristineValues: null for a blank field (clearing the override) and the parsed number otherwise. Saving an unrelated detail therefore never clears a stored override.
Office and resourcing rule
The Office field (officeLocationId in ProjectSettingsValues) is the office the project runs out of. Its choices come from listOfficeLocations(), which reads every page of GET /api/global/locations?owner=ours&status=active and keeps { id, name }. Only the organisation’s own active locations appear; supplier yards belong to the pricing catalogue, not here. The helper text says the field is not the pricing location under Rate preferences. withSelectedOffice appends the project’s current officeLocation when it is no longer in the active list, so the field never renders blank over a real value.
The office list loads with the page’s other reads but its failure is caught separately. When it fails, offices is null, the helper reads “Your offices can’t be loaded right now. The rest of this page still saves.”, and the rest of the page is unaffected.
There is no wire-level clear. The field is marked required once the project has an office, and a metadata save with no office chosen is blocked with OFFICE_REQUIRED_ERROR (“Choose the office this project runs out of before saving.”). officeLocationId is sent to upsertProject only when it differs from pristineValues and is not empty, so saving unrelated fields never re-asserts an office that has since been retired. A 422 office_location_invalid shows OFFICE_INVALID_ERROR (“That office can’t be used any more. Choose another of your offices.”) on the field.
Below the field, a Resourcing rule box holds a Switch for preferResourcesFromOffice, headed “Prefer resources from {office}”. officeName resolves the office from the loaded list or the project record, falling back to “the project’s office”. The rule is advisory: it is stored and shown, but nothing allocates resources from it yet, and the description says so (“Recorded for the team; nothing is allocated automatically yet.”). The value is sent only when it changes, and a project with no stored value loads as true.
Site location
The project details section includes a site location sub-form backed byLocationPicker from @stralos-co-nz/maps-ui 0.9.1. The address search field is labelled Address search. This is a separate package from @stralos-co-nz/ui; it is listed as a direct dependency in package.json and bundled into this remote.
LocationPicker is mounted with mapDisplay="collapsible" and entityLabel="project". The Country field (a SelectCombobox driven by COUNTRY_OPTIONS from src/countryOptions.ts) controls the countryCodes prop: the selected ISO 3166-1 alpha-2 code is passed in lowercase, restricting geocoding search results to that country. When the user picks a geocoded result, the returned countryCode overwrites the Country field (uppercased). Country options are rendered using Intl.DisplayNames with the en-NZ locale and sorted alphabetically.
The Country field defaults to NZ for new projects (sourced from project.geoTag.countryCode ?? "NZ" on load).
The location fields are stored on the project’s geoTag and written to upsertProject with the other metadata. Validation rejects coordinates outside ±90° latitude or ±180° longitude; both fields must be finite numbers before save enables.
@stralos-co-nz/maps-ui is bundled inline alongside @stralos-co-nz/ui and @stralos-co-nz/domain. The Google Maps API key is supplied by the shell at runtime; it is not a build-time environment variable. Vitest inlines the package (see server.deps.inline in vite.config.ts) so tests load it through the bundler path rather than Node’s ESM loader.
The Client and Project name fields are both required and carry the required asterisk and native required attribute. The Client reference field is optional and carries no “Optional.” prefix (required fields are marked; optional fields are not). The Client, Country, and Contract SelectCombobox fields each receive required passed through to the combobox input so the attribute reaches assistive technology (a FormField required alone sets data-required on the wrapper but not required on the underlying input).
The Override status control is a SideSheet (not a centred dialog). It carries confirmOnDirtyClose and reads isDirty from adminReason.trim().length > 0 at event time, so a stray Escape or an outside-click that occurs after the admin has typed a reason prompts for confirmation rather than silently discarding the audit text. While the write is in flight, suppressDismiss prevents closing the sheet. The reason field is marked required on the FormField component.
Search distance
SearchDistanceField (src/SearchDistanceField.tsx) renders the Search distance group: how far from the project pricing looks for suppliers. The counting, default and wording live in the pure module src/searchDistance.ts (no React, no fetch). src/searchDistanceApi.ts owns the reads and the one write. The same rules are written separately in stralos-feature-workspace’s post-create Suppliers for this project step; there is no shared package, so change both.
Picker. A radio group offers RADIUS_PRESETS (5, 10, 20, 35, 50, 100, 150 km) and Other with a free-text input (accessible name “Other distance in km”). parseOtherRadius accepts 1 to 2000 km (OTHER_RADIUS_MIN_KM, OTHER_RADIUS_MAX_KM) with at most one decimal place; anything else shows OTHER_RADIUS_ERROR (“Enter a distance from 1 to 2000 km, with at most one decimal place.”) below the input and as the save bar’s blocked reason. api-global accepts any value greater than 0, so the 1 km floor is the UI’s.
Default. displayedChoice(draft, saved, measured) shows the person’s pick, else the saved radius, else suggestedRadius(measured): 50 km if any measured site is within 50 km, else 100 if any is within 100, else 150; 50 when nothing could be measured. Only the person’s pick is a draft to save. Each preset radio fires on click as well as change, so clicking the already-selected suggestion chooses it. With nothing saved, the description reads NOT_CHOSEN_TEXT (“Not chosen yet. Pricing looks within 50 km, then 100, then 150.”); otherwise “Saved: N km.”
Measuring. When the saved project coordinates are usable (coordinatesUsable; 0, 0 is the editor’s empty value and is not a place), the field calls loadSearchDistance(projectId):
GET /api/rates/rate-siteslists the distinct supplier and site pairs with live rates, across every rate group. A row with a nulllocationIdis an All locations price.measureTargetssplits them intolocationIdsand, for All locations prices,partyIds.POST /api/global/projects/:id/location-routes/resolveruns withpartial: true,straightLineKm: 150(MEASURE_STRAIGHT_LINE_KM) andalsoNearest: 3(MEASURE_ALSO_NEAREST), in batches of at most 200 IDs of each kind (RESOLVE_BATCH_SIZE), one after another.partyIdsexpands each All locations supplier to all its active sites. Routes are cached server-side for 90 days.GET /api/global/locations?owner=theirsandGET /api/global/parties?role=suppliersupply the site and company names.
buildSearchDistance turns the results into measured sites (road distance, nearest first), noAddress sites (unroutable reason no_coordinates) and a routingUnavailable flag. The 150 km straight-line pre-filter is fixed and does not use the saved radius.
What it shows. A polite live region carries one sentence from summarySentence: the distinct supplier count within the chosen radius (supplierCount counts suppliers, not sites), or “No suppliers within N km of this project. The nearest is …”, or NO_RATES_TEXT when there are no supplier rates. A list named “Sites within reach” shows each site within the radius as “Company — Site · N km” (siteLine). Sites without an address are counted in noAddressSentence and named when there are at most NO_ADDRESS_NAME_LIMIT (6).
Address states. With no usable form address the field shows ADD_ADDRESS_TEXT (“Add the project’s address first to see the suppliers near it.”). With a form address but no usable saved one it shows “Save the project to see the suppliers near this address.” When the form address differs from the saved one it adds “These distances are from the saved address. Save the project to measure from the new one.”
Failures. A failed load, or any routing_unavailable site, shows MEASURE_FAILED_TEXT (“We couldn’t measure the distances just now. You can still choose a distance.”) and a Try again button that re-runs the load.
Draft and save. searchRadius state in ProjectSettingsPage.tsx holds { projectId, saved, draft }, scoped to the project ID so it never carries across projects. draftChangesSaved(draft, saved) makes the section dirty (an unusable Other counts, so it must be fixed). On save, putSearchRadius(projectId, km) writes PUT /api/global/projects/:id/search-radius before upsertProject, after the Multiple estimates write, so a failed details save never leaves the distance half-applied. A failed distance write returns “The search distance could not be saved.” (or the server’s sentence) and the details are not sent. putSearchRadius always calls forgetSharedProject, because the project DTO carries searchRadiusKm. After a save that moves the project, the page bumps searchMeasureRun, which the field takes as measureRun and measures again.
Hours per working day
The Hours per working day field reads its value from the project’s primary estimate viaapi-estimate, loaded independently of the main project record through useProjectReadiness<ProjectEstimate> from @stralos-co-nz/ui. The estimate load runs in parallel with the rest of the page so an api-estimate outage costs only this field, never the page load or saves.
The field is always read-only on this page (PRD 210 §12.2). Each estimate owns its own working day; the value is changed on the estimate, not here. When the estimate has loaded (workingDayLoaded), the field shows the primary estimate’s hoursPerDay value alongside an Edit on the estimate button. The button links to /projects/:projectId/estimates/:estimateId?edit=settings, which opens the estimate’s settings sheet focused on the hours-per-working-day field. The field contributes no dirty state to the save bar.
The display moves through these states based on estimateReadiness.state:
The
preparing state is triggered by a 409 project_not_provisioned response. unavailable covers network errors, 5xx responses, and estimate-level 404/403 responses. A 404 or 403 from api-estimate is treated as unavailable rather than surfaced as “Project not found”, because the page’s own project load is the authority on access.
classifyEstimateRead in ProjectSettingsPage.tsx wraps classifyProjectReadinessError from @stralos-co-nz/domain and remaps not-found and no-access outcomes to unavailable.
@stralos-co-nz/ui 0.73.1 changed SideSheet to open non-modal on md screens and wider when the AI assistant is open, so both panels are usable side by side. On smaller screens the sheet continues to open as a modal overlay. This affects all three sheets on this page: Override status, EditRulesSheet, and ChangeReviewSheet.
getProjectEstimate in src/api.ts accepts an optional AbortSignal so useProjectReadiness can abort a stale polling check on unmount or when a new projectId is set.
The ProjectPreparing component is mounted in every state including ready. This keeps the component’s ARIA live region in the DOM from the first render; a region mounted late loses its first announcement to assistive technology.
ProjectScopeCard
src/ProjectScopeCard.tsx (lazy-loaded) renders the Project scope section (scope) and registers with useSettingsSection under scope "Project scope". ProjectSettingsPage renders a SettingsSaveBar for that scope below the card.
Saving. The scope is sent in the project’s own PUT /api/global/projects/:id body. savedProjectBody(project) builds that body from the saved project and returns null when the client, name or coordinates are missing, which blocks the save with PROJECT_SCOPE_NEEDS_DETAILS. A scope longer than RICH_TEXT_MAX_CHARACTERS (50,000) blocks with PROJECT_SCOPE_TOO_LONG. The server’s 422 project_scope_too_long and 422 project_scope_invalid map to PROJECT_SCOPE_TOO_LONG and PROJECT_SCOPE_INVALID; anything else falls back to “The project scope could not be saved.”
Create scope with AI. The page passes estimateId and estimateName from the estimate it uses for rate preferences. With no estimateId the button is disabled and the helper reads “The project’s estimate must be available to create a scope with AI.” With unsaved edits, a small SideSheet (“Replace your unsaved scope?”, primary Create AI draft) confirms first. generateProjectScopeDraft in src/projectScopeAi.ts:
- Reads every work item and section of the estimate (
GET /api/estimate/estimates/:id/work-itemsand/sections, 200 a page, in parallel). No items throws “Add estimate items before creating a scope with AI.” - Calls
POST /api/documents/projects/:projectId/tender-scope-draftwith{ projectName, items: [{ description, section, quantity, unit }] }. Quantities are numbers ornull; no prices are sent.
{ scope, documentsUsed, documentsArePartial }. scope becomes the editor draft, and documentsUsed drives the status line (“Draft based on estimate items and partial excerpts from: …” or “Draft based on estimate items only; …”). While generating, the editor is read-only and the save bar is blocked with “Please wait for the AI scope draft, or cancel generation.” Cancel generation, a project or estimate change, unmount, and Discard abort the request; an aborted result is dropped and sets no error. A failure shows userFacingError with “The AI couldn’t create a scope draft. Please try again.” as the fallback, and the editor keeps its content.
ContractBasisCard
Loads and saves the contract profile for a project (GET/PUT /api/global/projects/:id/contract-profile). The card loads the existing profile before rendering any inputs; if the load fails, it shows an error and a Retry button — the form is not editable until the load succeeds, to prevent an uninformed save from clearing stored data.
Contract form options are defined in src/contractBasis.ts as CONTRACT_FORM_GROUPS and are searchable. The “Custom / heavily amended form” option requires a non-empty label (contractFormLabelError validates this). Overhead percentages are validated with parseOverheadPercent (0–100, up to three decimal places, blank clears).
The card registers its dirty state with the page-level SettingsDraftProvider via useSettingsSection. The card has no footer of its own; a SettingsSaveBar scoped to "Contract basis" is rendered in the contract-basis pane by ProjectSettingsPage, outside and below the card.
Validation error timing for contract form
The “Select the contract form.” inline error and the matchingblockedReason passed to SettingsSaveBar are gated on the section’s dirty state. The ContractBasisFields component accepts a showRequired prop (default true); ContractBasisCard passes its own isDirty flag as that prop. On a new project with no saved contract profile, the card starts clean, so showRequired is false and the error does not appear until the user edits the section. Once any field in the section is changed, isDirty becomes true, showRequired flips to true, and the missing contract form is treated as a blocking error in the usual way.
RatePreferencesCard
RatePreferencesCard loads GET /api/rates/project-rate-settings?projectId=…&estimateId=… and the supplier, location, category, and rate-item catalogues. Each estimate keeps its own preference list. The card works on one estimate at a time; which one is controlled by the ?estimateId= query-string parameter set by ProjectSettingsContent.
Estimate picker (PRD 210 phase 2). RatePreferencesCard receives estimateId, estimateName, and estimateChoices as props. When estimateChoices is non-null (the project has more than one live estimate), a SelectCombobox (accessible name “Estimate”) appears at the top of the card. Selecting a different estimate updates the URL parameter (setSearchParams, replace: true) and remounts the card via a key change, which re-reads the list for that estimate. A single-estimate project passes estimateChoices: null and shows no picker. With a picker, the card’s description names the estimate: “Where the {estimateName} estimate prices from and which sources its lines prefer, in order. Each estimate keeps its own list.”
Any estimate’s list can be edited here, not only the primary’s. Every write (saveProjectRuleSet, deriveProjectRateContext) sends estimateId to api-rates unconditionally. There is no fallback for a server that refuses the key.
The supplier, location, category, and rate-item catalogues are read once per page visit, not on every card mount. ProjectSettingsPage creates one ratePreferenceCatalogueCache (src/api.ts) around getRatePreferenceCatalogues, and the card reuses that read when it remounts on a section or estimate switch. The kept read is tied to the signed-in organisation and user (sharedReadScope), so a different user starts a fresh read. A failed read is not kept, so a retry issues a new request.
It renders the full ordered preference list in a read-only DataTable: the pricing-location entry at its stored context.position, followed or preceded by rules according to their positions. The first matching entry with an eligible option prices a line. Edit rules opens EditRulesSheet; all changes to the location or rules go through that sheet.
The DataTable includes a Distance column populated by evidenceSentence(rule) from src/ruleText.ts for each rule row (location entries always show "—"). The function reads rule.source and rule.evidence from the Rates API payload:
- Source rule pointing to a specific place (
source.locationIdnon-null): ifevidenceis present, shows"<km> km · <drive time>"(e.g."4.9 km · 12 min"or"212 km · 2 h 38 min"). Ifevidenceis absent, shows"— no route to this place yet". Ifevidence.staleistrue, the string is suffixed with" · stale". - Source rule pointing to an owner’s All locations (
source.locationIdnull): shows"Available at all locations". An All locations price applies everywhere, so it has no single distance. - Policy rules (
rule.sourcenull) show"—".
formatDriveTime(minutes) in src/ruleText.ts, which rounds to whole minutes: under 60 minutes renders "N min", and 60 minutes or more renders "N h" or "N h M min". The pricing-location summary uses the same formatter.
The card suppresses its drift check for a frozen estimate. For an active estimate, it calls previewRuleSet with the stored entries and detail: "counts" once per project and estimate. A count of out-of-date lines, or a pending re-price marker in localStorage (stralos.projectSettings.pendingRuleReprice.<projectId>.<estimateId>), shows Review and re-price. The legacy key stralos.projectSettings.pendingRuleReprice.<projectId> (which stored the estimate id as its value) is also read on load and cleared when the matching estimate’s re-price completes, so a pending marker in the older format is not lost. That action previews the stored list with detail: "lines" and opens ChangeReviewSheet without a rule-set write.
Saving a draft with no estimate or a frozen estimate writes the list directly. With an active estimate, previewRuleSet(estimateId, entries, "lines") runs before any write. If groups contains affected lines, ChangeReviewSheet opens; cancellation leaves the draft unsaved. If no line changes price, the list saves directly. A quiet re-stamp may then use rule-change-apply with empty apply, pin, and unpin arrays under the preview digest. The rule-set write is the commit point; re-pricing follows it. The pending marker remains when re-pricing fails or its completion is uncertain.
Re-apply rate preferences… routes to /projects/:projectId/estimates/:estimateId/builder/rates/re-apply when an estimate is named, or /projects/:projectId/estimate/builder/rates/re-apply when none is set. It is unavailable when there is no estimate or a write is in flight.
EditRulesSheet
src/EditRulesSheet.tsx renders a SideSheet (size="xl") containing a DraftEntry[]. draftFromPayload inserts the pricing-location entry at context.position (position 1 by default) among rules sorted by their stored positions. The location entry is reorderable but cannot be deleted; source: "none" removes its pricing effect. Rules can be added, edited, removed from the draft, and reordered by drag or Move up / Move down. insertionIndex places a new rule by specificity as a starting position, subject to user reordering. entriesFromDraft validates and serialises the complete ordered list used by this frontend. Rule kind is either source (whose price and where, picked via RuleTargetControl using RateLocationPicker from @stralos-co-nz/ui-reference-data 0.3.0) or policy (cheapest within a radius in km). Both kinds save and re-price end to end via the Rates and Estimate services.
The sheet previews the draft after a 400 ms debounce with previewRuleSet(estimateId, entries, "counts"). It shows the projected line count and per-entry PreferenceNote where a prior entry shadows a rule. A rule form updates dependent fields when its scope or target type changes and announces a live sentence explaining the draft. Save is the only write action. Closing a dirty sheet prompts before discarding it.
ChangeReviewSheet and ReviewList
ChangeReviewSheet is the shared SideSheet (size="xl") for an edited rule list or a pending re-price. ReviewList flattens the hierarchy rate group → rate item → estimate schedule line into a virtualised list. Its tri-state group selection is computed from the complete data set, including rows outside the viewport. Large reviews start with groups collapsed.
Re-priced lines start ticked. Unticking a line pins it to its current option, or to an eligible alternative selected for its rate item. Existing pinned lines start unticked; ticking one unpins it and takes the rules’ default. Managed companion removals follow their parent line. decisionsFor builds the atomic apply body as apply, pin, and unpin arrays; there is no componentIds or keepPinned wire field.
The sheet saves the ordered list with PUT /api/rates/project-rate-settings/rule-set, then calls POST /api/estimate/estimates/:id/rule-change-apply using the preview impactDigest and the three decision arrays. A 409 stale_rule_change_preview refreshes the review instead of applying stale decisions. A failed or uncertain post-save apply keeps the pending re-price visible. A RuleSaveUnconfirmedError triggers read-back before the UI claims the list was saved.
DayRatesCard
src/DayRatesCard.tsx renders the Day rates section (day-rates). It lists the day-rate schedules the project’s estimates pick from, each pinned to one revision. Add, Remove, and Update apply as soon as they are confirmed: the card has no save bar, is not a settings section, and does not register with SettingsDraftProvider (the 2026-10-04 settings ruling for panes that save their own way).
Loading. On mount the card loads, in parallel, the project’s set (getProjectDayRateSet, GET /api/rates/projects/:projectId/day-rate-set) and the organisation’s schedules (listOrganisationDayRateSchedules, GET /api/rates/day-rate-schedules with today as the in-force date). Both share a load token, so stale responses from a previous project ID or an earlier load are dropped. If the set fails to load, the card shows an error and a Retry button. If only the schedules fail, the set still renders and the schedules error is shown separately.
sortSet in src/dayRateSet.ts orders the set: client schedules before organisation schedules, then alphabetically by client and schedule name. Each row shows Automatic (origin === "auto") or Added, an Archived badge when archived, the owner, and the pinned revision.
Access. The gate is advisory: any project member may attempt a write. A 403 from api-rates on any write turns the card read-only for the session, with “You can view this project’s day rates, but not change them.”
Writes. All writes run through a shared write helper: one write in flight at a time (busy), actionError cleared at the start, an error toast on failure, and a reload() of the set afterwards.
- Add.
addableChoicesbuilds the combobox (accessible name “Add schedule”, placeholder “Choose a schedule to add”): active schedules with a revision in force that are not already in the set. The project’s own client’s schedules come first (rank 0), then other clients’ (rank 1), then organisation-wide (rank 2). When nothing is left it shows “Every active schedule is already in this project.” Add callsPOST /api/rates/projects/:projectId/day-rate-setwith{ scheduleId }. A409 day_rate_schedule_already_in_setis swallowed (the outcome asked for already holds). A422 day_rate_schedule_not_in_forcesurfaces as “That schedule has no revision in force today, so it can’t be added yet.” and a422 day_rate_schedule_archivedas “That schedule has been archived, so it can’t be added.” Other failures useuserFacingErrorwith the caller’s fallback. - Remove. Opens a
ConfirmDialognaming the schedule. While it is open,getDayRateUsage(GET /api/estimate/day-rate-usage?scheduleId=…&revisionId=…&projectId=…) counts the build-up lines in this project’s in-progress estimates that use the pinned revision; a failed count shows a fallback sentence. Confirming callsDELETE /api/rates/projects/:projectId/day-rate-set/:scheduleId. A404is treated as success (the row was already gone). - Update. When a row’s
newerRevisionis non-null, an Update button opensDayRateUpdateDialogwith the schedule ID, name, and revision reference. The card reloads the set when the dialog closes withchanged: true.
detectClientChange in src/dayRateSet.ts treats a schedule the set took in automatically (origin === "auto") for a client other than the project’s current client as evidence that the client changed. A schedule a user added from another client (for example a head contractor’s) never prompts. The card passes the project’s saved clientId and clientName, not a partly edited draft from the details form. The prompt is an orange-bordered Client changed section whose heading depends on the state:
- New client has schedules not yet in the set: “The client changed. Add {client}‘s schedules?” — with an Add button that adds each of them (
addProjectDayRateSchedule) in one write action. - All of the new client’s schedules are already in the set (
newClientInSet): “The client changed. All of {client}‘s day-rate schedules are in this project.” - New client has no schedules in force: “The client changed. {client} has no day-rate schedules in force.”
DayRateUpdateDialog
src/DayRateUpdateDialog.tsx is the “Update estimates using {schedule}?” dialog (PRD §4.5), scoped to one project (R17). feature-rates opens the same dialog across every project after an admin publishes a revision; this copy is self-contained in this remote. On open it calls getDayRateUsage for the project and schedule and lists this project’s in-progress estimates. Each row shows the estimate’s name, type, status, line count, current total, new total, and difference.
Each tick box’s accessible name comes from tickLabels(estimates): “Update {name} ({type})”, where type is “Tender” or “Delivery”. The type is always included because a Delivery estimate is copied from the won tender and may share its name. If two rows still produce the same label, each is suffixed with its position among its duplicates (”…, 1 of 2”), so no two tick boxes share an accessible name.
Nothing is ticked on open, and Skip writes nothing (R3, R15). Update is disabled until at least one estimate is ticked, except when the project has no in-progress estimates; then it is enabled straight away and moves only the project’s revision pin. Ticking an estimate with no lines from the schedule also moves only the pin, so future picks use the new rates. An edited line keeps its edited rate.
Update calls applyDayRateUpdates → POST /api/estimate/day-rate-updates with { revisionId, estimateIds } for the ticked estimates, or { revisionId, estimateIds: [], projectIds: [projectId] } for a project with no in-progress estimates. The dialog then shows each estimate’s outcome with resultLabel ("updated" or, for example, "skipped: it was submitted in the meantime") and resultDetail (lines moved, kept edited, and no longer in the schedule). Closing calls onClose(results != null), so the card reloads the set only when an update ran.
If the revision’s effectiveFrom is in the future, the dialog says that updated estimates take the new rates straight away rather than waiting for the effective date.
SharedDriveCard
src/SharedDriveCard.tsx renders the Shared drive section (shared-drive), the last entry in the rail. It lists this project’s drive mappings and, for an organisation admin, maps a drive folder to this project. Rows, empty-state sentences, and the mapping sheet come from @stralos-co-nz/drive-sync-ui (DriveMappingRow, DriveMappingSheet, driveSyncCopy), the same package Global Settings uses, so the two entry points match.
On load the card reads the drive-sync switch (readDriveSyncEnabled) alongside listDriveSources and listProjectConnections. It renders nothing until the first read settles, and nothing at all when the switch returns false. An unknown switch (a failed read, or a server that predates the switch) keeps the card. A failed list read shows an error with Retry.
Map a folder opens
ProjectMappingSheet, a thin wrapper around DriveMappingSheet with projectId fixed, so the project is shown read-only. It uses satisfies DriveMappingSheetProps so drift between this client’s domain shapes and the package’s props fails at the call site.
Row actions run through runRowAction, which marks the row busy, calls the API, reloads the lists, and posts a sentence (“Sync requested”, “Mapping paused”, “Mapping resumed”, “Mapping removed”) to the page’s status region through onAnnounce. A failure renders as a role="alert" line below the list.
isAdmin is an advisory gate: it hides the mapping, pause, resume, and remove controls, while api-documents enforces them with requireAdmin.
SettingsDraftGuard
SettingsDraftProvider maintains two registries, both keyed by a stable React useId. The provider coordinates the navigation blocker; it does not render a save bar — each section’s pane renders its own SettingsSaveBar.
- Section registry — full-page sections that participate in the shared save bar. A section registers via
useSettingsSection, supplying itsscopename,dirtyflag, an optionalblockedReasonsentence (prevents saving), andsave/discardcallbacks. The save bar reads this registry. - Draft registry — editors that save their own way and only need the navigation blocker, not the save bar. They register via
useSettingsDraft(scope, dirty). The one user today isEditRulesSheet(scope"Rate preferences"), which has its own Save.
dirtyScopes from the union of both registries. When at least one scope is dirty, it attaches a beforeunload handler and (when a data-router is present) mounts RouteDraftGuard.
RouteDraftGuard calls useBlocker from react-router-dom to intercept same-app navigations. The blocker fires when dirtyScopes is non-empty and either pathname or search changes — this covers rail section switches, cross-page navigations, and browser Back/Forward. A Dialog opens with three actions:
- Save changes (primary): calls
saveSectionsfor the dirty sections in registration order, then callsblocker.proceed()only if all saves succeeded. If a save fails, the dialog stays open with the failure message; nothing is lost. - Discard changes (destructive): calls each dirty section’s
discard()callback, thenblocker.proceed(). - Keep editing (cancel): calls
blocker.reset().
blockedReason on a dirty section also disables Save changes, prefixed with the section name.
Only blocker.proceed() / blocker.reset() from the dialog callbacks advance or cancel the navigation — the dialog never navigates itself.
SettingsSaveBar
SettingsSaveBar is a Discard + Save changes pair scoped to one section. It takes a scope prop (the section’s display name) and reads only the sections whose scope matches from the section registry.
- Shows Unsaved changes when the section is dirty, and No unsaved changes when it is not.
- Disables Discard and Save changes when the section is not dirty.
- Disables Save changes (but not Discard) when a dirty section reports a
blockedReason. The reason is prefixed with the section name (e.g.Project details: {reason}). - On Save changes, calls each dirty entry’s
savecallback in registration order. On full success, posts a toast:{scope} saved. On failure, shows the result inline (e.g. “Nothing was saved. .” or “Saved . Not saved: . ”). - The alert region that holds the outcome message is mounted from the first render so assistive technology announces it when it first receives text.
SettingsSaveBar is rendered at the bottom of the Project details pane (scope "Project details"), the Project scope pane (scope "Project scope"), and the Contract basis pane (scope "Contract basis"). Rate preferences (saved from EditRulesSheet), Day rates, and Shared drive have no section save bar.
API layer
Cross-remote shared reads (NET-01/02)
getProject and getOrganisationSettings in src/api.ts route their reads through sharedRead from src/sharedReads.ts. This keeps a small in-memory store on globalThis under the key __stralosSharedReadsV1, using the same pattern as __stralosPosthog, so a read issued by one remote (for example, the workspace entry-check or the onboarding widget) answers this remote’s mount read without a second network request.
The store is keyed by ${organisationId}:${userId} (the scope, derived from src/sharedReadScope.ts) and the request path including query string. Entries expire after 30 seconds. Behaviour details:
- A scope of
null(no signed-in user) bypasses the store entirely; the load function is called directly. - A successful read is stored. A rejected load is never stored; a caller that joined a read that then rejects re-issues its own load, so every participant sees its own error class and HTTP status.
- Pass
force: true(or callgetProject(id, { fresh: true })) for a read that must hit the network — after a write, or to resync on a failure. A forced read still refreshes the store. - After a write, call
invalidateSharedReads(scope, urlPrefix)for the affected URL prefix.forgetSharedProjectinsrc/api.tscalls this afterupsertProjectandtransitionProject. - Values are
structuredCloned per caller, so mutating a result is safe.
V1). To change the contract, bump the key to V2 in every participating remote so mixed deploys bypass each other’s entries rather than reading mismatched shapes.
src/sharedReads.ts is copied verbatim into each participating remote. Never edit one copy in isolation. Each remote also exports resetSharedReadsForTests as a test seam; every test file starts with an empty store.
Runtime URL resolution
Each service module resolves its base URL through the following precedence chain (highest to lowest):- Vite environment variable (
VITE_STRALOS_GLOBAL_API_URL/VITE_STRALOS_API_URL,VITE_STRALOS_RATES_API_URL,VITE_STRALOS_ESTIMATE_API_URL,VITE_STRALOS_DOCUMENTS_API_URL). window.__STRALOS_API_URLS__— a runtime seam the shell host injects with{ global, rates, estimate, documents }URLs.localStorage.getItem("stralos.<module>ApiUrl")— a per-module override for local development.sameOriginFallback(defaultApiUrls[module])— in a production build, returnswindow.location.origin; under Vite dev it returns the hard-coded localhost default (global: 3300,rates: 3301,estimate: 3303,documents: 3304).
Endpoints
Global service (api-global, port 3300)
The contract profile
GET endpoint is 404-free: when no profile exists for a project it returns a default payload with exists: false. A PUT upserts.
Rates service (api-rates, port 3301)
PUT /api/rates/project-rate-settings/rule-set accepts { projectId, estimateId?, expectedSettingsUpdatedAt, entries: RuleSetEntry[] }. estimateId names the estimate whose list is being saved; omitting it targets the primary. The entries array is the full ordered list: a single { kind: "location", source, locationId } entry plus any number of { kind: "rule", id?, scopeKind, scopeRef, ...selection } entries. A rule with an id is updated; one without an id is created; a live rule absent from the array is soft-deleted. Returns the full updated ProjectRateSettingsPayload.
POST /api/rates/project-rate-settings/derive-context similarly accepts { projectId, estimateId?, dryRun: true }.
GET /api/rates/project-rate-settings accepts estimateId as an optional query parameter; omitting it returns the primary estimate’s settings.
Error responses from the rule-set write: 409 settings_changed (another save landed since the read, so nothing was written), 409 derived_context_changed (the auto-derived location changed; the body carries fresh settings).
Rates service (api-rates, port 3301) — day-rate set
Estimate service (
api-estimate, port 3303)
GET /api/estimate/projects/:id/estimates returns the project’s live estimates (tender and delivery; not saved versions and not estimates hidden at award). The response envelope may be a page { items: [...] } or a bare array — listProjectEstimates in src/api.ts handles both forms. A 404 from this endpoint (api-estimate older than PRD 210) is caught silently; the page falls back to working on the primary estimate alone.
Documents service (api-documents, port 3304)
The drive-sync calls live in
src/documentsSyncApi.ts and use the same apiGet / apiSend helpers as src/api.ts. Reads and sync-now are open to any organisation member; folder browse and the mapping writes require an organisation admin (requireAdmin in api-documents’ syncAdmin routes).
Error handling
API failures surface anApiError instance carrying the HTTP status and the server’s code field. The ProjectSettingsPage catches the end_date_before_start_date code (HTTP 422) from upsertProject and renders the message inline on the End date field in addition to the toast. All other errors fall back to error.message or a static string.
Stale-status conflicts on status transitions (409 stale_status) trigger a refetch before toasting, so the badge and action bar reflect the server’s current state before the user sees the error.
User-facing error text (userFacingError)
Error messages shown on screen are produced by userFacingError(cause, fallback) in src/errorText.ts. A person sees the server’s own sentence, never the API URL or HTTP status line.
The function distinguishes three cases:
- Not an
Error, or aTypeError(network-level failure such as “Failed to fetch”) — returns the caller’sfallbackstring. - An error whose message does not match the transport prefix — the message is already a human-readable sentence (one the remote itself authored); it passes through unchanged.
- A transport-shaped message (matches
API request failed with status \d+(?: for \S+)?(?: - …)?) — extracts the server’s sentence from the message tail. If theApiErrorcarries abody.referencestring, the returned text is"<sentence> Reference: <id>".
503 busy-GET retry
When aGET request returns 503 with a Retry-After header containing a whole-number value of 10 seconds or fewer, fetchHonouringRetryAfter in src/api.ts waits that many seconds and issues one automatic retry. The retry is silent — no loading-state change is visible to the user.
Writes (POST, PUT, DELETE) are never retried automatically. A 503 on a write means the server may or may not have committed the operation; replaying it is not safe. A Retry-After value greater than 10 seconds is ignored and the 503 is surfaced as an error.
Auth
The access token is read fromlocalStorage (stralos.auth.accessToken). On a 401 the client calls window.__STRALOS_AUTH__.refreshAccessToken() (the shell auth bridge) and retries once. If the bridge is absent (standalone mode without the shell), it re-reads the stored token. A still-401 after the retry ends the dead session and redirects to /login?reason=expired&from=….
Ending the dead session is required before the redirect. The shell’s /login route considers a session live on presence alone — if stralos.auth.accessToken and stralos.auth.user are both non-null in localStorage, the login page sends the visitor straight to the default route, ignoring reason=expired. Without clearing first, the user lands back on the page that just failed and the redirect loops. endDeadSession() calls window.__STRALOS_AUTH__.clearSession() when the host bridge is available; if the bridge is absent or throws, it removes the three stralos.auth.* keys directly before the redirect runs.
The first-401 path (where token refresh itself fails) is left alone. The shell’s performRefresh clears the session before resolving null, so this remote does not duplicate that step.
Admin-only controls (the Override status button) are gated by reading localStorage["stralos.auth.user"] and checking user.role. This check is advisory; the server enforces the admin restriction with a 403.
Project request events
authedFetch passes responses to observeProjectRequest, which fires a stralos:project-request CustomEvent for a successful GET or HEAD, a successful non-OPTIONS mutation, or a 403/404 response. Other outcomes do not emit an event. The workspace shell listens for eligible outcomes to revalidate project access without waiting for the user to navigate. No response body, bearer token, or project payload is carried in the event.
The event detail is versioned:
path is window.location.pathname at the time the request was made. The event is only dispatched when the path has not changed by the time the response arrives — a navigation that happened mid-flight suppresses it. The kind values are:
Successful reads are reported because RLS-filtered list endpoints may return
200 with no rows after access is removed. The workspace shell uses all three kinds to decide when to revalidate project access.
The event does not replace the existing 401 flow. A 403 or 404 fires "refused" and throws an ApiError; the workspace shell handles revalidation. A 401 still triggers the token-refresh and session-clear path described above.
CI workflows
Package compatibility check
build-remote.yml runs scripts/check-package-compat.mjs before pnpm install as the step “Refuse a known-incompatible package pair”. It reads the resolved versions from the committed lockfile (.ci/workspace/pnpm-lock.yaml) and fails the build if a known-incompatible pair is present.
The current rules refuse a lockfile that resolves @stralos-co-nz/ui >=0.57.0 alongside @stralos-co-nz/maps-ui <0.3.0, or ui >=0.73.0 alongside maps-ui <0.5.0. ui 0.57.0 removed SupplierEditorModal, which maps-ui 0.1.x and 0.2.0 import; ui 0.73.0 removed rate-reference exports that maps-ui 0.4.0 and earlier import. Because ui is not a Module Federation singleton (each remote bundles it whole), pnpm’s peer resolution does not catch this pairing at install time; the lockfile is the only place to enforce it.
The check runs on the runner’s pre-installed Node before any install step. When a new ui release removes something a published maps-ui version imports, add an entry to the INCOMPATIBLE array in scripts/check-package-compat.mjs. The file is byte-identical across the estimate, workspace, project-settings, and organisation remotes — update all four.
To resolve a failure: move maps-ui forward to a version that is compatible with the pinned ui (or hold ui back), then regenerate .ci/workspace/pnpm-lock.yaml.
Produce for dev (deploy-dev.yml)
deploy-dev.yml runs automatically on pushes to main and also supports manual dispatch for an explicit ref. It calls build-remote.yml with deploy_dev: true, then runs two publishing jobs:
publish-by-sha— writes the deterministictar-v1component archive to the durable artefact store usingputWriteOnce. A re-dispatch for a sha that is already stored is safe: identical bytes are resumed; byte-different bytes fail closed.publish-remote-dev— synchronisesdist-remote/to the by-SHA prefix in the dev remotes bucket. It does not update the live manifest.
gate-dev in stralos-core verifies a compatible remote set and activates it. A successful produce run makes the commit available for activation and stable release; it does not prove that dev is serving it.
build-remote.yml also runs the size regression gate (step “Refuse a size regression over 10 % (thin-host D7)”). It judges the artifact’s JS gzip total against the base branch’s .ci/remote-size-baseline.json. A PR that grows the bundle by more than 10 % is blocked unless the pull request body contains remote-size-baseline: <new-value> and the committed baseline is updated to match. Run node scripts/remote-size.mjs measure locally for a per-file breakdown. The build itself sets ROLLDOWN_WORKER_THREADS=1 to serialise graph traversal for a reproducible size measurement.
archive-remote.mjs CLI
The produce step is driven by scripts/archive-remote.mjs produce. Its accepted options are:
--directory, --bucket, --remote, and --sha are required. All four options in brackets are optional: --built-against-ui and --built-against-domain are ignored under federation contract v2 (the record is derived from the contract file instead); --vite-config defaults to vite.config.ts and --federation-contract defaults to .ci/shell-federation-contract.json. If a required option is missing, the error message lists every option the subcommand accepts.
The release subcommand is retired in this repository. Do not use it.
Test production
deploy-test.yml publishes the projectSettings remote at remotes/projectSettings/<version>/ on a prerelease event, manual dispatch, or when called by the central release orchestrator via workflow_call. It accepts a version input in either stable X.Y.Z or release-candidate X.Y.Z-rc.N form. It uses a write-once claim to check repeated builds and occupied objects. Publication is separate from activation. See Remote test production and promotion for source selection, claim verification, and recovery.
Already-released check — Before the build starts, the already-released job reads remotes/projectSettings/by-sha/<commit>/test-version.json from the test bucket. If that pointer exists and its companion completion marker (remotes/projectSettings/<version>/complete.json) also exists, the run fails with an already-released error naming the previously produced version. Refusing before the build means the cheapest produce is one that does not happen.
A pointer without a marker means a produce died between writing the two records. That half-finished produce is logged and allowed through so a re-run can legitimately finish it.
At the end of a successful produce, two write-once records are written in order:
remotes/projectSettings/<version>/complete.json— the completion marker, written only after the destination key set is verified against the claim. It records the verified keys and sizes (not an end-to-end digest, because a multipart upload’s ETag is not the file’s SHA-256).remotes/projectSettings/by-sha/<commit>/test-version.json— the by-commit pointer, written last. It is only true if the marker is present, and the marker is only true if the version is whole.
occupied-by-different-bytes error — set the allow_second_version_for_commit dispatch input to true. The by-commit pointer is write-once and continues to name the first version.
Central release (release-create.yml)
release-create.yml is the entry point for the central release orchestrator in stralos-core. The orchestrator dispatches it at the release tag with six standard inputs: environment, version, sha, correlation_id, source_version, and promotion_digest. All six are declared even if unused, because the orchestrator sends the same set to every member remote and a missing declaration causes a 422 refusal.
The workflow behaves differently depending on environment:
test— callsdeploy-test.ymlviaworkflow_callwith the candidate version (which may beX.Y.Z-rc.N). This is the only produce step for this environment; no rebuild happens at promotion time.prod— callsstralos-core’sfrontend-release-promote.ymlwithprofile: remoteandname: projectSettings. Promotion re-keys the test bytes fromX.Y.Z-rc.NtoX.Y.Zwithout rebuilding.
result job runs after both branches (using if: always()) and calls stralos-core’s release-create-result.yml to report the outcome back to the orchestrator. The orchestrator reads the release-create-result artifact the final job leaves on success and failure.
Do not dispatch release-create.yml by hand. It does not make anything live; a create that nobody activates is only clutter. Use the central release orchestrator in stralos-core instead.
Release
The per-repositoryrelease.yml workflow and scripts/archive-remote.mjs release command are retired. Use the central release orchestrator in stralos-core. Test creation reuses the archived dev bundle with from_dev: true; production promotes the test bytes without rebuilding.
Federation contract
This remote operates under federation contract v2 (stralos-shell/federation/v2). Under v2, @stralos-co-nz/ui and @stralos-co-nz/domain are bundled directly into this remote rather than provided as host singletons — each remote bundles Chakra whole. The only shared singletons at the host level are react, react-dom, react-router-dom, and react/jsx-runtime, each at an exact version pin with import: false and strictVersion: true.
The singleton set is defined in .ci/shell-federation-contract.json, which is the single source of truth. vite.config.ts passes contract.shared directly to the federation plugin, and scripts/federation-contract.mjs derives all other artefacts from the same file.
Share guard
src/routes.ts carries one specifier-less import for each import: false entry in the contract file:
@module-federation/vite finalises each share’s virtual module from a usage scan that ends on a 10 ms settle timer. On a cold file cache the scan can call itself complete before the full export list is known, causing Rolldown to die on MISSING_EXPORT. The specifier-less imports mark each share unsafe to narrow, so the virtual module retains its complete export surface regardless of scan timing. This adds roughly 1 KB gzip. src/shareGuard.test.ts fails if the guard imports and the contract entries ever drift.
See Micro-frontends for the full singleton policy.
