Architecture - steilerDev/cornerstone GitHub Wiki
Cornerstone is a full-stack TypeScript web application deployed as a single Docker container. It serves a React SPA from a Fastify HTTP server, backed by SQLite for persistence.
+-------------------+
| Reverse Proxy |
| (HTTPS/TLS) |
+---------+---------+
|
| HTTP
v
+--------------+---------------+
| Docker Container |
| |
| +-----------------------+ |
| | Fastify Server | |
| | (Node.js + ESM) | |
| | | |
| | /api/* REST API | |
| | /* Static SPA | |
| +----+----------+-------+ |
| | | |
| v v |
| +-------+ +----------+ |
| |SQLite | | React | |
| | (vol) | | SPA | |
| +-------+ | (static) | |
| +----------+ |
+------------------------------+
|
| HTTP (proxy)
v
+-----------------+
| Paperless-ngx |
| (external) |
+-----------------+
| Layer | Technology | Version |
|---|---|---|
| Server | Fastify | 5.x |
| Client | React | 19.x |
| Routing (client) | React Router | 7.x |
| Database | SQLite via better-sqlite3 | -- |
| ORM | Drizzle ORM | 0.45.x |
| Bundler (client) | Webpack | 5.x |
| Styling | CSS Modules | -- |
| Testing (unit/integration) | Jest (ts-jest) | 30.x |
| Testing (E2E) | Playwright | 1.63.x |
| Language | TypeScript | ~6.0 |
| Runtime | Node.js | 24 LTS (>= 24.11) |
| Container | Docker (DHI Alpine) | -- |
See individual ADRs for detailed rationale: ADR Index
The project uses npm workspaces with three packages:
| Package | Name | Purpose |
|---|---|---|
shared/ |
@cornerstone/shared |
TypeScript types shared between server and client |
server/ |
@cornerstone/server |
Fastify REST API server |
client/ |
@cornerstone/client |
React SPA |
See ADR-007: Project Structure for the full directory layout.
- REST endpoints under
/api/prefix - Standard error response shape across all endpoints:
{ "error": { "code": "RESOURCE_NOT_FOUND", "message": "Human-readable description", "details": {} } } - Request validation via Fastify's JSON schema (AJV)
-
Pagination: Offset-based with
page(1-indexed, default 1) andpageSize(default 25, max 100). Response includes{ items: [...], pagination: { page, pageSize, totalItems, totalPages } }. See API Contract for full specification. -
Filtering: Query parameters per field (e.g.,
?status=in_progress&assignedUserId=...). Multiple filters are combined with AND logic. Text search viaqparameter. -
Sorting:
sortByandsortOrder(asc/desc) query parameters. One sort field per request.
See ADR-010: Authentication Architecture for detailed rationale.
Two authentication flows:
-
Local authentication: Email/password login for the initial admin account (setup flow) and as a fallback. Passwords hashed with Node's built-in
crypto.scryptat N = 2^17, r = 8, p = 1 (OWASP minimum); legacy N = 2^14 hashes still verify and are rehashed on the next successful login (ADR-010, Amendments). -
OIDC authentication: OpenID Connect via
openid-clientv6 as the primary mechanism. Supports any standard provider (Keycloak, Auth0, Okta, Google, Azure AD, Authentik). By default OIDC signs in existing accounts only and never self-registers: the callback resolves byoidc_subject, else links the account whose email matches the provider's verified email claim (case-insensitive, ambiguous = no match), else rejects. The operator can opt in to just-in-time provisioning withOIDC_JIT_PROVISIONING=true(defaultfalse, #2142): a verified email that matches no account then creates amemberaccount (auth_provider = 'oidc', no password), except when the email is ambiguous or initial setup has not run. There is no domain/email allow-list, so the flag is only safe with a provider that restricts sign-in. A linked local account keeps its password andauth_provider; the API exposesoidcLinked. See ADR-035.
Session management:
- Server-side sessions stored in the
sessionsSQLite table - Session token: 256-bit
crypto.randomByteshex string, delivered as an HttpOnly cookie (cornerstone_session) - Cookie flags:
HttpOnly=true,SameSite=Strict,Secure=true(configurable for dev) - 7-day lifetime (configurable via
SESSION_DURATIONenv var) - Lazy cleanup of expired sessions (hourly interval)
- Instant invalidation on user deactivation (all sessions deleted)
Route protection (Fastify hooks):
-
Authentication hook (
authenticate): GlobalpreHandleron all/api/*routes. Reads session cookie, validates session, loads user. Exempts public routes (health, auth/me, setup, login, OIDC endpoints). -
Authorization decorator (
requireRole('admin')): Route-levelpreHandlerthat checksuser.role. Returns 403 for insufficient permissions.
Roles:
| Role | Permissions |
|---|---|
| Admin | Full access: create, edit, delete everything + manage users |
| Member | Create and edit work items, budget entries, comments |
Frontend auth:
-
AuthContextReact context withuseAuth()hook - App initialization: calls
GET /api/auth/meto determine state (setup required / login / authenticated) - Components use
useAuth()for user info, login/logout/setup actions -
Role-gated routes (
RoleGuard,client/src/components/AuthGuard/RoleGuard.tsx, #2200): the UI half ofrequireRole. A pathless layout route<Route element={<RoleGuard allow={['admin']} fallback={<NoAccessPage />} />}>wraps the admin-only child routes (/settings/users,/settings/backups). It renders nothing while the session loads, the child routes for an allowed role, and otherwise the fallback in place (no redirect, URL kept, shell stays), so an admin page never mounts and never fires its API calls for a member. New admin-only routes go inside this layout route; the server-siderequireRolestays the authority, and the admin-only Settings tabs are hidden (not disabled) for members. Visual pattern: Style Guide › "No access" page.
On first launch with an empty database, the application requires initial admin setup before any users can authenticate. The setup flow ensures the first user account is created securely.
Client-side flow:
-
App initialization calls
GET /api/auth/meto determine the current state - If
setupRequired: trueis returned (no users exist), theAuthGuardautomatically redirects to/setup - The user fills out the setup form (email, display name, password)
- On submission, the client calls
POST /api/auth/setupwith the account details - After successful setup, the user is redirected to
/loginto sign in with the new credentials
Server-side protection:
- The
POST /api/auth/setupendpoint is only accessible when no users exist in the database - If users already exist, it returns
403 FORBIDDENwith error codeSETUP_COMPLETE - The endpoint validates password strength (minimum 12 characters)
- After creating the admin account, it returns
201 Createdwith the user object (no session created)
State detection via /api/auth/me:
{
"user": null,
"setupRequired": true,
"oidcEnabled": false
}-
setupRequired: true→ client redirects to/setup -
user: null(andsetupRequired: false) → client redirects to/login -
user: {...}→ client renders the authenticated app
The setup page is only accessible when setupRequired is true. After setup completes, the endpoint returns 403 and the client-side setup route redirects to login.
config -> errorHandler -> compress -> db -> auth -> routes -> static
The auth plugin registers after db (needs database access for session lookups) and before routes (to protect all route handlers by default).
- SQLite stored at
/app/data/cornerstone.dbinside the container (configurable viaDATABASE_URL) - Volume-mounted for persistence across container restarts
- WAL (Write-Ahead Logging) mode enabled at startup for better concurrent read performance
- Schema managed via hand-written SQL migrations (see
server/src/db/migrations/) - Drizzle ORM provides typed query building on top of better-sqlite3
- snake_case column naming convention
The database connection lifecycle is managed by a Fastify plugin (server/src/plugins/db.ts):
- Startup: Opens a better-sqlite3 connection, enables WAL mode, runs pending migrations, creates a Drizzle ORM instance
-
Request handling: All routes access the database via
fastify.db(Drizzle ORM instance with full schema type inference) -
Raw access: The underlying better-sqlite3 connection is available via
fastify.db.$clientwhen needed for pragmas or raw SQL -
Shutdown: The
onClosehook closes the connection, flushing WAL to the main database file
The plugin is registered first in the Fastify app to guarantee the database is available before any route handler executes. If migrations fail, the plugin throws and the server does not start.
server/src/db/sqlFunctions.ts (registerSqlFunctions) registers two deterministic application-defined
SQL functions on the better-sqlite3 connection: fold(text), the case-, accent- and German-digraph-
insensitive search form, and digits(text), the digits of a phone number. The db plugin calls it right
after opening the connection, so every app query can use them; both return NULL for NULL. They exist
only on connections that register them: every test that queries with them must call
registerSqlFunctions on its own connection, and they must never appear in a migration, view, index or
CHECK (the migration runner and the backup validator open connections without them). The same fold runs
client-side as foldSearchText from @cornerstone/shared (shared/src/lib/searchFold.ts), the single
definition both sides call. Search (API Contract › EPIC-21 contract 6) uses them with LIKE over full
scans, with no FTS index. A list endpoint that offers text search over the same object reuses that
object's search field list from searchService rather than a second copy, so the list and the command
search agree on what a text matches (first case: GET /api/vendors q, #2213).
All API errors are handled by a centralized Fastify error handler plugin (ADR-009). The pattern works as follows:
-
Route handlers and services throw
AppErrorsubclasses -- e.g.,throw new NotFoundError('User not found'). They never constructApiErrorResponseobjects directly. -
The
errorHandlerplugin catches all errors and formats them into the standardApiErrorResponseshape ({ error: { code, message, details? } }). -
AJV schema validation errors are handled automatically -- Fastify's built-in JSON schema validation produces validation errors that the plugin normalizes into
VALIDATION_ERRORresponses with field-level details. -
Unknown errors are sanitized in production -- Any error that is not an
AppErroror AJV validation error is returned asINTERNAL_ERROR(500). In production mode, the message is replaced with a generic string to prevent information leakage.
Error
+-- AppError (base: code, statusCode, message, details?)
+-- NotFoundError (NOT_FOUND, 404)
+-- ValidationError (VALIDATION_ERROR, 400)
+-- UnauthorizedError (UNAUTHORIZED, 401)
+-- ForbiddenError (FORBIDDEN, 403)
+-- ConflictError (CONFLICT, 409)
Error classes are defined in server/src/errors/AppError.ts. The ErrorCode type union is defined in @cornerstone/shared for cross-package type safety.
- 4xx errors are logged at
warnlevel (client mistakes, not server failures) - 5xx errors are logged at
errorlevel (genuine server failures for alerting) - All logging uses Fastify's
request.logfor request-scoped context
config -> errorHandler -> db -> routes
The error handler registers after config (to access NODE_ENV for production mode detection) but before routes (to catch errors from all route handlers).
See ADR-015: Paperless-ngx Integration Architecture for detailed rationale.
Communication pattern: All Paperless-ngx API requests are proxied through the Fastify server. The browser never communicates directly with Paperless-ngx. This keeps the API token secure on the server and avoids CORS issues.
Proxy endpoints under /api/paperless/ provide a curated subset of the Paperless-ngx API:
-
GET /api/paperless/status-- Check if Paperless-ngx is configured and reachable -
GET /api/paperless/documents-- Search/browse documents (with pagination, filtering, sorting) -
GET /api/paperless/documents/:id-- Single document metadata -
GET /api/paperless/documents/:id/thumb-- Document thumbnail (binary passthrough) -
GET /api/paperless/documents/:id/preview-- Document preview/PDF (binary passthrough) -
GET /api/paperless/tags-- List all Paperless-ngx tags
Document linking uses a polymorphic document_links table that stores references between Cornerstone entities (work items, household items, invoices) and Paperless-ngx document IDs. Links are managed via:
-
POST /api/document-links-- Create a link -
GET /api/document-links?entityType=...&entityId=...-- List links for an entity -
DELETE /api/document-links/:id-- Remove a link
Configuration: Two environment variables control the integration:
| Variable | Default | Description |
|---|---|---|
PAPERLESS_URL |
(none) | Base URL of the Paperless-ngx instance |
PAPERLESS_API_TOKEN |
(none) | API authentication token for Paperless-ngx |
The integration is enabled when both variables are set. If either is missing, proxy endpoints return 503 SERVICE_UNAVAILABLE.
API version pinning: All upstream JSON requests (including the document upload) include Accept: application/json; version=10, from the single PAPERLESS_API_VERSION constant in paperlessService.ts. Paperless-ngx accepts only versions 9 and 10 and answers any other with 406, which Cornerstone surfaces as 502 PAPERLESS_ERROR naming the unsupported version. The binary thumbnail/preview passthroughs send no Accept header and use the server's default version. See ADR-015 §5 for the version-dependent field shapes (hex tag color, date-only created).
Caching: No server-side cache in the initial implementation. With fewer than 5 users, request volume is minimal. An in-memory LRU cache can be added later without changing the API contract.
See ADR-017: Chart Library for the chart library decision.
Dashboard architecture:
The dashboard (/) is the default landing page for authenticated users. It aggregates data from existing API endpoints into a grid of health cards. No dedicated dashboard data endpoint exists -- the client fetches data in parallel from existing endpoints (GET /api/budget/overview, GET /api/timeline, GET /api/budget-sources, GET /api/subsidy-programs, GET /api/invoices?status=pending) and renders each card independently.
Design rationale:
- Client-side aggregation: Each dashboard card fetches from an existing endpoint. This avoids introducing a new server-side aggregation endpoint that would couple all card data into a single request. Individual fetches enable parallel loading, independent error states per card, and reuse of existing API infrastructure.
- Recharts for charts: SVG-based charting library (donut charts, bar charts) consistent with the existing custom SVG Gantt chart approach. See ADR-017.
-
Card visibility: Users can show/hide individual dashboard cards. Visibility state is persisted via the user preferences API (
dashboard.hiddenCardspreference key).
User preferences architecture:
Per-user key-value storage for UI settings (theme, dashboard card visibility, future preferences). Stored in the user_preferences SQLite table with a UNIQUE(user_id, key) constraint for upsert semantics.
-
Key format: Dot-notation namespacing (e.g.,
dashboard.hiddenCards,theme) - Value format: Opaque strings; complex values (arrays, objects) are JSON-encoded by the client
- Open schema: The API accepts any string key without server-side validation, allowing the client to evolve preference keys without backend changes
-
ThemeContext migration: On first load,
ThemeContextchecks for athemepreference via the API. If none exists butlocalStoragehas a legacy theme value, it migrates the value to the preferences API and clearslocalStorage.
The report wizard (client/src/pages/ReportWizardPage/wizardReducer.ts) is the reference implementation of the convention every multi-step flow in the client should follow. Its state is partitioned into tiers (selection, sources, report, content, settings, nav), each with a freshXTier() factory, and every reducer case builds its next state by spreading the relevant factories plus explicit field writes — never by an ad-hoc spread of individually-cleared fields.
The point is the forcing function: adding a field to a tier's TypeScript interface is a compile error in that tier's factory, so the field cannot be silently forgotten by a reset path. This convention exists because three separate defects (#1943, #1946, and the hazard #1947 was raised to close) were all the same bug — wizard state surviving a step it should not have survived. Hand-rolled resets are what let that happen.
Which state belongs in which tier is decided by its reset lifecycle, not by what it feels like. Report column visibility (#1973) lives on ContentTier alongside overrides purely because it must reset when the use case or the source changes; it is not otherwise a "content edit".
One deliberate tier-membership opt-out exists, and it is the limit. DISCARD_EDITS spreads freshContentTier() and then writes hiddenColumns: state.hiddenColumns back, because column visibility is a presentation choice rather than a discardable text edit. It is commented at the call site and pinned by its own reducer test. If a second field ever needs the same treatment, split the tier. Introduce a presentation tier that SELECT_USE_CASE/SELECT_SOURCE reset but DISCARD_EDITS does not, rather than adding per-field exceptions. Two or more opt-outs mean the tier boundary is in the wrong place, and each additional one weakens the compile-time guarantee the convention exists to provide.
aiError write-backs are an error-display carve-out and do not count toward that limit. Two reducer cases also write aiError back after spreading freshContentTier():
-
SELECT_SOURCErestoresaiError: state.aiErrorunconditionally. -
DISCARD_EDITSrestores it only when no generation is in flight:aiError: state.aiRequestId !== null ? '' : state.aiError. When a request is in flight, the same spread nullsaiRequestId, so the stale-request guard inAI_GENERATION_COMPLETE/AI_GENERATION_ERRORdrops the abandoned response, and the error is cleared along with it.
These are not tier-membership opt-outs. hiddenColumns survives because its own reset lifecycle differs from its tier's. aiError is different: it has no reset lifecycle of its own. It reports the outcome of the last generation attempt, and the AI_GENERATION_* events own its value (AI_GENERATION_STARTED clears it, AI_GENERATION_ERROR/AI_GENERATION_BLOCKED set it). The write-backs stop an unrelated action from erasing an error the user has not yet acted on. SELECT_USE_CASE, the tier's defining reset, still clears it. A write-back qualifies as an error-display carve-out only if it meets all of these conditions:
- The field reports the outcome of one asynchronous operation.
- That operation's own start event clears the field.
- The tier's defining reset (
SELECT_USE_CASEhere) still clears it.
Anything else is a tier-membership opt-out and counts toward the one-opt-out limit above. If a second error channel ever joins ContentTier, give the operation-outcome fields their own structure instead of adding more carve-outs.
Per-run wizard state is not a user preference. Column visibility is deliberately not routed through the server-persisted preferences API described above: the appropriate column set varies per recipient, so a sticky per-user value would be wrong more often than right and wrong invisibly. It also keeps a network dependency (and its failure modes) out of a wizard step that has none. Reach for user_preferences for stable per-user reading preferences; reach for reducer state for per-document editorial decisions.
See ADR-020: Construction Diary Architecture for detailed rationale.
The construction diary is a project-level documentation tool that combines manual entries (daily logs, site visits, deliveries, issues, notes) with automatic system events (status changes, budget breaches, schedule impacts). Key design decisions:
-
Single
diary_entriestable with a JSONmetadatacolumn for type-specific fields. 11 entry types (5 manual + 6 automatic) share one table. Metadata validated at the application layer via TypeScript type guards. -
Polymorphic source references: Automatic entries link to their source entity via
source_entity_type+source_entity_id(same pattern asdocument_linksandphotos). No FK constraint — diary entries intentionally survive source entity deletion. -
Fire-and-forget auto events: The
diaryEventServiceis called from existing services after state changes. It never throws or blocks the caller. This ensures diary logging never breaks core functionality. -
Signatures stored inline: Base64
data:image/(png|jpeg|webp)URLs in the metadata JSON (max 512 KB each, at most 10 per entry;signerNameat most 300 characters after trimming). Simple storage for small, write-once artifacts. -
Photo attachments via existing infrastructure: Uses the
photostable withentity_type = 'diary_entry'. No new photo endpoints. -
Immutability rules: Manual entries are editable. Automatic entries cannot be updated but can be deleted (#808). An entry with signatures locks only once saved (
isDiaryEntrySignatureLockedin@cornerstone/shared, #2124): the update returns403 IMMUTABLE_ENTRYand its photo mutations also return403 IMMUTABLE_ENTRY. Drafts stay fully editable. - PDF export: Server-side PDF generation with embedded photos and signatures. Pure JavaScript library (no native dependencies).
See ADR-021: Internationalization Architecture for detailed rationale.
Library: react-i18next (client-side only). Translations are statically bundled -- no runtime HTTP fetches for translation files.
Supported locales: en (English), de (German). Fallback language: en.
Locale detection order:
- Server preference (user_preferences API, key
locale) - localStorage key
locale(instant hydration) - Browser
navigator.language(mapped:de*->de, else ->en) - Fallback:
en
Locale preference values: 'en' | 'de' | 'system' (same tri-state pattern as theme preference).
Form of address (#2298, owner decision 2026-10-16): German UI text addresses the user informally
("du", "dein", "dich"; imperatives in the du form). Text written to third parties keeps the formal "Sie":
the generated cover letters to banks and authorities and the LLM prompt text. A "Sie"/"sie" that refers to
a thing or a third party (an invoice, the recipient) is not an address form and is unaffected. The rule
lives in the glossary, client/src/i18n/glossary.json _meta.addressForm (one entry per _meta.locales
locale, validated by client/src/i18n/glossary.ts), and the translator enforces it like glossary terms.
A new locale adds its addressForm entry together with its term forms.
Translation file structure:
client/src/i18n/
index.ts # i18next initialization
en/ # One JSON file per namespace
common.json, auth.json, dashboard.json, workItems.json,
householdItems.json, budget.json, schedule.json, diary.json,
documents.json, settings.json, errors.json
de/ # Same structure
Key conventions: Flat dot-notation within each namespace. Prefixes: nav.*, button.*, label.*, placeholder.*, status.*, toast.*, heading.*, empty.*, confirm.*.
Currency: Deployment-level setting via CURRENCY environment variable (default: EUR). Exposed to the client via GET /api/config (public, no auth). All users in one instance share the same currency.
Formatter localization: All formatters in client/src/lib/formatters.ts accept locale and currency parameters. A useFormatters() hook wraps them with the current locale and currency from LocaleContext.
Error translation: Server error messages remain English. The client translates ErrorCode values to display messages using the errors translation namespace.
Union-derived keys (#2029): A string-literal union that must be enumerated at runtime is declared as an as const tuple, with the type derived from it so the two cannot drift (export const SOURCE_REPORT_TYPES = [...] as const; export type SourceReportType = (typeof SOURCE_REPORT_TYPES)[number]; likewise BUDGET_SOURCE_TYPES, INVOICE_STATUSES, ATTACHMENT_TYPES). The tuple lives in @cornerstone/shared when the server also uses the union, and is client-local when only the client enumerates it (REPORT_SKIP_REASONS in client/src/lib/reportContent/types.ts). Any translation key built from a union member goes through a key set in the I18N_UNION_KEYS registry in client/src/i18n/unionKeys.ts (set.key(member) returns ${prefix}.${member}), never through a template-literal key at the call site. client/src/i18n/templateLiteralKeys.test.ts scans client/src for template-literal t() keys and fails on any match outside its explicit allow-list of non-union sites, so a new union key built by template literal is caught at test time (#2136). The factory is module-private, so every key set lives in the registry, and unionKeys.test.ts iterates the registry against every locale: adding a union member without a translation fails a test instead of rendering a raw key (in the report PDFs, to a bank). The module is pure data with no i18next import, so it respects the injection-only locale contract of ADR-034: consumers pass set.key(member) to their own injected or hook-provided t(). Each status vocabulary has exactly one canonical key set in the common namespace (statusVocabulary.*, #2192), whose labels are the glossary v1 words; the domain glossary is read through client/src/i18n/glossary.ts, whose former names are the client-side search aliases of API-Contract EPIC-21 contract 6 (findSearchDestinations, #2210).
Context tree:
<BrowserRouter>
<ThemeProvider>
<LocaleProvider> <!-- new: mirrors ThemeProvider pattern -->
<ToastProvider>
<AuthProvider>
<ThemeServerSync />
<LocaleServerSync /> <!-- new: syncs locale on auth -->
<Routes>...</Routes>
</AuthProvider>
</ToastProvider>
</LocaleProvider>
</ThemeProvider>
</BrowserRouter>
DataTable always mounts both the desktop table and the mobile card list; CSS hides whichever does not
match the viewport. Every data-testid a page emits through it therefore exists twice in the DOM unless
the two surfaces produce different ids.
-
ColumnDef.render(item, surface)andrenderActions(item, surface)receivesurface: 'table' | 'card'. - Every testid emitted from those callbacks must come from
dataTableTestId(prefix, id, surface)(client/src/components/DataTable/dataTableTestId.ts), which yieldsprefix-idon the table andprefix-mobile-idon the card. -
renderCardandexpandableRows.renderChildCardare card-only: they use the-mobile-form directly. -
Guard:
findDuplicateTestIds(client/src/test/findDuplicateTestIds.ts) is asserted in each DataTable page's unit test. -
E2E: a page-wide
getByTestId(...)resolves to the desktop id. On mobile viewports target the-mobile-id or scope the locator to the card.
One route source for client, server and E2E (#2201). Details and the staged rollout: ADR-038 §2–§3 and Route Map.
-
Module
shared/src/routes/(exported from@cornerstone/shared):routeMap.ts(ROUTE_MAP, pure data, every entry withid?,from,to,kind,change,section,guard,gate,permanent,carries,stage,interim?,parent?,match?),routeUrl.ts(routeUrl,routePattern,getRouteEntry,isRouteServed; typesRouteId,ServedRouteId,RouteParams),redirects.ts(LIVE_REDIRECT_ROUTES,resolveRedirect,effectiveTarget,liveConditionalRules,conditionHolds),match.ts(resolveLocation,matchLocation,matchPattern,baseFrom). The folder imports only its own siblings and no Node/DOM APIs: E2E page objects andnpm run plan:checkload it as source (shared/src/routes/index.js) without buildingshared/. -
App URLs only via
routeUrl(id, params?, query?), in client and server code alike (the vCard and iCal writers and the OIDC login redirects use it). It accepts served ids only (a planned id is a type error), URI-encodes params (pass raw values, never pre-encoded ones) and builds the query withURLSearchParams. Legacy URLs have no id, so they cannot be emitted. API paths (/api/…) are not app URLs and stay literals inlib/*Api.ts.client/src/navigation/routeSource.test.tsfails on any string-literal app path inclient/src, the three server writers,e2e/pagesande2e/fixtures/testData.ts. -
Router:
client/src/App.tsxdeclares every page<Route path={routePattern('<id>')}>and generates every plain redirect fromLIVE_REDIRECT_ROUTESthroughRouteRedirect(client/src/navigation/):<Navigate replace>toresolveRedirect(target, params, search, hash), which carries the query string and the hash (target-defined query keys win). There are no hand-written<Navigate>redirect routes. Query maps (change: 'query-map', live from #2202) match a path plus query pair, take precedence over that path's plain redirect and consume the keys they match (/schedule?view=calendar→/schedule/calendar, not…?view=calendar); other pairs and the hash are carried. -
Conditional routes: integration gates use the layout route
RouteGate rules={liveConditionalRules(id)}(client/src/navigation/RouteGate.tsx); it readsGET /api/paperless/status/GET /api/config, renders nothing while loading, fails open on a fetch error and redirects in one hop when a rule's condition holds. Role guards stay withRoleGuard(see Authentication › Frontend auth); the map'sguard: 'admin'and theRoleGuard-wrapped routes must agree (plan:check). -
NavConfig (
client/src/navigation/navConfig.ts): sidebar sections in ADR-038 §1 order, views per section, phone bar and More sheet, i18n label keys (common:navigation.*, glossary canon). It holds route ids, never paths, under the propertyroute(neverto, which the pattern baseline counts as a destination).navSections(ctx)drops unserved, admin-only (for members) and Paperless-gated (when Paperless is off) entries from the map'sstage,guardandgate; it never copies them. Interim views: a view whose final home does not exist yet carriesinterimUntil: <route id>and is shown only while that route is not served — staging in the same spirit as the route map'sinterim. Today: Money's Funding sources, Grants and Bank report carryinterimUntil: 'financing'(ADR-038 §1); the story that serves the named route deletes them from NavConfig. Interim views are not views for page identity (navLabelKeyForRouteandisNavViewskip them), so their pages keep theirPAGE_LABEL_KEYSh1 and the "Money" breadcrumb. Interim page content follows the same rule outside NavConfig: content whose final home is a route that is not served yet is rendered only while!isRouteServed(<route id>)(from@cornerstone/shared, derived from the route map'sstage), with a code comment naming the story that serves the route, and its replacement (usually a pointer row) renders underisRouteServed(<route id>). Flipping the route'sstagein that story therefore swaps them with no other edit; the story then deletes the dead interim branch. Today: Settings › Project setup's Areas card (#2215;areas, served by #2218), whose pointer row and?tab=areasredirect torouteUrl('areas')use the same condition. -
A route change edits
shared/src/routes/routeMap.tsandplan/restructure/routemap.json(plus the Route Map page) in the same PR;npm run plan:checkenforces agreement and thatApp.tsxserves everydone/interimentry. A story that builds a target page flips itsstagetodoneand deletesinterim.
The sidebar is the navigation (#2205); no page renders a tab row.
-
Rendering:
Sidebar(client/src/components/Sidebar/) rendersnavSections(useNavContext()): primary and quiet groups in one<nav>("Main navigation"), the footer Settings group in a second<nav>("Settings"); each group is a<ul>, and a section's views are a nested<ul>rendered only while the active entry belongs to that section (no toggle, no hidden-but-focusable items). A section entry always opens its main view.useNavContext()returns{ role, paperlessConfigured }and callsGET /api/paperless/statusonly when a served NavConfig route is Paperless-gated (paperlessStatusNeeded()); it fails closed. -
Hrefs:
navHref(route)(client/src/navigation/navActive.ts) builds the href withrouteUrl(route)(one narrow cast, as inbreadcrumbChain), after asserting that the route is served and parameter-free (itsroutePatternhas no:and is not*); it throws otherwise. Home isnavHref('home')(/), never the legacy/project. Interim section routes take their one-hop redirect. -
Exactly one active entry, from the route map:
resolveNavActive(pathname, sections)matches the pathname to its route-map entry and walks the entry and itsparentchain; the first id that is a visible view makes that view active (its section gets the "contains active" style, never the active style); else the first id that is a section main route makes that section active; else the section that owns the entry's route-mapsection(navSectionForRoute), which covers interim aliases and pages reached only by links. No match, theSystem/Authsections or a section the user cannot see yieldnull(nothing highlighted, no views open). Links setaria-current="page"from this result;NavLinkprefix matching is not used (it highlights two entries and cannot express "contains active"). -
History (ADR-038 §1 rule 8): a sidebar link replaces the current history entry when the user
switches within the current section and the current page is that section's entry or one of its views:
replace = isCurrent && active.exact, whereisCurrentmeans the link's section is the active section andNavActive.exactmeans the matched page is itself the active entry (not a page below it). This covers view to view (Schedule → Calendar) and view to section entry (Calendar → Tasks). A sidebar link pushes from a detail page (a task → Schedule, sinceexactis false) and into another section. Nested view links use the samereplacevalue as their section. -
No tab rows: the ten copied tab arrays,
pages/shared/budgetTabs.tsandScheduleSubNavare deleted andPageLayouthas nosubNavslot.client/src/navigation/noTabRows.test.ts(TypeScript AST over non-testclient/src) fails on a tab array (an array literal of two or more objects that each haveto/hrefandlabel/labelKey), on aSubNavimport orSubNavTaboutsidecomponents/SubNav/, on anyScheduleSubNavand on asubNavJSX attribute.SubNavitself stays, without consumers, until theObjectTabsstory (plan §5.11) extends it and relaxes that guard deliberately. -
Admin views (Users, Backups, Integrations) are hidden from members by
navSections; the routes stay role-guarded (RoleGuard, D-23), so hiding is never the only protection.
Every page states where the user is: an h1, the browser tab title, a breadcrumb of its parents and a Back link to where the user came from (#2202 for Tasks, Purchases and Milestones, #2203 for Money, #2204 for Site diary, Photos, Settings, sign-in and the not-found and no-access pages). Everything is derived from the route map and NavConfig; no page hard-codes a parent, a section name or a path.
- h1: the page or object name. Object pages use the object's title (fallback "Untitled ‹type›"); the loading and error states use the type name and not-found uses "‹Type› not found". The h1 text is also the page segment of the tab title.
-
Tab title
‹page or object› · ‹section› · ‹house name›(separator U+00B7). Pages calluseDocumentTitle(h1Text)(client/src/hooks/).composeDocumentTitle(client/src/navigation/pageIdentity.ts) trims each part, drops an empty page and a section equal to the page, and falls back to the product nameCornerstonewhen no house name is set. The section label is the NavConfig section that owns the route-map entry'ssection; the house name comes fromHouseNameProvider(client/src/contexts/,GET /api/settingsonce per signed-in user; Manage pushes a renamed house in without a reload). -
Title ordering:
RouteTitleFallbackat the app root sets‹section› · ‹house›in auseLayoutEffectkeyed on the pathname; a page'suseDocumentTitlesets the full title in a passiveuseEffect, which runs after every layout effect of the same commit. So a page that has not adopted the hook never keeps the previous page's title, and an adopting page always wins. The hook has no cleanup (a passive cleanup would run after the fallback and overwrite it) and depends on the pathname, so two objects with the same name still re-assert the title. It also depends on the real location key (useRealLocationKey(),client/src/navigation/realLocation.ts; falls back touseLocation().keywithout a provider): opening or closing a dialog route changes the real location but not the display location, and the page behind must re-assert its title when the dialog closes (see Dialog routes). -
Title options:
useDocumentTitle(pageTitle, { section?, house? }), both defaulttrue.section: falsedrops the section segment (No access renders at an admin route, but its title is "No access · ‹house›").house: falseuses the product nameCornerstoneinstead of the house name (sign-in and setup always say "· Cornerstone", even in the instant before a signed-in user is redirected away). The effect depends on the two booleans (options?.section !== false,options?.house !== false), never on the options object, so an inline object literal does not re-run it. -
Breadcrumb = parents only, built by
breadcrumbChain()from the route map'sparentlinks (root first, unserved ancestors skipped). Labels come from NavConfig (common:navigation.*), else fromPAGE_LABEL_KEYS(see below); an object parent without a NavConfig label uses the name the page passes inobjectNames(keyed by route id) and is omitted until it has loaded. A value ofnullinobjectNamesmeans "this ancestor does not apply": the row is skipped entirely, it is not pending, and it is not the nearest parent for Back suppression.undefined(or an absent key) keeps meaning "still loading". Only the draft diary edit page usesnull: a draft continues the create flow and has no entry page yet, so its trail is "Site diary" and its h1 is "New diary entry". The current page is never an item (the h1 says it). Views (a NavConfig section main route or aviews[]route, incl. the interim aliasesdashboard→ Home andvendors→ Companies) show neither a trail nor Back. Non-view pages render<PageBreadcrumbs objectNames={…}>(client/src/navigation/) first, in every state (loading, error, not-found), since the parents do not depend on the object. -
Placement: pages built on
PageLayoutpassbreadcrumbs={<PageBreadcrumbs />}; the slot renders directly above the header row with no wrapper (Breadcrumbsrenders nothing when empty, so no space is reserved). Pages withoutPageLayoutrender<PageBreadcrumbs>themselves, above their header row. InsideAppShellthe row is always portaled into the top bar's slot (BreadcrumbSlotContext), at every width (#2206, #2207). From 1024 px (TOP_BAR_MEDIA_QUERY) it rendersBreadcrumbs layout="bar"(one line, no wrapping, the object crumb shrinks first). Below 1024 px it rendersBreadcrumbs layout="compact": a single "‹ ‹target›" link, where the target is the Back origin, else the nearest parent; nothing renders when there is neither (a view), and the compact top bar then shows the house name in its place. The compact link's test id isbreadcrumbs-backfor the origin andbreadcrumbs-parentfor a parent, so E2E's Back locator is valid at every width. The inline row (layout="page", above the page's header row) renders only outsideAppShell(no slot: sign-in, setup, unit tests).PageBreadcrumbsrenders exactly oneBreadcrumbs, so there is never a second "You are here" landmark. Pages keep passing<PageBreadcrumbs>as before and do not know where it renders. -
Page-label registry: a non-view page with a fixed h1 that can be an origin (e.g. Bank report,
Funding sources, Grants) registers its
common:navigation.*key inPAGE_LABEL_KEYS(route id → key,client/src/navigation/pageIdentity.ts), and its h1 uses the same key so the two cannot drift. The registry names the page as a Back origin (originLabelKeyForPath) and as a chain label when it is a parent (pageLabelKeyForRoute). It does not make the page a view:isNavViewstays NavConfig-only, so registered pages keep their trail. The registry holds keys only, never ato. -
Back to origin travels in router location state,
{ origin: { to, name? } }:tois the full origin URL (pathname + search + hash);nameis set only when the origin is an object page and is its display title. Labels are never stored translated: at render time the label isname, else the NavConfig label of the routetomatches, else itsPAGE_LABEL_KEYSlabel, else no Back link. Back is suppressed when the origin pathname equals the current pathname or the nearest breadcrumb parent. Back is a normal push toto, neverhistory.back()(scroll/zoom restore is story 5.3), with one exception: the compact Back rule (#2207). The compact top-bar link callsnavigate(-1)on a plain left click only when React Router'shistory.state.idx > 0and the previous in-app pathname equals the pathname of the link's target. Otherwise it follows its href like any link (push). Plan §4.6 says "history.back() when the previous entry is in-app"; taken literally, "‹ Tasks" could return to the Schedule, so the label must name the page Back leads to.AppShelltracks the previous pathname (useTrackPreviousPath,PreviousPathContextinclient/src/navigation/previousPath.ts), read lazily at click time and never during render;PageBreadcrumbspasses the check toBreadcrumbsasshouldGoBack(href). Modified clicks (new tab, middle click) always follow the href (isPlainLeftClick,client/src/lib/plainClick.ts). -
Origin validation:
readOrigin(state)(client/src/navigation/origin.ts) is the only reader oforigin. It acceptstoonly whensafeAppPathaccepts it (see In-app path rule below) and drops anamethat is empty or longer than 200 characters after trimming, so history state written by other code (or a craftedhistory.replaceState) can never become ajavascript:or cross-originhref. -
In-app path rule (
safeAppPath):safeAppPath(value)(shared/src/routes/safePath.ts, exported from@cornerstone/shared) is the single rule for every URL taken from a query parameter, history state or a request: the server's OIDCredirect(isSafeRedirect), the sign-in page'snext(readNextParam), the origin'sto(readOrigin) and a dialog route's background location (readBackground, #2212). It returns the value unchanged ornull; it never decodes or rewrites. Accepted: a string of 1 toMAX_APP_PATH_LENGTH(2048) characters that starts with/, does not start with//, and contains no\, no://and no ASCII control character (U+0000 to U+001F, U+007F). Browsers read\as/and strip tab and newline inside URLs, so/\xand/<tab>/xboth become//x. Such a value reachesnavigate, anhrefor aLocationheader only through this rule; a second hand-written check is a defect. -
Deep links through sign-in (fixes D-13):
AuthGuardsends a signed-out user torouteUrl('login', undefined, { next }), wherenextis the full requested URL (pathname, search and hash). It is omitted when the URL is exactly/, so the plain sign-in URL stays/login.LoginPagereadsnextwithreadNextParam, which appliessafeAppPathand also ignores anextwhose pathname is the sign-in or setup page (no loops).-
Local sign-in: after
login()succeeds,await refreshAuth()and only thennavigate(next ?? routeUrl('home'), { replace: true }). Without the refresh,AuthContext.userstaysnulluntil a reload, soRoleGuardshows "No access" to an admin andHouseNameProvidernever loads the house name. The "already signed in" check on/loginnavigates the same way. -
SSO: the button calls
/api/auth/oidc/login?redirect=<encoded next>. The server stores the value with the OIDC state and redirects there after the callback (a full page load, so no refresh is needed). Callback error redirects carry&next=when the state is known (API Contract ›GET /api/auth/oidc/callback), so a retry still lands on the deep link.
-
Local sign-in: after
-
Senders: every in-app link or
navigate()that opens an object page passesstate={useOriginState(name?)}, except the object's own list page (Back would be suppressed anyway). Openers on pages without a NavConfig label still pass origin; Back simply does not render there until those pages get names. -
History rules (plan §4.6):
- create success:
navigate(detail, { replace: true, state: forwardOriginState(location.state) }), so Back never returns to an empty form and the original origin survives; - create Cancel:
navigate(originHrefOr(location.state, list), { replace: true }). On a dialog route opened over a page (a validbackgroundin history state) Cancel isnavigate(-1)instead; see Dialog routes › Close paths; - edit Save/Cancel, and every edit-like flow that returns to its target (e.g. Split with AI returning to
the invoice):
navigate(-1)when the page was opened from that target (the origin pathname equals the target'srouteUrl), elsenavigate(target, { replace: true }), so history keeps no duplicate entry; - document-start flows (a page whose mount starts work, e.g. the Paperless review that runs an AI
extraction): success replaces the page with the created object (
replace: true, origin forwarded), and Cancel replaces it withoriginHrefOr(…), so browser Back never re-enters the flow; - delete success:
navigate(list, { replace: true }); - filters, paging and sort:
setSearchParams(…, { replace: true })(useTableStatedoes this for every list); - sidebar: switching between views of the current section replaces when the current page is that
section's entry or one of its views (
replace = isCurrent && active.exact); a sidebar click from a detail page or into another section pushes (see Sidebar from NavConfig › History); - nothing pushes an entry on mount.
- create success:
-
Baseline:
Breadcrumbsrenders anchors throughuseHref+useLinkClickHandlerand never a react-routerLink, and its data useshref, neverto, so importing it adds no destinations to the click-count baseline (plan/restructure/scripts/build-baseline.mjs).
The shell has two layouts, switched at 1024 px. From 1024 px: the sidebar and the desktop top bar
(breadcrumb row on the left; Search, live since #2211 (see Command search); the attention bell, an
inert aria-disabled placeholder until the attention story; the New menu, live since #2212; the user
menu on the right). Below 1024 px (phones
and tablets alike, no separate rail): the compact top bar, the bottom bar and the More sheet. There is
no drawer, no floating menu button and no overlay.
-
Structure:
AppShellreadsuseMediaQuery(TOP_BAR_MEDIA_QUERY)and mounts one layout; the other is not in the DOM (never CSSdisplay: none). So there is exactly one "Main navigation" landmark, one banner and one Log out at any width.- ≥ 1024 px:
Sidebar·div.shellColumn› (TopBar variant="desktop",div.mainContent›main). - < 1024 px (
.appShell.compact):div.shellColumn› (TopBar variant="compact",BottomBar,div.mainContent›main) ·MoreSheet(a sibling of the column, so it can mark the columninertwhile open).Sidebaris not mounted.
AppShellowns the navigation model for both layouts:navSections(useNavContext())and oneresolveNavActive(pathname, sections)result, passed toSidebar,BottomBarandMoreSheet. So one rule decides the highlight at every width (ADR-038 §1 rule 3). The providersShortcutRegistryProvider,BreadcrumbSlotContext,PreviousPathContextandViewMenuContextwrap the whole shell. - ≥ 1024 px:
-
Why the top bar sits outside
.mainContent:.mainContenthasoverflow-y: auto, but it never scrolls. The document scrolls, because.appShellsetsmin-height(100vh, then100dvh), notheight. Aposition: stickybar inside.mainContentwould stick to a box that never scrolls, so it would scroll away with the page. The bar is the first child of the non-scrolling.shellColumn, so it sticks to the viewport..mainContentkeepsoverflow-y: autobecause it contains horizontal overflow of wide pages. Never move the bar into.mainContent. -
Shell breakpoint 1023/1024: the switch is the single JS media query
TOP_BAR_MEDIA_QUERY(client/src/navigation/breadcrumbSlot.ts). The shell CSS (AppShell,Sidebar,TopBar,BottomBar,Sheet,MoreSheet) has no width breakpoint of its own;Sheetonly centres its panel from 768 px. The1024pxmedia queries of page content (DataTable, budget pages and others) are content layout and are not part of this rule. E2EAppShellPage.isCompact()uses< 1024. -
Compact top bar (
TopBar variant="compact"): the breadcrumb slot (holding the compact Back link, see Page identity › Placement), the house name (HouseNameProvider, else the product name), shown only while the slot is empty (.slotCompact:has(> *) + .houseName), the page title, and a search icon (opens the full-screen command search, #2211). The title is the page's h1 text once that h1 has scrolled up under the bar (useScrolledPastHeading,client/src/hooks/: anIntersectionObserveron themainh1, re-attached by aMutationObserverwhen the h1 changes, reset perlocation.key); it isaria-hiddenbecause the h1 already names the page. There is no avatar, New or bell below 1024 px: the More sheet holds the user block. -
Bottom bar (
client/src/components/BottomBar/): a<nav>"Main navigation",position: fixedto the bottom, with the safe-area insets as padding. Its slots come from NavConfig's id tuplePHONE_BAR(home,diary,capture,photos,more), never from an array of link objects (the baseline countstoobjects, andnoTabRowsflags label+href arrays). Home, Site diary and Photos take the section's label andnavHref(section.route); the + slot (idcapture) is abuttonlabelledtopBar.newthat opens the New sheet (CreateSheet, #2212;aria-haspopup="dialog",aria-expanded,aria-controlsthe sheet, neveraria-current); Diary entry is the sheet's first row until P2.3 restores one-tap capture. More is abutton(aria-haspopup="dialog",aria-expanded,aria-controlsthe sheet). Section slots replace exactly like the sidebar (replace = isCurrent && active.exact). More is highlighted (aria-current="true") when the active section is not one ofPHONE_BAR_SECTIONS, i.e. it lives in the sheet; a member on an admin URL (No access) therefore highlights More, as the sidebar highlights Settings. 404 and unknown routes highlight nothing. The Home slot takes an optional needs-attention count (homeBadge, story 3.4). -
More sheet (
client/src/components/MoreSheet/):Sheetcontent. A<nav>"More" lists the remaining sections in NavConfig'sMORE_SHEETgroups (Tasks · Purchases · Money · Companies; Areas · History · Documents [Paperless only]; Settings), filtered bynavSections, each aNavAnchorthat closes the sheet before it navigates. The user block comes last: name and role, Account, Theme and Language (radio groups that toggle in place and keep the sheet open), Help, About (the version), GitHub, Keyboard shortcuts and Log out, which is always the last row. The user block and the desktop user menu read one model,useUserMenuModel(client/src/hooks/), so they cannot drift. Keyboard shortcuts shows only with a hardware keyboard (useHardwareKeyboard:(any-pointer: fine), or akeydownthis session whose target is not an editable field, since on-screen keyboards only type into fields). Activating it takes the shortcut snapshot, closes the sheet, then opensKeyboardShortcutsHelp: the sheet and the dialog both listen toEscapeondocument, so they must never be stacked. Closing the dialog returns focus to the More button. The sheet is open only while its open key equalslocation.key, so any navigation closes it (derived state, not an effect). The key is cleared during render as soon as it differs, because browser history entries keep their key: without the clear, Back and then Forward would land on the old key and reopen the sheet. It also closes, and never reopens, when the viewport grows to 1024 px. -
Sheet(client/src/components/Modal/Sheet.tsx, exported fromcomponents/Modal/index.ts): the bottom-sheet dialog. It lives incomponents/Modal/, the dialog family's home, because the pattern baseline counts every intrinsicrole="dialog"/aria-modaloutsidecomponents/Modal/as a hand-rolled dialog (handRolledDialogs). The panel stays mounted and isinertwhile closed. It carriesrole="dialog",aria-modalandaria-labelledby(its h2 title) only while open, so a closed sheet is invisible to assistive technology and to E2E locators such as[role="dialog"]. While open: focus moves to the first focusable control of the body (else Close); Close, the backdrop andEscapedismiss it and return focus toreturnFocusRef;html[data-scroll-locked="true"]locks page scroll; andAppShellsetsinerton.shellColumn, so the bars and the page cannot be reached. Stacking: backdrop--z-overlay, panel--z-overlay+ 1, above both bars. The panel'smax-heightleaves the top bar visible (100dvh - var(--topbar-height)). Variants and options (#2212):variant="bottom"(default, the above) orvariant="fullScreen"(the whole viewport, safe-area padded, body scrolls between a 56 px header and an optionalfooter; ObjectForm below 768 px). Optional props:footer,headerStart(a slot before the h2, e.g. "‹ Back to …"),initialFocusRef(mount focus: it, else the first focusable control of the body, else Close),dismissible(default true;falsemakes Escape, backdrop and Close inert, Close getsaria-disabled),restoreFocusOnUnmountwithskipOpenerRef(for conditionally mounted sheets), andreturnFocusRefis optional. #2211 addshideTitle(the h2 stays as the accessible name, visually hidden; withheaderStartthe slot grows to fill the header) and, forfullScreenonly,contentMaxWidth(a CSS length: header content and body are centred in a column of at most that width while the panel still fills the screen; omitted, nothing changes).ModalgainsheaderStarttoo, and with #2211hideTitleandvariant="palette"(no header row or Close button, a wide panel in the upper third; the command search only). Both containers restore focus through one chain,useFocusRestore(components/Modal/useFocusRestore.ts): the opener (document.activeElementat mount), thenreturnFocusRef, thenfocusPageHeading(), never<body>. -
useFocusTrap(client/src/components/Modal/useFocusTrap.ts, withgetFocusableElements): the one focus-trap implementation, shared byModalandSheet. Whileactive,Escapecalls the latestonEscapeandTab/Shift+Tabwrap inside the container; disabled controls and elements whosetabindexattribute is-1are skipped (#2211: a[href]withtabindex="-1", such as a command-search result anchor, would otherwise become the trap's last element and Tab would leave the dialog). A new dialog-like surface uses it instead of its own keydown loop. -
NavAnchor(client/src/navigation/NavAnchor.tsx): a real<a>(middle click and copy link work) built withuseHref+useLinkClickHandler, with optionalreplace,state(history state passed to the navigation; the New sheet uses it for origin and dialog background) and anonNavigatecallback that runs before the in-app navigation. Bar and sheet links use it. It is not a react-routerLink, so the shell adds no destinations to the click-count baseline (shell.destinationsstays 2).tois always anavHref()/routeUrl()result, never a string literal. -
Title menu (PLT-004, below 1024 px): on a section's main view or one of its views, the page h1 is the view menu's trigger ("Tasks ▾").
AppShellcomputes the model withviewMenuFor(active, sections)(client/src/navigation/viewMenu.ts: main view first, then the visible views;nullwhen the page is not exactly such a view or the section has fewer than two) and provides it throughViewMenuContextonly in the compact layout.PageTitle(client/src/components/PageLayout/PageTitle.tsx, used byPageLayoutandTimelinePage) renders a plainh1without a model, else anOverflowMenuwhoserenderTriggerputs the triggerbuttoninside theh1, so the page keeps exactly one h1. Entries arelinkitems withcurrenton the current view; a plain click callsnavigate(href, { replace: true }), since a view change replaces the history entry (ADR-038 §1 rule 8). The menu closes on navigation (closeSignal={location.key}). -
--topbar-heightis declared on.appShell(calc(var(--spacing-12) + var(--spacing-2)), 56 px) and is the bar's exactheight. The compact layout addsenv(safe-area-inset-top).--shell-bottom-height(client/src/styles/index.css) is the space the bottom bar takes:0remby default andcalc(var(--spacing-16) + env(safe-area-inset-bottom))whilehtml[data-shell-bar="shown"], whichBottomBarsets while it is mounted and visible..mainContentpads its bottom by it, and toasts sit above it. A page sized to the viewport subtracts both at every width:calc(100dvh - var(--topbar-height) - var(--shell-bottom-height)). On desktop the second term is 0. Today these pages do this: Timeline, Auto-itemize, Paperless invoice review,SpotViewerand the photo-spot viewer page. A new viewport-sized page must do the same. -
The on-screen keyboard hides the bar:
useOnScreenKeyboard(client/src/hooks/) reports a keyboard whilevisualViewport.height × scaleis below 75 % ofwindow.innerHeight(so pinch-zoom does not count). The bar then getsdata-keyboard-open="true"(slides out,visibility: hidden) andhtml[data-shell-bar]becomeshidden, so--shell-bottom-heightdrops to 0 and no field is covered. Playwright cannot raise an on-screen keyboard, so this is covered by unit tests only. -
--touch-target-min(tokens.css, 2.75 rem = 44 px) is the minimum size of the shell's small tap targets: sheet rows and Close, the compact Back link, the search icon, the title-menu trigger and theOverflowMenutrigger. Bar slots are taller (64 px). New touch controls use the token, never a literal size. -
Bar stacking: both bars use
z-index: calc(var(--z-dropdown) + 1). Page elements at--z-dropdown(for example the Auto-itemize page header) come later in the DOM and would otherwise paint over the open user menu. The sheet (--z-overlay), modals (--z-modal), dialogs stacked on them and the popover portals (OverflowMenu.menuFixed,AnchoredPanel, both atcalc(var(--z-modal) + 100)) stay above the bars; see Grammar foundations › Dialog stacking. Do not raise the bars to--z-overlay. -
Breadcrumb hosting:
AppShellkeeps the bar's slot element in state and provides it throughBreadcrumbSlotContext(client/src/navigation/breadcrumbSlot.ts, withTOP_BAR_MEDIA_QUERY). Both top-bar variants render the slot. See Page identity › Placement. -
OverflowMenuentry contract (client/src/components/OverflowMenu/).itemsis a list ofOverflowMenuEntry. Each kind maps to one role:Kind Renders Activation action ( kindomitted or'action')button role="menuitem";ariaDisabledgivesaria-disabled="true"(still focusable)Closes, then calls onClick. WithkeepOpen, callsonClickand stays open. AnariaDisableditem does nothinglinka role="menuitem" href(never atoproperty: the baseline countstoobjects as destinations).newTabaddstarget="_blank" rel="noopener noreferrer", anaria-hidden↗ and ansrOnlysrSuffix.currentaddsaria-current="page", the highlight and a leading ✓Calls onClick?.(e), then closesseparatordiv role="separator"— choicediv role="group"labelled by its label; options arebutton role="menuitemradio" aria-checked(optionallang)Calls onChange(value); the menu stays open and keeps focusgroupdiv role="group"with a label line (labelplus right-alignedmeta), then its action and link itemsPer item Other props:
header(non-item content above the list, linked witharia-describedby),triggerClassNameandtriggerIcon(custom look for the built-in trigger),renderTrigger(#2207: replaces the built-in trigger; it receivesOverflowMenuTriggerProps(ref, ids, ARIA, test id and handlers) and must spread them onto its own<button>, soOverflowMenustill owns the trigger's behaviour),wrapperClassName,placement('bottom-end'by default,'top-end', and'bottom-start': a non-portal, left-aligned panel below the trigger, used by the title menu; withusePortalit falls back to'bottom-end'),menuClassName,menuTestId,triggerRef(a caller-owned ref, used to return focus after a dialog that the menu opened),controlRef(#2212: anOverflowMenuControlwithopen(focus: 'first' | 'last', returnTo?),close()andisOpen(), so a shortcut can open the menu with focus on a row; Escape and Tab then return focus toreturnTowhen it is still connected, else to the trigger), andcloseSignal. The menu is open only whilecloseSignalequals its value at the moment the menu opened. This is derived state, not an effect. Passlocation.keyto close the menu on navigation.Keyboard follows the WAI-ARIA menu-button pattern. The trigger has
aria-haspopup="menu",aria-expanded, andaria-controlswhile open.ArrowDown,EnterorSpaceopens the menu on the first item, andArrowUpopens it on the last item. A mouse click opens it without moving focus. In the menu, the arrow keys wrap,HomeandEndjump to the ends, andEscapecloses the menu and returns focus to the trigger.TabandShift+Tabalso close it and return focus to the trigger, withoutpreventDefault, so the browser moves on from there.Spaceon a link clicks it.aria-disableditems stay in the arrow-key order;:disableditems do not. Lists of plain actions (the original API) keep working unchanged. -
Shortcut registry (
client/src/hooks/shortcutRegistry.tsx):ShortcutRegistryProvider(inAppShell) holds a ref-backedMap. Its registry object is memoised once, so registering never re-renders.useKeyboardShortcutsregisters its list under auseId()owner, and does nothing without a provider.snapshot()returns the lists in registration order, de-duplicated by key (the last one wins, except that a non-fallback owner always wins over a fallback one; see Dialog routes › Shortcuts). The Keyboard shortcuts entries of the user menu and the More sheet take a snapshot when they open the help dialog (KeyboardShortcutsHelp, withEmptyStatewhen the page has none); closing it returns focus to the avatar or the More button. -
User menu (
client/src/components/UserMenu/, ≥ 1024 px): a header (name and role), then Account (an anchor torouteUrl('settingsProfile')built withuseHref+useLinkClickHandler, never a react-routerLink), Theme and Language (choices that toggle in place), Keyboard shortcuts, Help (HELP_URL), About (the version and the GitHub linkGITHUB_URL; both URLs live inclient/src/lib/externalLinks.ts), and Log out last.AuthContext.logout()never rejects, so Log out has a pending state and no failure state. -
Exactly one Log out per viewport: the user-menu row from 1024 px, the More sheet's last row below. Both use the label
common:userMenu.logOut("Log out"). The sidebar footer (.footerLegacy), theThemeTogglecomponent and the drawer'saria.openMenu/aria.closeMenulabels were removed in #2207. The E2E helperAppShellPage.logout()picks the control by viewport. -
No primary button in the shell (pattern baseline
screens.shell.primaryButtonsis 0): the bars' buttons are secondary or icon buttons, the + slot is a bar slot whose icon sits on a neutral circle, and no shell rule sets a background to--color-primary.
Creating an object is a dialog over the page you are on, not a page (PLT-048, WRK-017, HHI-002; visual
spec: Style Guide › New menu and object form). The URL stays real (/project/work-items/new), so deep
links, reloads, the legacy redirects and the E2E entry points keep working.
-
Dialog routes:
DIALOG_ROUTE_IDS = ['workItemNew', 'householdItemNew', 'milestoneNew', 'companyNew'](client/src/navigation/dialogRoutes.ts, pure, no React;companyNew=/companies/newsince #2213). Each has a list route behind it, read from the route map'sparent(dialogParentRoute(id):workItems,householdItems,milestones,companies), never hard-coded. A static/…/newsegment outranks the sibling/…/:idroute, in React Router and in the shared matcher alike. Their<Route>declarations stay inApp.tsxwith the list page aselement, soplan:checkroute coverage and the legacy redirects still match; the display mapping below means they never render on their own. -
Background location in history state: an opener pushes the create URL with
state.background = { pathname, search, hash, state, key }, the location the user is looking at (the display location, i.e.useLocation()inside<Routes>).RoutedAppinApp.tsxreads the real location once and renders<Routes location={displayLocationFor(real)}>:- a valid background → the display location is the background, so the page you came from keeps rendering behind the dialog;
- a dialog route without a background (deep link, reload in a new tab, a legacy redirect) → the
parent list:
{ pathname: routeUrl(parent), search: '', hash: '', state: real.state, key: real.key }. The list behaves as a view (no trail, exact highlight), anduseTableStatenever readsareaIdordateas table filters; - anything else → the real location.
RealLocationKeyContext(navigation/realLocation.ts) carries the reallocation.keyto code inside<Routes>(useDocumentTitle). A reload keepshistory.state, so a reloaded dialog still has its background. Anything inside<Routes location={display}>that builds a URL from the requested location reads the real location throughuseRealLocation()(navigation/realLocation.ts), neveruseLocation(). This includesAuthGuard's sign-in redirect: a signed-out deep link to a create URL (/project/work-items/new?areaId=…) keeps its full URL innext(Page identity › Deep links through sign-in), not the parent list that is displayed behind it. -
Background validation (
readBackground) is the only reader ofstate.background. It accepts an object whosepathname + search + hashpassessafeAppPath(Page identity › In-app path rule), whose pathname is not a dialog route (no dialog behind a dialog) and whosekeyis a string. The nestedstateis kept with any nestedbackgroundremoved (backgroundStateForstrips it when writing, too). Anything else isnull, i.e. deep-link mode. The value becomes a router location, so a craftedhistory.replaceStatecan never render a foreign orjavascript:location.forwardOriginStateforwards onlyorigin, so a background never survives a Save. -
Why not a data router with
useBlocker: the app usesBrowserRouter. Moving tocreateBrowserRouterwould change error boundaries (route errors would no longer reachChunkLoadErrorBoundary) and every hook test built onMemoryRouter, out of proportion for one guard. -
The host sits at the router root:
ObjectFormRoutes(client/src/objectForms/) renders after<Routes>inRoutedApp, not insideAppShell. It must see the real location (inside<Routes location>,useLocation()returns the display location), and it keeps the pattern baseline honest:screens.shellcounts theAppShellimport closure, so a host there would raiseshell.primaryButtonsfrom 0 to 1 with ObjectForm's Save.App.tsxis not a screen; ObjectForm counts toward no screen. While a dialog is open its Save is the only primary on screen. The host renders nothing without a signed-in user. One small component per schema (TaskDialog,PurchaseDialog,MilestoneDialog) calls its schema hook (useTaskFormSchema,usePurchaseFormSchema,useMilestoneFormSchemainobjectForms/), keyed by dialog route id, never bylocation.key. -
Close paths and history (refines Page identity › History rules):
-
Cancel, Escape, Close ✕ with a background:
navigate(-1). The opener pushed exactly one entry, so the entry before is the background. Without a background:navigate(originHrefOr(state, routeUrl(parent)), { replace: true }). -
Save:
navigate(result.href, { replace: true, state: forwardOriginState(real.state) }). Back from the new object returns to the page you came from. No toast: the destination page is the confirmation. - Browser Back pops the entry; the dialog unmounts because the real URL changed.
-
Add another (task schema): Save keeps the dialog, resets the values except
keepfields, shows a persistentrole="status"confirmation and dispatchesnotifyObjectCreated(schema.id)(client/src/lib/objectCreatedEvents.ts, window eventcornerstone:object-created), on which the list behind reloads silently (no skeleton, rows kept).
-
Cancel, Escape, Close ✕ with a background:
-
"Discard changes?" on Back, without a blocker:
ObjectFormreportsonDirtyChange(dirty). When the real location leaves the dialog route withuseNavigationType() === 'POP'while dirty, the host holds the form: it keeps rendering ObjectForm for the held location withleaveRequested, which opens the discardConfirmDialog. Keep editing →navigate(1)returns to the dialog entry and the host clears the hold (the form is keyed by route id, so the input survives). Discard → the host clears the hold; the URL is already the background. PUSH and REPLACE away from a dialog come only from the form itself (Save, Cancel), because nothing behind a modal is reachable, so they are not guarded. Cancel, Escape and ✕ on a dirty form open the same confirm. There is nobeforeunloadguard (not in the UX spec; Playwright auto-dismisses it and would hang tests that reload a dirty form). -
Focus: initial focus is the first empty required field, else the first field, never ✕ (
initialFocusRef, computed in a layout effect). Close restores focus throughuseFocusRestore(see App shell ›Sheet). When the dialog was opened from the New menu or sheet (state.createOpener === true,hasCreateOpener) the menu row is gone, so the fallback is[data-create-trigger](the top-bar New button or the + slot, whichever is mounted). After Save the opener is skipped and focus goes to the destination h1 (focusPageHeading()). The discard confirm passes the opener as itsreturnFocusRef. -
Container switch at 768 px:
useMediaQuery('(min-width: 768px)')picksModalfrom 768 px andSheet variant="fullScreen"below. Values, errors, the step stack and the dirty baseline live in ObjectForm above the container, and the focused field is refocused by id after a switch. Media queries cannot read custom properties, so the literal768pxis used. -
ObjectForm(client/src/components/ObjectForm/): a schema-driven create form (ObjectFormSchema:id, translatedtitle,fields,initialValues,addAnother?,prefill?mapping pre-fill keys to fields,validate,submitreturning{ id, href, label },addedMessage?). Field kindstext,textarea,number,date,url,select,custom; the fieldnameis the DOM id, so existing E2E locators stay valid. Essential fields first, the rest behind a "More details" disclosure; Cancel before Save; Save is always enabled and errors show on the attempt (first invalid field focused). A400 VALIDATION_ERRORmapsdetails.fields[].pathto fields and shows our own "Check this value." text, never the server message; anything else shows aFormErrorbanner throughtranslateApiError. Create from a picker: a custom field's render context hascreateChild({ schema, prefill, onCreated }), which pushes a child step into the same dialog ("‹ Back to ‹parent›" inheaderStart); the child's Save sets the parent field's value and pops the step. -
CreateMenu(client/src/components/CreateMenu/): the New menu. From 1024 px the top bar's "New ▾" (CreateMenu, anOverflowMenuwith link rows); below 1024 px the + slot opensCreateSheet(aSheet, rows areNavAnchors). Entries come fromnavigation/createEntries.ts:CREATE_MENU_ENTRY_IDS(Diary entry · Task · Purchase · Invoice · Offer · Company · Milestone · Funding source · Grant) andCREATE_TARGETS, an object keyed by entry id (never an array ofto/href+ label objects, which the baseline andnoTabRowswould count) giving each entry'sroute, fixedquery, acceptedprefillkeys,moneyOnlyanddialog. Funding source and Grant show only on Money pages.CreateMenuProvider(inAppShell) providesuseCreateMenu()(open/close/isOpen;opendoes nothing while a modal is open) anduseCreateContext(). -
Openers: every create entry point (menu rows, list primaries, empty states,
n, the Home "Add" menu and Quick Actions card until 3.3, the Schedule "Add" menu until its story) usesuseOpenCreate()(navigate, push) oruseCreateEntryLink(id)({ to, state }for anchors), fromnavigation/useOpenCreate.ts. State = origin + (dialog targets)backgroundStateFor(display location)- (menu and sheet)
createOpener: true. Never a barenavigate(routeUrl('…New')): it would open the dialog in deep-link mode over the list instead of over the current page. Both hooks work outside aCreateMenuProvider(pre-fill is then empty). A plainhrefto a create URL (e.g.CalendarView's empty state) is allowed and opens in deep-link mode.
- (menu and sheet)
-
Context pre-fill: pages call
useCreateContext({ areaId?, companyId?, date? });createHref(id, context)appends only the keys the target accepts (CREATE_PREFILL_KEYS,navigation/createQuery.ts):Entry Route Fixed query Pre-fill → field Diary entry diaryEntryNew— date→ entry dateTask workItemNew(dialog)— areaId→ areaPurchase householdItemNew(dialog)— areaId→ area,companyId→ vendorInvoice invoicescreate=1companyId→ companyOffer invoicescreate=1,kind=offer(statusquotation)companyId→ companyCompany companyNew(dialog)— — Milestone milestoneNew(dialog)— date→ target dateFunding source (Money only) budgetSourcescreate=1— Grant (Money only) budgetSubsidiescreate=1— Each route's
carriesin the route map lists the keys it reads, and may also hold keys reserved for a planned surface of the same route (diaryEntryNewcarriesareaIdfor the P2.3 capture screen, while today's page reads onlydate). Validation (sanitizePrefill,prefillFromSearch):datemust match^\d{4}-\d{2}-\d{2}$and be a real calendar date; ids must be 1–64 characters of[A-Za-z0-9_-]; anything else is dropped silently. An id that is not among a field's loaded options is cleared in both the initial and the current values, so it never makes the form dirty. Wired today: the company page providescompanyId, the Schedule's Calendar view provides the visible date (today when shown, else the first day of the displayed month or week); the Gantt view provides no date; area pages arrive with story 1.11. -
Interim
?create=1surfaces (useCreateQuery): until their ObjectForm schemas exist (Invoices and Offers P4.9, Funding sources and Grants the Financing page), the New menu opens the existing create UI on its list page.useCreateQuery({ ready?, prefillKeys?, onOpen })(client/src/hooks/useCreateQuery.ts) callsonOpen({ kind, prefill })once per?create=1occurrence, on every URL change (not only on mount, so New › Invoice works while already on Invoices) and only whenready, then removescreate,kindand the declared pre-fill keys withreplace(other parameters kept; no write when the URL is already clean). Pages usinguseTableStatereserve those keys with the module-level constantcreateReservedParams(prefillKeys). Invoices: the create modal or the Paperless picker (unchanged gating),kind=offerpresets statusquotation,companyIdpresets the company. Funding sources and Grants: the inline create form, scrolled into view with its first field focused. These open in place, so Cancel stays on that list. Companies left this list in #2213:?create=1on/companies(old links) is replaced (replace) by/companies/new, which opens in deep-link mode over the list. -
Create from a picker (
CompanyCreateProvider, #2213): a company picker (SearchPickerwith acreateAction,show: 'noExactMatch', row "New company ‹text›") may sit on a page or inside aModalthat is not an ObjectForm, so it cannot use the ObjectForm child step or a dialog route without leaving its page.CompanyCreateProvider(rendered once byApp.tsx, around<Routes>and<ObjectFormRoutes />) exposesRequestCompanyCreate(name) → Promise<CompanyOption | null>throughCompanyCreateContext; pickers reach it viauseCompanyCreateAction(). It renders the companyObjectForm(Name pre-filled, not dirty) with no URL change and resolves with the new company on Save ornullon Cancel; it never navigates. One request at a time (a second resolvesnullat once), and a hostModalpassesdismissible={!isCreating}so dialogs never stack under it. It is not a screen and adds no route. Without the provider (unit tests) pickers show no create row. -
Shortcuts:
-
Fallback owners:
useKeyboardShortcuts(shortcuts, { fallback: true })registers a fallback (ShortcutRegistry.register(id, shortcuts, { fallback })). Its handler does nothing when a non-fallback owner registered the same key, andsnapshot()lists the non-fallback entry whatever the registration order. The shell registersn("Open the New menu") as a fallback; Tasks and Milestones keep their ownn(open the New task / New milestone dialog), which wins there. -
Off while a modal is open:
useKeyboardShortcutsreturns early whiledocument.querySelector('[aria-modal="true"]')matches, so no page shortcut fires behind a dialog, a sheet or a confirm (each handles its own Escape). Consequently no in-app PUSH can happen behind an open ObjectForm.
-
Fallback owners:
-
Behind the dialog: the page is hidden from assistive technology by the container's
aria-modaland focus trap, not byinert(the host sits outsideAppShell). The New sheet, which lives inAppShell, marks.shellColumninertlike the More sheet.
One search field for the whole app (WRK-069; visual spec: Style Guide › Command search;
API: API Contract › EPIC-21 contract 6, GET /api/search). It finds objects through the server and
places ("Go to") through NavConfig and the glossary, client-side.
-
Components (
client/src/components/CommandSearch/):-
CommandSearchContext.tsx:CommandSearchProvider,useCommandSearch()(available,isOpen,open({ query?, returnFocusRef? }),close,toggle), the ⌘K listener, the shell/fallback andusePaperlessBaseUrl()(the Paperless browser URL, fetched lazily once per shell; errors givenull). Outside a provider the hook returns a detached no-op API withavailable: false, likeuseCreateMenu(). The context module never imports the host (CommandSearch.tsx), so a consumer such asTopBarorDataTabledoes not pull the palette into its import closure. -
CommandSearch.tsx(the host),CommandSearchBody.tsx(input row and listbox, exported as two parts),SearchResultRow.tsx,HighlightedText.tsx(ranges fromfindMatchRangesinclient/src/lib/searchHighlight.ts: fold-aware, mapped back to the original string),useCommandSearchQuery.ts(request state),commandSearchModel.ts(pure: option order and ids, line-2 parts, label fallbacks, destination href andreplace) andSearchEverywhereRow.tsx(the list-field row). OnlyCommandSearchcounts as a shared component in the pattern baseline; the other files live inside its folder.
-
-
Open state and lifetime:
AppShellowns the request,{ key, query, returnFocusRef? } | null, keyed by the real location key (useRealLocationKey()), and derivessearchOpen = request !== null && request.key === realKey. As with the More sheet and the New menu, this is derived state, not an effect: every real navigation closes the search, whether a result click, a sidebar or breadcrumb link, or browser Back. A request whose key differs fromrealKeyis dropped during render, so going Back and then Forward to the entry it was opened on never reopens it (history entries keep their key). Opening and closing never touch history, so Back from an open search closes it by navigating back.CommandSearchProvidersits insideCreateMenuProvider, andopen()closes the desktop New panel first. -
Mounted only while open: the host renders only while
searchOpen. A closed search has no DOM; its query and pending requests go with it (useCommandSearchQueryaborts every request and clears every timer on unmount), and the next open starts empty unlessopen({ query })pre-fills it. -
Containers: from 1024 px (
wide, AppShell'sTOP_BAR_MEDIA_QUERYresult) aModalvariant="palette"withhideTitle(the title stays as the accessible name, visually hidden); below 1024 px aSheet variant="fullScreen" hideTitlewith the input inheaderStartand the results as the body. One of the two is mounted, never hidden with CSS. The query state lives in the host above the container, so resizing across 1024 px keeps the query and the results; a layout effect refocuses the input by id after the switch (the ObjectForm pattern). -
Background
inert: while the search is openAppShell's rootdivgetsinert, and the host renders after thatdivas its sibling (theSheetis not portaled, so inside the root it would be inert itself). The page's own list field is unreachable while the search is open, so a screen never has two live search fields (AC7)..shellColumnkeeps its owninertfor the More and New sheets. -
No stacking:
open()and ⌘K do nothing while another[aria-modal="true"]exists (ObjectForm, a confirm, a picker modal, a sheet). The search never opens over a dialog. -
⌘K / Ctrl K: one
keydownlistener onwindowin the capture phase, registered by the provider. It matches(metaKey || ctrlKey) && !altKey && !shiftKey && key.toLowerCase() === 'k', ignoringrepeatandisComposing, on every platform (the top-bar buttons declarearia-keyshortcuts="Control+K Meta+K"). Every match callspreventDefault(), so the browser's own ⌘K / Ctrl K (focus the address or search bar) never runs while the app has focus, even over another dialog. While open it closes the search; while closed, with no other modal, it opens it; with another modal open it does nothing else. It works while focus is in a text field. Capture onwindowis required: a field that calls ReactstopPropagation()on keydown (DiaryFilterBar) stops native propagation at the React root, so a bubblingdocumentlistener never sees the key.useKeyboardShortcutsis not used for ⌘K because it ignores modified keys and editable targets. -
/anddata-page-search: the shell registers/once, as a fallback shortcut (useKeyboardShortcuts(…, { fallback: true })). It focuses and selects the page's own list field (the visible element markeddata-page-search: theDataTabletoolbar field andDiaryFilterBar), and opens the search when there is none. A page marks at most one fielddata-page-search, and pages no longer register/themselves.WorkItemsPageandMilestonesPagebuild their?help fromuseShortcutRegistry()?.snapshot(), so the shell's/andnrows stay listed. -
nowners (mechanism unchanged, see Dialog routes › Shortcuts): the shell's fallbacknopens the New menu; Tasks, Milestones, Purchases, Companies, Invoices and Site diary own a pagenthat opens their own create surface and wins there. -
"Search everywhere" from a list field:
SearchEverywhereRowunder a list's search input callsopen({ query, returnFocusRef: <the list field> }).DataTabletakessearchEverywhere(defaulttrue;UserManagementPagepassesfalse);DiaryFilterBarrenders the row too. Picker modals (DocumentBrowserinside a picker) get no row, because the search never opens over a modal. -
Results and history (refines Page identity › History rules):
- item results are
NavAnchors whosehrefcomes fromsearchResultTarget()(the route map'srouteUrl(), never a string from the response). They passstate={useOriginState()}, so the object page shows "Back to ‹origin›", and leavereplaceunset (neverreplace={false}): React Router's default then pushes, except when the target equals the current location, where it replaces, so choosing the object you are on adds no duplicate history entry; - "Go to" destinations follow the sidebar rule:
replacewhen the current page is its section's entry or a view (active.exact) and the destination resolves to the same section (resolveNavActive(pathnameOf(href), sections)?.sectionId === active.sectionId), otherwise push.commandSearchModelcomputes it from AppShell'sactiveandsections; - external (Paperless) rows and modified or middle clicks do not navigate in-app and keep the search open;
- when a result's target equals the current location, the host also calls
onClose()itself. React Router's same-URL replace already gives the location a new key, which closes the search; the explicit close only guarantees it does not depend on that.
- item results are
-
Destinations:
findSearchDestinations(query, ctx, labelOf)(client/src/navigation/searchDestinations.ts) matches NavConfig labels, glossary canon, German forms andformerlyaliases from the first character. NavConfig entries pass throughnavSections(ctx), so unserved, admin-only and Paperless-gated places never appear, and interim NavConfig views follow theirinterimUntil. Glossary terms without a NavConfig entry come fromTERM_EXTRAS(a route plus an optional preset filter, e.g. Paid → Invoices withstatus=paid). Extras that stand in for a planned page are listed inINTERIM_UNTILwith the route that replaces them (Area→areas,History→history) and drop out once that route is served, so a term never appears twice; the story that serves such a route deletes its extra and itsINTERIM_UNTILentry. A destination from NavConfig carrieslabelKeyand is labelledt(labelKey); any other uses the term's glossary form (chip ?? singular) in the UI language. -
Requests (
useCommandSearchQuery): 250 ms trailing debounce (immediate for a pre-filled query, Retry and Show more); oneAbortControllerper request, the previous one aborted; a response whosequerydiffers from the current trimmed query is dropped, and stale responses are never auto-activated. A cancelled request is detected withcontroller.signal.aborted, nevererr.name:apiClientwraps every fetch rejection,AbortErrorincluded, inNetworkError. BelowSEARCH_QUERY_MIN_LENGTH(2) nothing is sent and only destinations show. -
Session expiry:
apiClienthas no global 401 handling. A401fromGET /api/searchcallsuseAuth().refreshAuth(); the user becomesnull,AuthGuardredirects to login withnext, and the shell (and the search with it) unmounts. The error state shows in the meantime. -
Focus: initial focus is the input (
initialFocusRef). On dismissal focus returns throughuseFocusRestore; when opened withreturnFocusRef,skipOpenerRefstartstrue, so the list field wins over the row button that opened the search. On choosing a result the host nulls the return target, setsskipOpenerRefand callsfocusPageHeadingWhenReady()before the navigation, so focus lands on the destinationh1, never on<body>. Options are never tab stops (tabindex="-1", driven byaria-activedescendant), which is whyuseFocusTrapskipstabindex="-1"(App shell ›useFocusTrap). -
Pattern baseline:
plan/restructure/scripts/build-baseline.mjsexemptscomponents/CommandSearch/from search-field counting, likecomponents/SearchPicker/and*Picker*.tsx. The combobox is the one global search field and sits in the shell's import closure, so counting it would raisescreens.shell.searchFields.SearchEverywhereRowis a button, so no page'ssearchFieldsrises, and rows render throughNavAnchor, so the shell adds no click-count destinations.
One interaction grammar for every page (visual spec: Style Guide › Grammar foundations: StatusMenu, UndoToast, ConfirmDialog):
-
Status changes on existing records use
StatusMenu(with Undo); create forms, and diary drafts until saved, may set an initial status.StatusMenu(client/src/components/StatusMenu/) is the only control that changes the status of a saved record; edit forms of saved records carry no status field. Its rows come from the per-vocabulary builders instatusVocabularies.ts, which read the shared transition maps inshared/src/lib/statusTransitions.ts(TASK_TRANSITIONS,PURCHASE_TRANSITIONS,MILESTONE_TRANSITIONS,INVOICE_TRANSITIONS,PROGRESS_PAYMENT_TRANSITIONS,GRANT_TRANSITIONS,FUNDING_SOURCE_TRANSITIONS,DEFECT_TRANSITIONS). A signed or automatic defect gets no transitions and shows a plain badge. The server derives its depositALLOWED_TRANSITIONSfrom the same map, so client and server allow the same moves. No page builds its own transition list, and no edit form of a saved record has a status<select>or checkbox. A status change goes throughuseUndoableStatusChange, which PATCHes the record and shows theundotoast variant (useToast().showUndoToast,UNDO_TOAST_MS= 6 s, below the server'sUNDO_WINDOW_MS= 30 s). Undo (the toast button or Ctrl/⌘ Z) posts only the token toPOST /api/undo/:token. -
Destructive actions use
ConfirmDialog(client/src/components/ConfirmDialog/, built onModalwithrole="alertdialog"): every delete, remove, revoke, deactivate, restore and discard confirmation. Nowindow.confirm, no inline Confirm/Cancel swap, no hand-written overlay. Records that others reference show counts fromGET /api/delete-impact/:entityType/:idviauseDeleteImpact(API contract 19); the rest show no list. A409from the DELETE hides the action (blocked); other errors leave it enabled for a retry. The only exception is the bank-report wizard's confirmations, which story 4.11 moves to the dialog's plannedtone="primary". -
Dialog primitives live in
client/src/components/Modal/(Modal,Sheet,AnchoredPanel,scrollLock.ts,dialogLayer.ts,useFocusTrap.ts), so the pattern baseline'shandRolledDialogscount only sees real hand-rolled dialogs. -
Dialog stacking (#2213). Dialogs may stack (e.g. the Trades sheet's Merge
ConfirmDialogover it, or the company create dialog over a picker's hostModal).dialogLayer.tskeeps the openModals andSheets in open order, whatever their kind or DOM position (useDialogLevel). The first open dialog keeps its stylesheet layer (Modal--z-modal,Sheet--z-overlay); a dialog opened at level n ≥ 1 getscalc(var(--z-modal) + 2n)on its backdrop and+ 2n + 1on its panel (dialogLayerStyle). Only the topmost dialog handles Escape and Tab:useFocusTrapkeeps a stack and ignores keys in the others. Popovers (OverflowMenu's fixed portal.menuFixed,AnchoredPanel) sit atcalc(var(--z-modal) + 100), above every dialog, so a menu opened inside a stacked dialog is never covered. More than ~49 stacked dialogs would reach the popover layer; nothing comes close. ObjectForm flows still never stack (child step instead), per Dialog routes. - A→B→A leaves no History. A manual reverse move inside the Undo window, or an Undo, removes the forward move's system event instead of adding another (ADR-040 W1, W3, W4).
-
Undo snapshots (
server/src/services/undoService.ts, API contract 11). The eight status PATCH routes wrap their service call inrunUndoable, which reads the tracked columns before and after the synchronous call (on the work-item and purchase routes, everywork_itemsandhousehold_itemsrow, to catchautoReschedule), and collects the system events written or retracted during it. When a status column of the subject changed, it stores the snapshot under a randomu_…token and returns the token in the response. The store is in process memory (single container, ADR-003): bound to the user, single use (consumed by200and409), 30 s (UNDO_WINDOW_MS), at most 500 entries, cleared by a restart. No table holds snapshots. Undo restores the tracked columns in one transaction, refuses with409when any snapshot row changed since, writes no events and does not reschedule. -
Status event ledger (
server/src/services/statusEventLedger.ts). An in-process map of the status events this process wrote, used by ADR-040 W3 because automatic rows carry no user or from/to until story 2.4; it is also the event collector thatrunUndoablereads. A restart clears it, and the next event is then written normally. -
Delete impact (
server/src/services/deleteImpactService.ts, API contract 19). Read-only counts that mirror each DELETE's cascades and set-nulls, so the confirmation shows what the server will do. When a DELETE changes its cascade behaviour, its delete-impact counts change in the same PR (the QA parity test per type fails otherwise).
See ADR-028: Areas & Trades for detailed rationale.
EPIC-18 replaces the generic tagging system with two purpose-built dimensions:
Areas — hierarchical spatial organization of the construction project. Areas represent physical locations (e.g., "Ground Floor > Kitchen > Island") using a self-referencing parent_id with arbitrary nesting depth. Each work item and household item belongs to at most one area (M:1, nullable). Areas enable cost breakdowns by location ("how much did the kitchen cost?").
Trades — flat craft/skill classification. Trades represent the kind of work performed (e.g., "Plumbing", "Electrical"). Trades are assigned to vendors (not directly to work items). A work item's trade is derived transitively via work_items.assigned_vendor_id -> vendors.trade_id. This avoids denormalization while enabling trade-based cost analysis.
Trade management (#2213): trades are managed in the Trades sheet of the Companies page
(/companies?trades=1; trades is in the companies route's carries), not in Settings. The sheet
creates, renames, merges (POST /api/trades/:id/merge: the duplicate's companies move to the kept
trade, then the duplicate is deleted; no Undo) and deletes (blocked while companies use the trade).
Renaming a built-in trade, budget category or household-item category clears its translation_key, so
the typed name shows in every locale. Translated trade names exist only in the client, so text search
over companies (GET /api/vendors q, GET /api/search) takes a client-resolved tradeIds list
that the server ORs into the match.
color, description and sort_order stay in the schema and the API but no view renders the colour or
description and no screen edits the three: before #2213 the colour was shown only as a swatch in the
Settings Trades tab itself, so hiding it removes no capability. Trades are listed A–Z by translated name,
client-side. A future view that wants trade colour must add an editor back with it.
Vendor assignment: Work items support direct vendor assignment via assigned_vendor_id, mutually exclusive with assigned_user_id (enforced by CHECK constraint). This models the distinction: either you do the work yourself, or a contractor does.
Removed: tags, work_item_tags, household_item_tags tables. The vendors.specialty free-text field is replaced by vendors.trade_id FK. The household_items.room free-text field is replaced by household_items.area_id FK.
- In production, Fastify serves the Webpack-built client from
client/dist/ - SPA fallback: any non-
/api/route servesindex.html - In development, Webpack dev server (port 5173) proxies
/api/*to Fastify (port 3000)
All configuration is via environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port |
HOST |
0.0.0.0 |
Server bind address |
DATABASE_URL |
/app/data/cornerstone.db |
SQLite database file path |
LOG_LEVEL |
info |
Pino log level |
NODE_ENV |
production |
Environment (production/development) |
CURRENCY |
EUR |
ISO 4217 currency code for display (e.g., EUR, USD, CHF) |
| Variable | Default | Description |
|---|---|---|
SESSION_DURATION |
604800 |
Session lifetime in seconds (default: 7 days) |
SECURE_COOKIES |
true |
Set Secure flag on cookies; set to false for local dev without TLS |
TRUST_PROXY |
false |
Trust X-Forwarded-* headers from a reverse proxy |
AUTH_RATE_LIMIT_MAX |
20 |
Login endpoint rate limit: max requests per IP per window (positive integer) |
AUTH_RATE_LIMIT_WINDOW |
15 minutes |
Login endpoint rate limit: time window (ms-library format, e.g. 15 minutes, 1h) |
OIDC_ISSUER |
(none) | OIDC provider issuer URL |
OIDC_CLIENT_ID |
(none) | OIDC client ID |
OIDC_CLIENT_SECRET |
(none) | OIDC client secret |
OIDC_JIT_PROVISIONING |
false |
Opt-in: create a member account when an OIDC sign-in matches no account |
EXTERNAL_URL |
(none) | Public base URL (e.g., https://cornerstone.example.com); OIDC callback origin |
OIDC is enabled when OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET are all set — three
variables, not four (server/src/plugins/config.ts, oidcEnabled). If any of the three is missing,
the OIDC endpoints are unavailable. OIDC_JIT_PROVISIONING is not part of enablement and is inert
while OIDC is disabled. It accepts true/false (case-insensitive); any other value fails startup.
The callback URL is not configured through a dedicated environment variable — there is no
OIDC_REDIRECT_URI, and the server never reads one. The redirect URI sent to the provider is derived
per request as <EXTERNAL_URL>/api/auth/oidc/callback, falling back to the incoming request's own
origin (request.protocol + request.host) when EXTERNAL_URL is unset. Behind a reverse proxy,
set EXTERNAL_URL: both halves of the fallback are gated on TRUST_PROXY — request.protocol
reads X-Forwarded-Proto and request.host reads X-Forwarded-Host only when TRUST_PROXY=true,
so with it unset the fallback yields the container's own internal scheme and host, not the public
one. A correct scheme is therefore not sufficient to make the fallback safe. The redirect URI
registered with the OIDC provider must match the derived value exactly.
The login rate limit (POST /api/auth/login) is keyed on the client address after
normalization — IPv4 addresses key per-address, IPv4-mapped IPv6 addresses are unwrapped to their
dotted IPv4 form, and all other IPv6 addresses are truncated to their /64 network prefix so that
an entire IPv6 allocation shares one bucket. Behind a reverse proxy, TRUST_PROXY=true is required
for the real client address to be resolved — otherwise every request appears to come from the proxy
and the limit is effectively shared across all clients. The first-user setup endpoint
(POST /api/auth/setup) has a separate, fixed limit of 5 requests per 15 minutes and is not
configurable.
Normalization is @fastify/rate-limit's normalizeIP. The invariant is that the rate-limit key is
always normalizeIP-normalized, at the same subnet width the plugin is configured with.
rateLimitPlugin satisfies it with an explicit exported generator —
rateLimitKeyGenerator(request) => normalizeIP(request.ip ?? 'unknown', IPV6_SUBNET) — and passes
that same IPV6_SUBNET constant (64) as the plugin's ipv6Subnet option, so the two cannot drift.
The override is required, not stylistic: request.ip is typed string but is genuinely null when
the socket has no address metadata (and normalizeIP dereferences its argument, so an unguarded call
turns every rate-limited endpoint into a 500), and the library applies its own normalization only
when keyGenerator is the exact defaultKeyGenerator reference — any custom generator is invoked
without a subnet argument and must therefore call normalizeIP itself. A generator that returns
request.ip verbatim skips the /64 truncation and lets an attacker rotate addresses within their own
prefix to bypass the limit; that regression was CVE-2026-15144 (issue #1995). Hand-rolling
X-Forwarded-For / X-Real-IP parsing inside the generator is also unnecessary, since TRUST_PROXY
already makes request.ip reflect those headers. See
API Contract → Rate-Limit Key for the per-form key table and the full
list of invariants any change to rateLimitKeyGenerator must preserve.
| Variable | Default | Description |
|---|---|---|
PAPERLESS_URL |
(none) | Base URL of the Paperless-ngx instance (e.g., http://paperless:8000) |
PAPERLESS_API_TOKEN |
(none) | API authentication token for Paperless-ngx (obtain from Paperless-ngx admin panel) |
Paperless-ngx integration is enabled when both PAPERLESS_URL and PAPERLESS_API_TOKEN are set. If either is missing, all /api/paperless/* endpoints return 503.
| Variable | Default | Description |
|---|---|---|
BACKUP_DIR |
/backups |
Backup destination directory (must not be app data directory or its subdirectory) |
BACKUP_CADENCE |
(none) | Cron expression for automatic backups (e.g., 0 2 * * * for daily at 2 AM) |
BACKUP_RETENTION |
(none) | Maximum number of backup archives to retain (oldest deleted when exceeded) |
Backups are always enabled. BACKUP_DIR defaults to /backups (an empty value is treated as unset and falls back to the same default); mount a volume at /backups (or point BACKUP_DIR elsewhere) to keep archives outside the container. A BACKUP_DIR equal to or inside the app data directory fails config validation at startup. There is no "not configured" state and no enablement flag: the /api/backups/* handlers have no 503 path, and a backup directory that cannot be created or written fails the backup itself with a 500 when it runs. BACKUP_CADENCE alone controls whether the in-process scheduler runs; without it, backups are manual only. Automatic scheduling uses node-cron (in-process, no external cron dependency).
Backups are .tar.gz archives in format v2 (ADR-037): the app data directory under its top-level folder, with a consistent SQLite backup() snapshot as the only database and a cornerstone-backup-manifest.json naming it. The live database and its -wal/-shm/-journal sidecars, stray snapshots and the restore-reserved names (.restore-*, .pre-restore-*, lost+found) are excluded. Archives written before v2 still restore, preferring their own consistent snapshot over the live copy they also contain.
Restore never renames the data directory, which is a VOLUME mount point in the container. It extracts into .restore-staging-<ts>/ inside the data directory and validates the staged database (integrity check, _migrations present, migration level not newer than the running code) while the live database stays open; any rejection is logged as RESTORE_FAILED, staging is removed and the server keeps serving the old data. It then closes the database and swaps the directory's contents with same-filesystem renames: originals into .pre-restore-<ts>/, staged entries into place. A three-phase marker (.restore-state.json: moving-aside, moving-in, swapped) lets startup recovery in the db plugin finish or roll back a swap interrupted by a crash, and an invalid marker fails startup rather than guessing. On success the moved-aside originals are deleted (no undo copy is kept) and the process exits with code 0; a failed swap is rolled back and the process exits with code 1. Restore is a full replacement: files added since the backup, including photos, are removed.
Restart policy requirement: the container must run with restart: unless-stopped (as docker-compose.yml ships) or always. on-failure does not restart after the exit code 0 of a successful restore, and a container without a restart policy stays stopped. Limitation: a PHOTO_STORAGE_PATH outside the app data directory is neither backed up nor restored. A restore temporarily needs free space on the data volume of about the extracted archive size.
| Variable | Default | Description |
|---|---|---|
DIARY_DRAFT_RETENTION_DAYS |
30 |
Number of days a draft diary entry can sit untouched before the daily orphan-cleanup job hard-deletes it (and its photos). Set to 0 to disable cleanup. |
DIARY_AUTO_EVENTS — Planned removal (#2223). The server still reads this variable (default true; false suppresses all automatic diary entries), although this table never listed it. #2223 removes it: system events are always written and a stale value has no effect (ADR-040 §4).
The orphan cleanup job is a node-cron task that runs daily at 03:00 server time (in-process, sharing the scheduler pattern with backupService). It deletes diary entries where status = 'draft' AND updated_at < datetime('now', '-N days'). Photo cascade uses the existing deletePhotosForEntity path. See ADR-022 for the full draft model.
- Single Docker container built from a multi-stage Dockerfile
- Production image uses DHI (Docker Hardened Images) Alpine with a non-root user
- SQLite data persisted via a Docker volume at
/app/data/ - HTTPS handled by an upstream reverse proxy (nginx, Traefik, Caddy, etc.)
- Health check endpoints:
GET /api/health(liveness) andGET /api/health/ready(database query on every call, plus a password-hashing self-check that runs once per process and caches only a success). The image'sHEALTHCHECKprobes/api/health/readyevery 30 s.
The development sandbox environment has only 4 GB of total RAM. This is insufficient to run the full Jest test suite (34+ test files across 3 workspaces) with default parallelism settings. Each jsdom worker loads React, react-dom, react-router, and testing-library, consuming approximately 200--300 MB per worker. With Jest's default worker count (number of CPU cores minus one), the suite exhausts memory and the Node.js process is killed by the OOM reaper.
The following flags have been applied to all three test scripts (test, test:watch, test:coverage) in the root package.json:
| Flag | Value | Purpose |
|---|---|---|
--max-old-space-size |
2048 |
Caps the V8 heap at 2 GB (Node.js flag), preventing a single process from consuming all available memory |
--maxWorkers |
2 |
Limits Jest to 2 parallel workers instead of auto-detecting CPU cores |
--workerIdleMemoryLimit |
300MB |
Recycles any Jest worker whose memory exceeds 300 MB, preventing unbounded heap growth from jsdom leaks |
Example of the current test script:
node --max-old-space-size=2048 --experimental-vm-modules node_modules/.bin/jest --maxWorkers=2 --workerIdleMemoryLimit=300MB
These flags do not affect production -- they only apply to the development test runner. The --experimental-vm-modules flag is required for ESM support in Jest and is unrelated to the memory constraint.
-
Slower test runs: With only 2 workers, the full suite runs sequentially across fewer processes. Wall-clock time is roughly 2-3x longer than it would be with
--maxWorkers=autoon a machine with 4+ cores. -
Worker recycling overhead: The
--workerIdleMemoryLimitflag causes Jest to terminate and respawn workers mid-run, adding per-recycle overhead (approximately 1-2 seconds each time). - No functional impact: All tests produce the same results regardless of worker count or memory limits. Coverage numbers are unaffected.
When the sandbox memory increases (8 GB or more), revert the memory-constrained test scripts in the root package.json to their ideal configuration:
Current (constrained):
"test": "node --max-old-space-size=2048 --experimental-vm-modules node_modules/.bin/jest --maxWorkers=2 --workerIdleMemoryLimit=300MB",
"test:watch": "node --max-old-space-size=2048 --experimental-vm-modules node_modules/.bin/jest --watch --maxWorkers=2 --workerIdleMemoryLimit=300MB",
"test:coverage": "node --max-old-space-size=2048 --experimental-vm-modules node_modules/.bin/jest --coverage --maxWorkers=2 --workerIdleMemoryLimit=300MB"Target (unconstrained):
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch",
"test:coverage": "node --experimental-vm-modules node_modules/.bin/jest --coverage"Changes to make:
-
Remove
--max-old-space-size=2048from all three scripts (let V8 use its default heap limit) -
Remove
--maxWorkers=2from all three scripts (let Jest auto-detect the optimal worker count) -
Remove
--workerIdleMemoryLimit=300MBfrom all three scripts (or raise to512MBif jsdom memory leaks are still a concern) -
Keep
--experimental-vm-modules-- this is required for ESM support and is unrelated to memory
In progress. The route map, routeUrl(), generated redirects and NavConfig data are implemented
(#2201; see Frontend Conventions › Route map), and page identity is live for Tasks, Purchases,
Milestones (#2202) and Money (#2203; see Frontend Conventions › Page identity), and the grammar foundations (StatusMenu, UndoToast, ConfirmDialog, Undo snapshots, delete impact) are live (#2209; see Frontend Conventions › Grammar foundations); the rest is planned. The UX/IA restructure ("one home per thing", owner-validated 2026-10-07) is
tracked as one epic, EPIC-21 (UX restructure, #2190), delivered in phases with a single beta → main promotion
at the end. Its architecture:
| Record | Decides |
|---|---|
| ADR-038: Information Architecture v2 | The sidebar is the navigation (no tab rows on Tasks, Money, Settings; views show under their section while inside; title menu on phones and tablets); a shared route map (shared/src/routes/) plus a client NavConfig as the single route source, used by the server's vCard/iCal writers too; today's URLs are kept, old URLs redirect in one hop, externally stored URLs are permanent; the reachability rule (≤ 2 clicks) and the click-counting rule |
| ADR-039: One Money Service | One server module computes every money figure; "Left to spend" with range and the strict verdict (Over budget whenever the figure is below zero, no "Tight"); grants as a capped percentage of explicitly listed items; a closed money-label registry |
| ADR-040: System Events | Events stay in diary_entries but carry versioned parameters in metadata, are de-duplicated at write time (incl. A→B→A inside the Undo window), render client-side in the UI language, leave the diary for History; a data-only backfill; exact repeats collapse on display; nothing is deleted |
| Route Map | Every current URL and where it lands |
| API Contract › EPIC-21 | The plan's 18 contracts plus 2a and 19 (delete impact, added by #2209), each marked with its plan story and security-review need |
Constraints that hold for the whole epic: no schema change (owner decision Q3; ADR-040 and contract
18 add data-only migrations), no real household data in the repository, issues, PRs or this wiki
(Q18; synthetic fixtures and examples only), and every story keeps the app coherent on beta.
The database schema and API contract evolve incrementally as each epic is implemented. See:
- Schema -- current database schema documentation
- API Contract -- current REST API specification
This log records where the page drifted from the implementation and how the drift was resolved. It carries two payloads: what is true now, and what we previously believed and how we got it wrong. Only the second justifies keeping a log instead of a changelog.
Corrections are made forward. The Deviation cell of an existing row — the observation — is
immutable. Once entries can be silently rewritten, no reader can tell whether a claim was ever made,
and an instrument built to catch recurring drift starts erasing its own misses. When a row turns out
to be wrong, append to its Resolution cell, leading with Correction to the observation above:,
and name the wrong claim, state what is actually true, and cite the source. A wholly spurious entry is
withdrawn in its Resolution, never deleted. Typographical fixes and cell re-padding are editable
in place.
| Date | Page Section | Deviation | Resolution |
|---|---|---|---|
| 2026-08-04 | Environment Variables (Auth) | The login rate-limit paragraph said the limit "is keyed on the client IP", presenting the raw address as the bucket key. That was never accurate for IPv6 under @fastify/rate-limit 11.2.0, whose default keyGenerator normalizes request.ip (IPv4-mapped unwrapping plus /64 truncation). The wording also invited the CVE-2026-15144 regression (issue #1995): a custom keyGenerator returning request.ip verbatim reads as a faithful implementation of the documented behaviour while silently removing the /64 grouping that prevents bypass via address rotation within an attacker's own prefix. |
Wiki was outdated and misleading; code (after PR #1998, refined in PR #1999) is correct. Rewrote the paragraph to describe the normalized key (IPv4 per-address, IPv4-mapped unwrapped, other IPv6 truncated to the /64 prefix), noted that hand-rolled X-Forwarded-For/X-Real-IP parsing is redundant under TRUST_PROXY, and cross-linked the API Contract's "Rate-Limit Key" table. The first pass of this entry stated that rateLimitPlugin intentionally defines no keyGenerator; PR #1999 invalidated that, since an explicit override is required — request.ip is typed string but can be null at runtime (normalizeIP dereferences it, producing a 500), and the library applies its own normalization only for the exact defaultKeyGenerator reference, so a custom generator must call normalizeIP itself. The paragraph now documents rateLimitKeyGenerator (normalizeIP(request.ip ?? 'unknown', IPV6_SUBNET), with the same IPV6_SUBNET passed as the plugin's ipv6Subnet so the two cannot drift) and why the override exists. Source of truth: normalizeIP/defaultKeyGenerator and the generator-identity gate in @fastify/rate-limit 11.2.0 (defaultIPv6Subnet = 64), and server/src/plugins/rateLimitPlugin.ts. This Deviation Log table did not previously exist on this page and was created by this entry. |
| 2026-08-05 | Key Architectural Patterns |
The wizard tier-factory convention was never documented anywhere. The freshXTier()-spread rule in wizardReducer.ts — adding a field to a tier interface is a compile error in its factory, so no reset path can silently forget it — was introduced in #1947 to close a hazard that had already produced three defects (#1943, #1946, and the one #1947 was raised for), but existed only as code comments. A reviewer on PR #2010 (#1973) had to reconstruct it from the reducer to judge whether DISCARD_EDITS's new hiddenColumns: state.hiddenColumns write was drift or a deliberate exception. |
Wiki was silent; code is correct. Added a "Multi-step wizard state: tier factories" subsection stating the convention, the rule that tier membership is decided by reset lifecycle rather than by topic, the single deliberate DISCARD_EDITS opt-out with the explicit limit that a second opt-out must trigger a tier split rather than another exception, and the per-run-state vs user_preferences boundary that R5 of #1973 settled. |
| 2026-08-06 | Authentication & Sessions | The Authentication & Sessions table documented an OIDC_REDIRECT_URI variable that server/src/plugins/config.ts never reads, and the sentence below it claimed "OIDC is enabled when all four OIDC variables are set". The gate is three variables (config.ts:142: oidcEnabled = !!(oidcIssuer && oidcClientId && oidcClientSecret)), and the callback URL is not configurable at all — it is derived per request from EXTERNAL_URL, which the table did not list. An operator would set a variable that has no effect, or suspect a misconfiguration after correctly setting the three real ones. |
Wiki was outdated; the code is correct (issue #1992, the tracked follow-up flagged in the API Contract's Deviation Log by PR #1989). Removed the OIDC_REDIRECT_URI row and put EXTERNAL_URL in its place, restated the gate as three variables, and added a paragraph explaining that the redirect URI is derived per request as <EXTERNAL_URL>/api/auth/oidc/callback, falling back to request.protocol + request.host when EXTERNAL_URL is unset (and that those honour X-Forwarded-* only under TRUST_PROXY=true). Source of truth: server/src/plugins/config.ts:137-142 and server/src/routes/oidc.ts:45. Wiki-only change; no production code touched. The mirrored correction on the API Contract page is recorded in that page's own Deviation Log. |
| 2026-09-28 | Paperless-ngx Integration | The API version pinning paragraph stated that upstream requests send version=5. Current Paperless-ngx accepts only versions 9 and 10 and rejects anything else with 406, so the documented pin (which matched the code) meant the integration failed on every JSON call. |
Wiki and code were both outdated (issue #2097). The paragraph now states the version=10 pin via the PAPERLESS_API_VERSION constant, the 406 to 502 PAPERLESS_ERROR mapping, and that binary passthroughs send no Accept header. It links to ADR-015 §5, which holds the details and its own Deviation Log entry. |
| 2026-09-30 | Authentication & Sessions | The OIDC bullet said "Automatic user provisioning on first login". Issue #1865 removed provisioning (an open provider such as Google let anyone self-register) and replaced the email-conflict rejection with verified-email linking. | Deliberate design change recorded in ADR-035; bullet rewritten. Source of truth: server/src/routes/oidc.ts, server/src/services/userService.ts. |
| 2026-09-30 | Environment Variables (Backup & Restore) | Issue #2023. The table gave BACKUP_DIR no default and the paragraph said backups are enabled only when it is set, with all /api/backups/* endpoints returning 503 otherwise. server/src/plugins/config.ts defaults BACKUP_DIR to /backups (empty string also falls back), so backupEnabled is always true and the 503 is unreachable. |
User decision 2026-09-30 (issue #2023 AC4): code is right, docs were wrong. Default set to /backups; paragraph now says backups are always enabled, that BACKUP_CADENCE alone gates the scheduler, that an in-data-dir BACKUP_DIR fails at startup, and that the 503 BACKUP_NOT_CONFIGURED guard is retained only defensively. Mirrored on the API Contract page's Deviation Log. |
| 2026-09-30 | Key Architectural Patterns (Multi-step wizard state: tier factories) | Issue #2014 finding. The subsection said "One deliberate opt-out exists" (hiddenColumns in DISCARD_EDITS). The reducer has two more post-spread write-backs on the same tier, both older than the tier-factory convention (from the useReducer refactor, 217cb408): SELECT_SOURCE restores aiError unconditionally, and DISCARD_EDITS restores it when aiRequestId === null. The 2026-08-05 entry above wrote the section from the hiddenColumns exception alone and missed both. A reader who counted write-backs would conclude the "second opt-out means split the tier" limit had already been exceeded. |
Wiki was incomplete; code is correct. Decision: aiError is an error-display carve-out, not a tier-membership opt-out. Its value is owned by the AI_GENERATION_* events rather than by a reset lifecycle of its own, and SELECT_USE_CASE still clears it. The section now names both write-backs, explains the conditional in DISCARD_EDITS (an in-flight request is abandoned by the same spread, so its error is cleared), states the three conditions under which a write-back qualifies as a carve-out, and keeps the one-opt-out limit for tier membership. The reducer comment is updated in the same package to list both write-backs. Source of truth: client/src/pages/ReportWizardPage/wizardReducer.ts (SELECT_SOURCE, DISCARD_EDITS, AI_GENERATION_* cases). |
| 2026-10-01 | Authentication & Sessions, Environment Variables (Auth) | Issue #2142. The OIDC bullet said OIDC "never self-registers", and the env table had no provisioning variable. Accurate until #2142, which adds opt-in provisioning; a decision change, not drift. | Decision changed (ADR-035 amendment #2142, homeowner request). Bullet now states the default-off OIDC_JIT_PROVISIONING opt-in, its member-only scope, the ambiguous-email and empty-users refusals, and the no-allow-list risk. Env table gained the row; the enablement paragraph says the flag is not part of the three-variable OIDC enablement. Source of truth: server/src/plugins/config.ts, server/src/services/userService.ts (findOrLinkOidcUser). Mirrored on the API Contract page's Deviation Log. |
| 2026-10-01 | Construction Diary (Immutability rules) | The bullet said automatic entries cannot be deleted and did not describe the signature lock. Story #808 allows deleting automatic entries. | Wiki was outdated (#808) and the signature lock changed in #2124. Bullet now states automatic entries can be deleted and that signatures lock an entry only once saved (403 on update, 409 on photo mutations), with drafts fully editable. Source of truth: server/src/routes/diary.ts, server/src/routes/photos.ts, isDiaryEntrySignatureLocked in @cornerstone/shared. |
| 2026-10-01 | Construction Diary (Immutability rules) | The diary-bundle change (#2124/#2125) switched the six photo mutations on a saved, signed diary entry (upload, PATCH /:id, delete, PATCH /reorder, annotation PUT/DELETE) from 409 CONFLICT to 403 IMMUTABLE_ENTRY. The bullet, written earlier the same day, said 409. |
Decision change (409's generic "may already exist" copy misleads users). Bullet updated. Source of truth: server/src/routes/photos.ts. |
| 2026-10-01 | Construction Diary (Signatures stored inline) | PR #2152 architecture and security review. The bullet said 500 KB, which nothing enforced. The new validation caps signatures at 512 KB. | Bullet now states the enforced rules: data:image/(png|jpeg|webp), 512 KB each, at most 10 per entry. |
| 2026-10-01 | Construction Diary (Signatures stored inline) | Correction to the same-day signature-validation entry: signerName was documented as capped at 200 characters after trimming; the enforced cap is 300, because vendor names may be 200 characters and the composed signer name is vendor (signatory). |
Changed 200 to 300 with that rationale. |
| 2026-10-01 | Tech Stack | PR #2155 architecture review (#1823, #2138). The Tech Stack table had drifted from the manifests: Playwright said 1.59.x (e2e/package.json pins @playwright/test 1.63.0), Drizzle ORM said 0.38.x (server/package.json pins drizzle-orm 0.45.3), and Runtime said 24 LTS while root engines.node is now >=24.11.0 (Babel 8 requires `^22.18.0 |
|
| 2026-10-01 | Backup & Restore | Issue #2132. The paragraph said backupEnabled is always true and that the /api/backups/* handlers keep a defensive 503 BACKUP_NOT_CONFIGURED guard that no configuration can reach. The guard, the flag and the error code were dead code. |
Code changed: backupEnabled, its five route guards, the two service guards, BackupNotConfiguredError and the BACKUP_NOT_CONFIGURED code are removed, and the scheduler is gated on BACKUP_CADENCE alone. The paragraph now states there is no "not configured" state, and that an uncreatable or unwritable backup directory fails the backup with a 500. Supersedes the defensive-guard wording of the 2026-09-30 #2023 row. Mirrored on the API Contract page's Deviation Log. |
| 2026-10-02 | Backup & Restore | PR #2168 review, Issue 3 (refs #2132). The paragraph said backups are .tar.gz archives of the entire app data directory and that restore replaces the app data directory. Both were true of the code and both were the defect: the archive also held the live cornerstone.db, -wal and -shm, copied non-atomically, and restore used that copy rather than the consistent snapshot; restore renamed /app/data (a VOLUME mount point, EBUSY) and staged under dirname(BACKUP_DIR) (/ for the default, EACCES and EXDEV). Restore could not succeed in the shipped container. |
Code fixed and decision recorded in ADR-037 (new). The paragraph now describes archive format v2 (snapshot-only plus manifest, v1 still restorable), in-volume staging with validation while the database stays open, the content swap with its three-phase crash-recovery marker and startup recovery, exit codes 0 and 1, full replacement with no undo copy, the unless-stopped/always restart-policy requirement, and the outside-dataDir PHOTO_STORAGE_PATH limitation. Source of truth: server/src/services/restoreSwap.ts, server/src/services/backupService.ts, server/src/plugins/db.ts. Mirrored on the API Contract page's Deviation Log. |
| 2026-10-02 | Authentication (local flow), Deployment (health check) | The local-authentication line said passwords are hashed with argon2id; the server has used crypto.scrypt since PR #72 (2026-02-16). The Deployment list named only GET /api/health, but the Docker HEALTHCHECK probes GET /api/health/ready. Found in the #2122 hashing-hardening pass. |
Drift, now corrected, plus the #2122 hardening (ADR-010 Amendments: scrypt N = 2^17, legacy N = 2^14 accepted and rehashed on login, readiness self-check cached on success). Both lines rewritten. Mirrored on the API Contract page's Deviation Log. Source of truth: server/src/services/userService.ts, server/src/app.ts, Dockerfile. |
| 2026-10-09 | Diary Drafts (environment variables) |
DIARY_AUTO_EVENTS is read by the server (false suppresses all automatic diary entries) but was never listed on this page. |
Drift noted; owner decision at EPIC-21 approval removes the variable in #2223 (system events always written, a stale value has no effect). A Planned-removal note was added under the Diary Drafts table instead of a table row. See ADR-040 §4. |
| 2026-10-10 | Frontend Conventions › Page identity (origin validation, in-app path rule, deep links, title options, objectNames) |
#2204 (story 0.11c). readOrigin had its own path check (starts with /, not //, no \), and the server's isSafeRedirect had a different, weaker one that accepted /\evil.example (an off-site redirect once a browser reads \ as /). Two rules for the same question had already drifted. Signed-out deep links lost their target (D-13), and local sign-in navigated before AuthContext knew the user. |
Design change: one shared rule, safeAppPath, adds the control-character, :// and length checks and is now the only check for redirect, next and origin paths. Added the deep-link flow (?next=, refresh before navigating, SSO ?redirect=), the useDocumentTitle options and the objectNames null = skip meaning. The rule is also in .claude/checklists/implementation-checklist.md › EPIC-21. Mirrored on the API Contract's Deviation Log for the OIDC endpoints. |
| 2026-10-10 | Frontend Conventions › Route map/NavConfig, Sidebar from NavConfig, Page identity (history rules) | #2205 (story 0.12). The page described NavConfig as data only and named SubNav (NavLink replace) as the tab-row history rule; the sidebar still used NavLink prefix matching and ten copied tab arrays plus ScheduleSubNav rendered tab rows. Deleting BUDGET_TABS alone would have left Funding sources, Grants and the Bank report without a navigation entry until Financing exists. |
Added Sidebar from NavConfig (rendering, navHref, the single-active-entry rule resolveNavActive, the no-tab-rows guard) and interim views (interimUntil) to the NavConfig bullet; the tab-row history rule is gone. Source of truth: client/src/navigation/{navConfig,navActive,useNavContext}.ts, client/src/components/Sidebar/Sidebar.tsx. |
| 2026-10-10 | Frontend Conventions › Sidebar from NavConfig (History), Page identity (history rules) | #2205 architect review. The history-rules bullet said a sidebar link replaces only when its target equals the current location (React Router Link default), and the Sidebar section had no history rule; the code replaces on every switch within the current section from its entry or a view (replace = isCurrent && active.exact, ADR-038 §1 rule 8). |
Added the History bullet to Sidebar from NavConfig and rewrote the history-rules bullet: replace within the current section from its entry or a view, push from a detail page or into another section. Source of truth: client/src/components/Sidebar/Sidebar.tsx, client/src/navigation/navActive.ts. |
| 2026-10-10 | Frontend Conventions › Page identity (Placement), App shell and top bar | #2206 (story 1.1). Placement said the breadcrumb row always renders above the page's header row. From #2206 the desktop shell (≥ 1024 px) hosts the row in the top bar's slot. The page also had no section on the shell structure, the bar's sticky position, --topbar-height, the shell breakpoint, the OverflowMenu entry contract or the shortcut registry. |
Design change: Placement now says that PageBreadcrumbs portals the row into the bar at ≥ 1024 px when the slot exists and renders it inline otherwise (one instance at any time). Added App shell and top bar (#2206). Source of truth: client/src/navigation/PageBreadcrumbs.tsx, client/src/components/AppShell/AppShell.tsx. |
| 2026-10-10 | Frontend Conventions › App shell and top bar, Page identity (Placement, Back to origin) | #2207 (story 1.2a). The shell section described the interim phone layout: a drawer sidebar opened by a floating menu button, an overlay, a display: none top bar below 1024 px, a CSS breakpoint shared by four stylesheets (one of them ThemeToggle.module.css), div.footerLegacy with the theme toggle and Log out, and AppShellPage.needsDrawer(). Placement said the breadcrumb row renders inline below 1024 px, Back said it is never history.back(), and the OverflowMenu contract said there is no render-prop trigger. All of that changed with the phone and tablet shell: a mounted (JS) layout switch, compact top bar, bottom bar, More sheet (Sheet in components/Modal/, role only while open), NavAnchor, the compact Back link with its history rule, the title menu (renderTrigger, current, bottom-start), --shell-bottom-height and --touch-target-min. The section was rewritten in the same PR as the code; the drawer, FAB, overlay, .footerLegacy and ThemeToggle are gone. |
|
| 2026-10-10 | UX Restructure (EPIC-21) table, Frontend Conventions › Grammar foundations, Undo snapshots and delete impact | #2209 (story 1.3). The EPIC-21 table said the API Contract holds "the 18 planned contracts (plus 2a)"; #2209 adds contract 19 (GET /api/delete-impact/:entityType/:id). The page had no rule for status controls or destructive confirmations, and no description of the in-memory Undo store. |
Table row updated. New sections: the StatusMenu + UndoToast and ConfirmDialog rules, and the in-process Undo snapshot store, status event ledger and delete-impact service. ADR-040 W3 implementation deviation is logged on ADR-040. |
| 2026-10-10 | Frontend Conventions › Grammar foundations; Undo snapshots | PR #2285 review round 1. The rule said status changes use StatusMenu with no exception, but create forms (and diary drafts, which are captures not yet created) legitimately set an initial status; the map list lacked grants, funding sources and defects; the Undo paragraph counted seven routes. | Rule narrowed to existing records (create forms and drafts may set an initial status; saved-record edit forms carry no status field). Grant, funding-source and defect maps listed; eight routes (funding sources added). |
| 2026-10-11 | Frontend Conventions › Page identity (title ordering, history rules), App shell and top bar (New placeholder, bottom bar + slot, Sheet, NavAnchor, OverflowMenu, shortcut registry); new section Dialog routes, the New menu and ObjectForm |
#2212 (story 1.6). The page described create flows as pages (/…/new rendering a create page whose Cancel replaces with the origin), the top-bar New as an inert placeholder, the + slot as a link to /diary/new, Sheet as bottom-only, and the shortcut snapshot as plain last-wins. |
New section documents the dialog-route mechanism (history-state background, Routes location={display}, readBackground through safeAppPath, Cancel/Save/Back history, the POP hold for "Discard changes?"), CreateMenu, ObjectForm, context pre-fill and useCreateQuery. Create Cancel over a background is navigate(-1); New is live; + opens the New sheet; Sheet gains variant="fullScreen" and the shared useFocusRestore; NavAnchor gains state, OverflowMenu gains controlRef; fallback shortcuts lose to page shortcuts and shortcuts are off while a modal is open. |
| 2026-10-11 | Frontend Conventions › Dialog routes, the New menu and ObjectForm (context pre-fill, real location) | PR #2287 architect review. The section said each create route's carries lists exactly the keys it reads, but diaryEntryNew carries areaId as well as date while DiaryEntryCreatePage reads only date. It also did not say which location code inside <Routes location={display}> must use to build URLs: AuthGuard read the display location, so a signed-out deep link to a create URL got next = the parent list and lost the dialog and its pre-fill. |
carries reworded: it lists the keys a route reads and may also reserve keys for a planned surface of the same route (areaId for the P2.3 capture screen). Added the rule that anything inside <Routes location={display}> that builds a URL from the requested location, AuthGuard included, reads the real location through useRealLocation() (navigation/realLocation.ts), so next keeps the full create URL. Source of truth: client/src/navigation/realLocation.ts, client/src/components/AuthGuard/AuthGuard.tsx, shared/src/routes/routeMap.ts. |
| 2026-10-11 | Frontend Conventions › App shell and top bar (Search placeholders, useFocusTrap); new section Command search |
#2211 (story 1.5). The shell section still described the desktop Search button and the compact search icon as inert placeholders, the page had no description of the command search, and useFocusTrap counted [href] elements with tabindex="-1" as tab stops. |
Search is live in both top bars. New section documents CommandSearchProvider/useCommandSearch, the request keyed by the real location key, mount-only-while-open with the shell root inert, the window capture-phase ⌘K listener (preventDefault() on every match), the single shell / fallback with data-page-search, the history rules for item results (replace left unset, so the same URL replaces) and "Go to" destinations, INTERIM_UNTIL, abort detection by signal, 401 → refreshAuth(), focus return and the baseline exemption. useFocusTrap skips tabindex="-1". Source of truth: the #2211 spec and client/src/components/CommandSearch/. |
| 2026-10-11 | Frontend Conventions › App shell and top bar (More sheet, Sheet options), Command search (open state, same-URL close) |
PR #2288 architect review. The code drops a command-search request whose key no longer matches during render, which the page did not say; the More and New sheets kept a stale open key, so Back then Forward reopened them (history entries keep their key). Sheet gained hideTitle and contentMaxWidth, and Modal hideTitle and variant="palette", without a mention. The same-URL bullet implied the location key does not change, but React Router's same-URL replace issues a new key. |
Documented the stale-key drop for the search and the sheets (the sheets' fix lands in PR #2288), the new Sheet/Modal props, and the corrected same-URL note. Source of truth: client/src/components/AppShell/AppShell.tsx, client/src/components/Modal/{Sheet,Modal}.tsx. |
| 2026-10-11 | Frontend Conventions › Dialog routes, the New menu and ObjectForm; Areas & Trades | #2213 (P1.7). Companies were listed as an interim ?create=1 surface until 5.10b, and the New menu's Company row opened /companies?create=1; the section had no way to create an object from a picker outside an ObjectForm. |
companyNew (/companies/new) joins DIALOG_ROUTE_IDS (parent companies); the Company row opens it as a dialog; ?create=1 on /companies is replaced by it. New CompanyCreateProvider bullet for picker create. Trade management paragraph added. |
| 2026-10-11 | App shell and top bar › Bar stacking; Frontend Conventions › Grammar foundations (Dialog stacking) | #2213 dev-team-lead review. The bar-stacking note put portaled page menus at the sheet's level (--z-overlay) below modals, and the page had no rule for one dialog opened over another: stacked dialogs shared one layer, so which one painted on top depended on DOM position, and nothing said which open dialog handles Escape and Tab. |
#2213 adds dialogLayer.ts (open-order levels: first dialog keeps its layer, level n at calc(var(--z-modal) + 2n)), a stacked useFocusTrap (topmost dialog only), and moves the OverflowMenu portal and AnchoredPanel to calc(var(--z-modal) + 100). Note and new Dialog stacking bullet updated. Source of truth: client/src/components/Modal/dialogLayer.ts, useFocusTrap.ts, OverflowMenu.module.css, AnchoredPanel.module.css. |
| 2026-10-16 | Internationalization › Form of address | #2298 introduced an owner rule (informal "du" in German UI text) that no architecture page recorded; the PR first stored it as an unvalidated _meta.germanAddressForm string. |
Decision recorded: the rule is part of the glossary schema as _meta.addressForm (per locale, validated by glossary.ts), with the third-party formal exceptions. Source of truth: client/src/i18n/glossary.json, client/src/i18n/glossary.ts, CLAUDE.md › Internationalization & Translation. |