{"uuid": "995688c5-4a84-49f3-887d-fd92772057da", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-qx2v-qp2m-jg93", "type": "seen", "source": "https://gist.github.com/farrelalvis/1030dc3a5520ef382113c429daf63288", "content": "\n\n\n\n\nassureworx_app \u2014 Wiki\n\n\n\n\n*{margin:0;padding:0;box-sizing:border-box}\n:root{\n  --bg:#ffffff;--sidebar-bg:#f8f9fb;--border:#e5e7eb;\n  --text:#1e293b;--text-muted:#64748b;--primary:#2563eb;\n  --primary-soft:#eff6ff;--hover:#f1f5f9;--code-bg:#f1f5f9;\n  --radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08);\n}\nbody{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;\n  line-height:1.65;color:var(--text);background:var(--bg)}\n\n.layout{display:flex;min-height:100vh}\n.sidebar{width:280px;background:var(--sidebar-bg);border-right:1px solid var(--border);\n  position:fixed;top:0;left:0;bottom:0;overflow-y:auto;padding:24px 16px;\n  display:flex;flex-direction:column;z-index:10}\n.content{margin-left:280px;flex:1;padding:48px 64px;max-width:960px}\n\n.sidebar-header{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--border)}\n.sidebar-title{font-size:16px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px}\n.sidebar-title svg{flex-shrink:0}\n.sidebar-meta{font-size:11px;color:var(--text-muted);margin-top:6px}\n.nav-section{margin-bottom:2px}\n.nav-item{display:block;padding:7px 12px;border-radius:var(--radius);cursor:pointer;\n  font-size:13px;color:var(--text);text-decoration:none;transition:all .15s;\n  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.nav-item:hover{background:var(--hover)}\n.nav-item.active{background:var(--primary-soft);color:var(--primary);font-weight:600}\n.nav-item.overview{font-weight:600;margin-bottom:4px}\n.nav-children{padding-left:14px;border-left:1px solid var(--border);margin-left:12px}\n.nav-group-label{font-size:11px;font-weight:600;color:var(--text-muted);\n  text-transform:uppercase;letter-spacing:.5px;padding:12px 12px 4px;user-select:none}\n.sidebar-footer{margin-top:auto;padding-top:16px;border-top:1px solid var(--border);\n  font-size:11px;color:var(--text-muted);text-align:center}\n\n.content h1{font-size:28px;font-weight:700;margin-bottom:8px;line-height:1.3}\n.content h2{font-size:22px;font-weight:600;margin:32px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border)}\n.content h3{font-size:17px;font-weight:600;margin:24px 0 8px}\n.content h4{font-size:15px;font-weight:600;margin:20px 0 6px}\n.content p{margin:12px 0}\n.content ul,.content ol{margin:12px 0 12px 24px}\n.content li{margin:4px 0}\n.content a{color:var(--primary);text-decoration:none}\n.content a:hover{text-decoration:underline}\n.content blockquote{border-left:3px solid var(--primary);padding:8px 16px;margin:16px 0;\n  background:var(--primary-soft);border-radius:0 var(--radius) var(--radius) 0;\n  color:var(--text-muted);font-size:14px}\n.content code{font-family:'SF Mono',Consolas,'Courier New',monospace;font-size:13px;\n  background:var(--code-bg);padding:2px 6px;border-radius:4px}\n.content pre{background:#1e293b;color:#e2e8f0;border-radius:var(--radius);padding:16px;\n  overflow-x:auto;margin:16px 0}\n.content pre code{background:none;padding:0;font-size:13px;line-height:1.6;color:inherit}\n.content table{border-collapse:collapse;width:100%;margin:16px 0}\n.content th,.content td{border:1px solid var(--border);padding:8px 12px;text-align:left;font-size:14px}\n.content th{background:var(--sidebar-bg);font-weight:600}\n.content img{max-width:100%;border-radius:var(--radius)}\n.content hr{border:none;border-top:1px solid var(--border);margin:32px 0}\n.content .mermaid{margin:20px 0;text-align:center}\n\n.menu-toggle{display:none;position:fixed;top:12px;left:12px;z-index:20;\n  background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);\n  padding:8px 12px;cursor:pointer;font-size:18px;box-shadow:var(--shadow)}\n@media(max-width:768px){\n  .sidebar{transform:translateX(-100%);transition:transform .2s}\n  .sidebar.open{transform:translateX(0);box-shadow:2px 0 12px rgba(0,0,0,.1)}\n  .content{margin-left:0;padding:24px 20px;padding-top:56px}\n  .menu-toggle{display:block}\n}\n.empty-state{text-align:center;padding:80px 20px;color:var(--text-muted)}\n.empty-state h2{font-size:20px;margin-bottom:8px;border:none}\n\n\n\n\n&#9776;\n\n\n\n\n\n\n\n\n\nassureworx_app\n\n\n\n\n\n\n\nGenerated by GitNexus\n\n\n\n\n\nLoading\u2026\n\n\n\nvar PAGES = {\"admin-support-reporting-backend-src\":\"# Admin, Support &amp; Reporting \u2014 backend-src\\n\\n# Admin, Support &amp; Reporting \u2014 Backend Module\\n\\n## Overview\\n\\nThis module bundles the operational and platform-facing capabilities of the AssureWorx backend: platform administration, continuous assurance sweeps, tenant reporting, and support ticket management. It also contains the shared audit-event infrastructure (`audit`, `audit-context`, `audit-query`) that every other module uses to record and read immutable events.\\n\\nThree Express routers are exposed, each mounted under its own path and guarded by its own middleware chain:\\n\\n| Router | Module path | Primary audience |\\n|---|---|---|\\n| `adminRouter` | `src/modules/admin/index.ts` | `platform_admin` only |\\n| `reportingRouter` | `src/modules/reporting/index.ts` | `tier1_admin`, `tier1_user`, `platform_admin` |\\n| `supportRouter` | `src/modules/support/index.ts` | Any authenticated actor; platform admins get cross-tenant access |\\n\\nA fourth component, `src/modules/assurance/`, contains no HTTP routes \u2014 it is a background job (scheduler + sweep) that recomputes verification status when calendar expiry would otherwise leave it stale.\\n\\n```mermaid\\nflowchart LR\\n    Admin[adminRouterplatform_admin] --&gt; SchemaERD[buildSchemaErd]\\n    Admin --&gt; AuditWrite[audit]\\n    Admin --&gt; Memberships[createSetupSeat / emailSeat]\\n\\n    Reporting[reportingRoutertier1 + platform] --&gt; AuditQuery[audit-query]\\n    Reporting --&gt; Verify[evaluateAndPersistStatus]\\n    Reporting --&gt; Evidence[getEvidenceForParties]\\n\\n    Support[supportRouterauth + tenant] --&gt; SLA[sla.ts]\\n    Support --&gt; AuditWrite\\n    Support --&gt; AuditQuery\\n\\n    Scheduler[assurance schedulersetInterval + jitter] --&gt; Sweep[runAssuranceSweep]\\n    Sweep --&gt; Verify\\n    Verify --&gt; AuditQuery\\n```\\n\\nAll three routers follow the same shape: a `/health` probe, then `requireAuth` \u2192 `resolveTenantContext` \u2192 (role check) middleware before route handlers. All handlers wrap their logic in `h(...)` from `shared/http/errors.ts` for async error propagation, and respond through `ok`/`created` envelopes.\\n\\n## Module: Admin (`src/modules/admin/index.ts`)\\n\\n### Access Model\\n\\nThe entire router is gated behind:\\n\\n```ts\\nadminRouter.use(requireAuth, resolveTenantContext, requireRole(\\\"platform_admin\\\"));\\n```\\n\\nEvery route therefore runs with the caller's `actorId` available on `req.ctx`, but all database work executes inside `withBypass` \u2014 platform operations need to read across tenants and touch tables (like `core.meta`) that are revoked from the application role.\\n\\n### Provisioning a Principal Contractor\\n\\n`POST /principal-contractors` implements ADR-0010. The Zod schema `provisionSchema` accepts `orgName`, `abn` (optional), and first/last name + email for the initial admin.\\n\\nInside a single `withBypass` transaction, the flow is:\\n\\n1. Reject if the email already exists on `t_actor`, or the ABN already exists as a `D01` party identifier (checksum-validated first via `isValidAbn`).\\n2. Insert the org party \u2014 `kind_cd 'K01'`, `class_cd 'E01'`, `status_cd 'S01'` \u2014 plus the `D01` ABN identifier if supplied.\\n3. Insert the person party (`K03`/`M02`) with first/last name, plus a `D03` email identifier.\\n4. Insert the actor using `actorSubjectForEmail(email)` as the Entra subject, then grant `R02` (Tier 1 admin) against the org.\\n5. Call `invalidateMembershipCache(actorId)` so the new grant is visible immediately.\\n6. Start a complimentary `PL2` ledger entry (1 year).\\n7. Create a setup seat via `createSetupSeat`.\\n8. Write three audit events: `pc.provisioned`, `role.granted`, `subscription.started`.\\n\\nAfter commit, the setup email is dispatched through `emailSeat` with kind `pc_setup`. The response includes `inviteUrl` from `seatInviteUrl(result.seatToken)` and a boolean `emailSent`.\\n\\n### Resending Setup\\n\\n`POST /principal-contractors/:orgId/resend-setup` finds the org's first Tier 1 admin (earliest active `R02` grant), soft-deletes any outstanding `U01` setup seats for the org (flipping them to `U04` with `deleted_at = NOW()`), creates a fresh seat, and re-sends the `pc_setup` email. Fails with `NO_ADMIN` if no active Tier 1 admin exists.\\n\\n### Overview &amp; List Endpoints\\n\\n- `GET /overview` \u2014 aggregate counts for the platform console: principal contractors (`K01`), trade companies (`K02`), actors, pending reviews (artifacts in `F01`), and unexpired pending seats (`U01`), plus the 20 most recent `t_event` rows joined to actor email.\\n- `GET /principal-contractors` \u2014 lists up to 200 orgs with their ABN, trade count (via `t_rel` kind `L02`, status `RS1`), seat count, and the earliest admin's email.\\n- `GET /organisations?q=&amp;kind=` \u2014 searchable list of `K01`/`K02` parties; `kind` filters to `principal_contractor`, `trade_company`, or `all`. Search matches display name or ABN.\\n- `GET /actors?q=` \u2014 lists actors with their memberships as a JSON array of `{orgId, orgName, role}`. Status maps `RS2` \u2192 `\\\"disabled\\\"`.\\n\\n### Actor Status Control\\n\\n`PATCH /actors/:id` accepts `{ status: \\\"active\\\" | \\\"disabled\\\" }`. Disabling bumps `session_ver` so existing tokens are invalidated. The route refuses to disable the requesting actor (`SELF_DISABLE`), and audits `actor.disabled` / `actor.enabled`.\\n\\n### Schema ERD\\n\\n`GET /schema` returns a live `SchemaErdModel` built by `buildSchemaErd` in `shared/services/schema-erd.ts`. The builder:\\n\\n- Reads table/column metadata from `information_schema`, primary keys from `table_constraints` + `key_column_usage`, and foreign keys from `pg_constraint` (JSON-aggregated so the column arrays arrive as real `text[]`).\\n- Overlays logical names and meanings from `core.fn_map_dict()` \u2014 a SECURITY DEFINER projection, because `meta` schema is revoked from the app role. `withBypass` lifts RLS but does **not** grant `meta` USAGE, hence the function call.\\n- Falls back to hardcoded meanings for tables absent from `map_dict` (e.g. `t_pass_dl`, `t_pass_device`).\\n- Assigns each table a domain (`identity`, `network`, `work`, `evidence`, `training`, `ops`, `commercial`, `system`) and includes the applied migration versions from `core.fn_schema_migrations()`.\\n\\n### Closure Cancellation\\n\\n`POST /parties/:partyId/closure/cancel` is a thin delegation to `cancelAccountClosure` in the retention module, passing the requesting actor and scope from `req.auth` / `req.ctx`.\\n\\n## Module: Continuous Assurance (`src/modules/assurance/`)\\n\\n### Problem\\n\\nEvent-driven paths call `evaluateAndPersistStatus` whenever evidence, identity, or payment data changes. Calendar expiry has no event \u2014 a white card expiring overnight would leave `t_party.status_cd` and `t_link` readiness stale until the next human action. The assurance sweep exists to close that gap.\\n\\n### Scheduler (`scheduler.ts`)\\n\\nThe server starts `startAssuranceSweepTimer` at boot and stops it during shutdown. Key properties:\\n\\n- Interval comes from `config.assurance.intervalMinutes`; setting `ASSURANCE_SWEEP_INTERVAL_MIN=0` disables it.\\n- Each interval is jittered by \u00b115% (`jitter()`) to avoid thundering-herd alignment across replicas.\\n- A `running` flag provides skip-if-still-running protection, so a slow sweep never overlaps itself.\\n- The interval timer is `unref()`'d; a one-off first pass runs ~2 minutes after boot so overnight expiries are caught promptly.\\n\\n### Sweep (`sweep.ts`)\\n\\n`runAssuranceSweep` opens a `withBypass` transaction, calls `listAssuranceCandidates`, and recomputes each candidate via `evaluateAndPersistStatus(tx, partyId, null)` \u2014 passing a null actor since this is a system job.\\n\\n`listAssuranceCandidates` is a three-branch `UNION` of `party_id`s:\\n\\n1. **Expired evidence** \u2014 live artifacts in `F02` or `F04` with a non-null `expires_on` before today.\\n2. **Expired identity documents** \u2014 the latest `C02` check in `H02` (not superseded) whose `doc_expires_on` is before today.\\n3. **Lapsed identity consent** \u2014 latest `C02` check in `H02` where the most recent `N01` consent row is either not `granted` or has an `expires_at` in the past.\\n\\nThe query is bounded by `config.assurance.batchLimit` (clamped to 1\u20131000). The `C02` subqueries select the latest check by `(created_at DESC, seq_n DESC)`, which is the same ordering used elsewhere for identity checks.\\n\\nThe test file (`sweep.test.ts`) pins the contract: the candidate query must reference `expires_on &lt; CURRENT_DATE`, `doc_expires_on &lt; CURRENT_DATE`, and consent `purpose_cd`; and `evaluateAndPersistStatus` must not early-return when the status code is unchanged \u2014 otherwise site boards would go stale.\\n\\n## Module: Reporting (`src/modules/reporting/index.ts`)\\n\\n### Access Model\\n\\n- `GET /dashboard` \u2014 requires `tier1_admin` or `tier1_user`.\\n- `GET /events` \u2014 additionally allows `platform_admin`.\\n- `GET /events/export` and `POST /assurance/sweep` \u2014 `tier1_admin` or `platform_admin` only (no `tier1_user`).\\n\\nTenant scoping is handled per-route: `withTenant` for the dashboard and manual sweep, `withScope` for tenant-scoped event queries, and `withBypass` for platform-wide event queries.\\n\\n### Dashboard\\n\\n`GET /dashboard` builds a `DashboardSummary` inside a single `withTenant` transaction:\\n\\n1. **Network** \u2014 `getNetworkParties(tx, scope)` yields the tenant graph; the dashboard counts companies (`K02`) vs individuals (`K03`) and buckets verified status into a `StatusSplit` (`verified` / `awaiting` / `outstanding`, where `action_required` + `suspended` collapse into `outstanding`).\\n2. **Trade types** \u2014 `t_party_tag` joined to `t_party`, grouped by tag code and status, sorted by count descending, top 8. Each entry carries verified/pending/actionRequired/suspended counts.\\n3. **Evidence attention** \u2014 `getEvidenceForParties` returns per-party evidence rows; the dashboard buckets them into `expiring`, `missing`, and `failed` items, ranked in that order, capped at 24 items. `expiringSoon` is derived from the same buckets (expiring or failed with a real artifact + expiry).\\n4. **Portfolio** \u2014 project/site counts from `t_node` (`N01`/`N02`) and link readiness from `t_link` by `owner_party_id`, mapped through `LINK_STATE` (`J01`\u2013`J04`).\\n5. **Activity** \u2014 the 30 most recent `t_event` rows for the scope, humanised through `summariseVerb`.\\n6. **Top companies** \u2014 link readiness aggregated per trade company, sorted by total assignments, top 6.\\n\\n### Audit Timeline\\n\\n`GET /events` implements keyset pagination via the `before` query parameter (an ISO timestamp, exclusive). `listAuditEvents` orders by `(at DESC, id DESC)` so ties are stable, and clamps `limit` to 1\u2013200 (default 50). Filters: `objType`, `objId`, `verb`.\\n\\nThe scope is role-dependent:\\n\\n- `tier1_admin` / `tier1_user` \u2192 always `ctx.scopeOrgId`.\\n- `platform_admin` \u2192 optional `?scope=` (validated UUID) or `null` for global, which routes through `withBypass` instead of `withScope`.\\n\\nThe response is an `AuditEventPage` with `items` and a `nextBefore` cursor (the last item's timestamp), so clients can page by passing `?before=nextBefore`.\\n\\n### Audit Export\\n\\n`GET /events/export` mirrors the timeline filters but uses a `from`/`to` window instead of keyset pagination, and caps at 10,000 rows. Two output formats:\\n\\n- `format=json` \u2014 `{ items, truncated: rows.length &gt;= 10_000 }`.\\n- `format=csv` (default) \u2014 streams `eventsToCsv(rows)` as an attachment `assureworx-audit-YYYY-MM-DD.csv`.\\n\\nThe CSV path is injection-hardened: `csvCell` prefixes any cell starting with `=`, `+`, `-`, `@`, tab, or CR with a tab inside quotes, so spreadsheet formula/DDE payloads in actor emails or summaries are neutralised. This behavior is unit-tested in `audit-query.test.ts`.\\n\\n### Manual Sweep\\n\\n`POST /assurance/sweep` gives a Tier 1 admin or platform admin a bounded, on-demand version of the background job: it iterates the tenant network inside `withTenant` and calls `evaluateAndPersistStatus` per party with the requesting actor's ID (so any `artifact.expired` events are attributed to a human). Rate-limited by `sweepLimiter`.\\n\\n## Module: Support (`src/modules/support/index.ts`)\\n\\n### Access Model\\n\\n`supportRouter` requires only `requireAuth` + `resolveTenantContext`. Tenant isolation is then applied at the data-access layer:\\n\\n- **Platform admins** run through `withBypass` and can see every ticket, create notes marked internal, and change ticket status/priority.\\n- **Everyone else** runs through `withActor(ctx.actorId)`, which scopes the query session to their actor. RLS enforces ownership, and the app layer double-checks with explicit `requester_actor_id` predicates.\\n\\n### Ticket Lifecycle\\n\\n**Create** (`POST /`): rate-limited by `supportLimiter`. The handler:\\n\\n1. Parses the `createSchema` body (`kind`, optional `priority`, `subject`, `body`).\\n2. Computes SLA deadlines via `slaDeadlines(priority ?? \\\"normal\\\")`.\\n3. Inside `withActor`, generates a human reference from `nextval('core.seq_sr_ref')` \u2192 `AWX-SR-`.\\n4. Inserts into `t_sr` with status `T01` (open), requester actor/party from `ctx`, org scope from `ctx.orgId`, and both SLA deadlines.\\n5. Audits `support.created`.\\n6. Inserts an in-app message (`t_msg`, channel `Z01`, kind `support.opened`) on the requester's org so the platform inbox (if configured) is notified.\\n7. Returns the mapped ticket via `mapRow`.\\n\\nNote the comment: platform admins creating on behalf of a tenant still insert with their own actor as requester, because `t_sr` has an RLS `WITH CHECK` requiring requester = current actor.\\n\\n**Detail** (`GET /:id`): calls `loadDetail`. For non-admins, the ownership check runs first (under `withActor`), returning `null` \u2192 `404` if the ticket isn't theirs. Note the explicit app-layer guard exists as defence in depth alongside RLS, and it runs **before** `stampBreaches` \u2014 a foreign UUID must not touch the row at all.\\n\\n**Notes** (`POST /:id/notes`): non-admin callers can only add public notes to their own tickets. Anyone other than the requester posting the first non-internal note starts the response clock: `first_response_at = NOW()` and status `T01 \u2192 T02`. Platform admins may set `internal: true`. Every note is audited as `support.note_added`.\\n\\n**Status update** (`PATCH /:id`): platform admins only. Accepts `status` (`open` / `in_progress` / `waiting` / `resolved` / `closed`) and an optional priority change. Changing priority recomputes both SLA deadlines from `slaDeadlines`. The UPDATE also:\\n\\n- Assigns `assignee_actor_id` on first touch (COALESCE).\\n- Sets `first_response_at` when moving out of `T01`.\\n- Sets `resolved_at` / `closed_at` when entering `T04` / `T05`.\\n\\n### SLA Engine (`sla.ts`)\\n\\nSLAs are calendar-hour based (Phase 1; business-hours calendars are a later replacement):\\n\\n| Priority | Response | Resolve |\\n|---|---|---|\\n| `critical` | 1h | 4h |\\n| `high` | 4h | 24h |\\n| `normal` | 8h | 72h |\\n| `low` | 24h | 120h |\\n\\n`slaDeadlines(priority, from?)` computes the two due timestamps. `evaluateSla` derives per-dimension states:\\n\\n- `met` \u2014 action (first response / resolution) completed before the due time.\\n- `breached` \u2014 no action and now is past due, or action completed late.\\n- `due_soon` \u2014 within the 2-hour `DUE_SOON_MS` window.\\n- `ok` \u2014 otherwise.\\n\\n`mapRow` calls `evaluateSla` for both dimensions and exposes `responseSla` / `resolveSla` on every ticket object.\\n\\n### Breach Stamping\\n\\n`stampBreaches(tx, id)` is the persistence side of the SLA logic. It runs a single `UPDATE` that fills `response_breached_at` / `resolve_breached_at` with `COALESCE` semantics \u2014 the first breach timestamp wins, and the stamp happens either when the deadline passes while still open or when a late action is finally recorded. It is idempotent by construction (COALESCE on an already-set column) and is invoked from `listTickets`, `loadDetail`, `addNote`, and the status UPDATE.\\n\\n### Monitor &amp; List\\n\\n- `GET /monitor` \u2014 platform-admin only; returns a `SupportMonitor` with open / in_progress / waiting / breached / due_soon / resolved_today counts computed with `FILTER` clauses.\\n- `GET /` \u2014 lists tickets; non-admins see only their own, platform admins see all. Supports `?status=` and `?breached=1`. Open tickets (`T01`\u2013`T03`) sort first, then by `sla_resolve_due_at ASC`. The list is capped at 200. `listTickets` also fires `stampBreaches` for each returned row before mapping.\\n\\n## Shared Infrastructure: Audit Pipeline\\n\\n### Request Context (`audit-context.ts`)\\n\\n`bindAuditContext` is an Express middleware that captures request metadata into an `AsyncLocalStorage` store for the lifetime of the request:\\n\\n- **IP** \u2014 from `req.ip` (respects `trust proxy`), normalised by stripping the `::ffff:` IPv4-mapped prefix.\\n- **User-Agent** \u2014 truncated to 256 chars, with C0 control characters and DEL stripped so they can never enter the audit log.\\n- **Correlation ID** \u2014 honours an incoming `x-request-id` if it matches a UUID shape, otherwise generates one; it is echoed back on the response header.\\n\\n`getAuditRequestContext()` lets `audit()` stamp IP / UA / correlation id without threading Express objects through every call site. Outside an HTTP request (system jobs, scheduled sweeps), the store is empty and those columns fall back to `null` (or an explicit `entry.ip` override).\\n\\n### Writing Events (`audit.ts`)\\n\\n`audit(tx, entry)` appends an immutable row to `core.t_event` inside the caller's transaction \u2014 never its own transaction, so the event is atomic with the mutation it describes. The `actor_kind_cd` is `\\\"user\\\"` when `actorId` is set, else `\\\"system\\\"`. `delta` is serialised to JSON; IP must be a valid `inet`, corr id a valid `uuid` (both null-safe). Callers across the codebase include auth, onboarding, identity, nodes, parties, memberships, retention, payments, and `evaluateAndPersistStatus`.\\n\\n### Reading &amp; Exporting (`audit-query.ts`)\\n\\n- `listAuditEvents(tx, opts)` \u2014 keyset page, newest first, `(at, id)` tie-break. Explicitly filters `scope_party_id` even though RLS/scoped sessions already constrain it.\\n- `exportAuditEvents(tx, opts)` \u2014 same shape, `from`/`to` window, hard cap of 10,000.\\n- `summariseVerb(verb, objType, delta)` \u2014 human-readable summaries for activity rails. It special-cases `party.status_changed` (appends `| evidence expired` when the reason is `evidence_expired`) and `artifact.expired` (prefixes `Document expired |`). Unmapped verbs fall back to the verb string with `_`/`.` replaced by spaces.\\n- `eventsToCsv(rows)` \u2014 header + one line per row, each cell through `csvCell`.\\n- `recordNewlyExpiredArtifacts(tx, partyId, actorId, scopePartyId)` \u2014 emits one `artifact.expired` audit event per newly-expired live artifact for a party. Idempotent via `NOT EXISTS` against existing `artifact.expired` events, and is called from inside `evaluateAndPersistStatus` so the events are transactional with the status recompute.\\n\\n## Data Access Patterns\\n\\nThese modules exercise every connection mode from `shared/db/pool.ts`:\\n\\n| Mode | Used when | Example |\\n|---|---|---|\\n| `withBypass` | Platform-wide operations, RLS lifted, system jobs | Admin routes, `runAssuranceSweep`, platform audit query/export, platform support access |\\n| `withTenant` | Tenant-scoped work with actor identity for attribution | Reporting dashboard, manual sweep |\\n| `withScope` | Tenant-scoped reads without writer attribution | Tenant audit pages via `listAuditEvents` |\\n| `withActor` | Actor-scoped RLS for ownership-sensitive rows | Support ticket create/detail/notes for non-admins |\\n\\n## Connections to the Wider Codebase\\n\\n- **Admin provisioning** depends on `modules/memberships` (`createSetupSeat`, `emailSeat`, `seatInviteUrl`), `shared/auth/entra.ts` (`actorSubjectForEmail`), and `modules/retention/closure.ts` (`cancelAccountClosure`).\\n- **Assurance** depends on `evaluateAndPersistStatus` in `shared/services/verified-status.ts`, which itself calls `recordNewlyExpiredArtifacts` from `audit-query.ts`. The server lifecycle wires the scheduler in `server.ts` (`startAssuranceSweepTimer` / `stopAssuranceSweepTimer`).\\n- **Reporting** consumes `getNetworkParties` (network module), `getEvidenceForParties` / `iso` / `isoDate` (evidence service), and the audit-query helpers. The dashboard's `LINK_STATE` / `PARTY_STATUS` / `TAG_NAMES` mappings come from `shared/codes.ts`.\\n- **Support** uses `evaluateSla` / `slaDeadlines` (local), the audit pipeline, and the shared rate limiters (`supportLimiter`, `exportLimiter`, `sweepLimiter`) from `shared/middleware/rate-limit.ts`.\\n- **Audit infrastructure** is the cross-cutting dependency: nearly every mutation in the codebase calls `audit()`, and the reporting module exposes that event stream back to tenants and platform admins as a first-class product surface.\",\"admin-support-reporting-frontend-src\":\"# Admin, Support &amp; Reporting \u2014 frontend-src\\n\\n# Admin, Support &amp; Reporting \u2014 Frontend\\n\\n## Overview\\n\\nThis module contains the operator-facing and self-service surfaces of the Assureworx app:\\n\\n- **Platform console** (`/admin`) \u2014 a `platform_admin`-only workspace for tenant oversight: organisation search, account enable/disable, document review, payments reconciliation, schema exploration, Azure resource lineage, and principal-contractor provisioning.\\n- **Support mailbox** (`/support`) \u2014 a two-pane ticket system available to every signed-in user, with an admin SLA monitor and internal notes.\\n- **Settings** (`/settings`) \u2014 organisation settings for Principal Contractor admins, centered on seat invites.\\n- **Account** (`/settings/account`) \u2014 self-service profile photo, login email change, password management, and personal-data erasure.\\n- **Reports** (`/reports`) \u2014 a role-gated placeholder for future reporting pages.\\n\\nEvery page follows the same split: a **server component** (`page.tsx`) enforces access control and pre-fetches initial data, then renders a **client component** that owns all interactivity. This gives a first paint without a client round-trip while keeping the interactive state machine in the browser.\\n\\n```mermaid\\ngraph LR\\n    AdminPage[admin/page.tsx] --&gt; AdminClient\\n    SupportPage[support/page.tsx] --&gt; SupportClient\\n    SettingsPage[settings/page.tsx] --&gt; SettingsClient\\n    AccountPage[settings/account/page.tsx] --&gt; AccountClient\\n    ReportsPage[reports/page.tsx] --&gt; ComingSoonPage\\n    AdminClient --&gt; PaymentsConsole\\n    AdminClient --&gt; SchemaErdStudio\\n    AdminClient --&gt; AzureLineageStudio\\n```\\n\\n## Roles and access control\\n\\nAccess is enforced twice, on both sides of the server/client boundary.\\n\\n| Route | Server gate | Client gate |\\n|---|---|---|\\n| `/admin` | `requirePortal(\\\"platform_admin\\\")` | `AdminClient` redirects via `homeFor(me)` unless `me.active.role === \\\"platform_admin\\\"` |\\n| `/support` | \u2014 (authenticated via `(app)` layout) | none |\\n| `/settings` | \u2014 | seat-invite features require `me.active.role === \\\"tier1_admin\\\"` |\\n| `/settings/account` | \u2014 | none (trade-pass photo deletion is locked per-role) |\\n| `/reports` | `requirePortal(\\\"principal\\\")` | none |\\n\\n`AdminClient` also renders `null` while the redirect effect runs, so a non-admin never sees a flash of the console.\\n\\n## Shared infrastructure\\n\\nAll four client components use the same building blocks:\\n\\n- **`api` client** (`@/lib/api`) \u2014 `get`/`post`/`patch`/`del` wrappers that throw `ApiClientError` with a server-provided message. Component code consistently does `err instanceof ApiClientError ? err.message : \\\"fallback\\\"`.\\n- **`loadAppPage` / `loadAppPageOptional`** (`@/lib/load-app-page`) \u2014 server-side fetchers used by `page.tsx` to seed initial state. The `Optional` variants are used where the endpoint can be unavailable (e.g. Entra status when not configured).\\n- **`useApi`** (`@/lib/use-api`) \u2014 client hook returning `{ data, loading, error, refresh }`; accepts `initialData` so the server-seeded value is used without a second fetch.\\n- **`useSession`** \u2014 session/profile state plus `refresh()`, called after any mutation that changes the current user (photo update, email change).\\n- **`useToast`** \u2014 success/error notifications. Every mutation ends in a toast.\\n- **Skeleton / EmptyState conventions** \u2014 `SkeletonRows` while first data is loading, `EmptyState` for empty results, `errorStrip` for page-level errors.\\n\\nA shared `busyId` pattern runs through the console and support mailbox: a single state string tracks which row is mid-mutation, so buttons show `loading` without blocking other rows.\\n\\n## Platform console (`/admin`)\\n\\n### Tab model and lazy loading\\n\\n`AdminClient` holds one `tab` state:\\n\\n```ts\\ntype Tab = \\\"overview\\\" | \\\"orgs\\\" | \\\"accounts\\\" | \\\"review\\\" | \\\"payments\\\" | \\\"erd\\\" | \\\"provision\\\" | \\\"azure\\\";\\n```\\n\\nOnly the active tab is mounted. Data loading is keyed to the tab via a single effect; each tab's fetch callback fires the first time the tab becomes active. `overview` is fetched once (`!overview` guard) while other tabs refetch on every activation.\\n\\nThree panels are deliberately kept out of the default bundle with `next/dynamic` + `ssr: false`, each with a `SkeletonRows` fallback:\\n\\n- `SchemaErdStudio` (ERD tab)\\n- `AzureLineageStudio` (Azure tab)\\n- `PaymentsConsole` (Payments tab) \u2014 the source comment notes it \\\"fetches four payment endpoints on mount | keep it off the default chunk\\\"\\n\\n### Overview tab\\n\\nMetrics grid from `GET /admin/overview`: principal contractors, trade companies, accounts, pending reviews, open invites. The pending-reviews metric is a button that jumps to the review tab, highlighted when non-zero.\\n\\nBelow is the **recent audit** list (`recentEvents`), showing verb, object type/id, actor email, and timestamp. A persistent note explains the current Entra limitation: password reset and MFA unlock require Microsoft Graph, which is not wired in this environment, so the console only offers app-side disable and set-up resend.\\n\\n### Organisations tab\\n\\nTwo panels:\\n\\n1. **Principal contractors** \u2014 `GET /admin/principal-contractors` returns `PcOrg[]` with trade/seat counts and admin email. Each row has **Resend set-up**, which calls `POST /admin/principal-contractors/:orgId/resend-setup`, toasts whether email was sent, and attempts to copy the returned `inviteUrl` to the clipboard.\\n2. **Search organisations** \u2014 `GET /admin/organisations?q=&amp;kind=` with a free-text name/ABN input and a kind filter (`all | principal_contractor | trade_company`). Enter or the Search button triggers `loadOrgs`.\\n\\n### Accounts tab\\n\\n`GET /admin/actors?q=` returns `ActorRow[]` with memberships rendered as `orgName (role)` pairs. Per-row actions:\\n\\n- **Disable / Enable** \u2014 `PATCH /admin/actors/:actorId` with `{ status }`. Disable is blocked for the actor's own account (`disabled={actor.actorId === me.userId}`).\\n- **Reset password** \u2014 permanently disabled, again because it requires Entra Graph.\\n\\n### Review queue\\n\\n`GET /artifacts/review-queue` returns `ReviewItem[]`. Each row shows the party, document name, class code, authority/reference, and upload metadata. When `item.extraction` is present, the verdict badge (`hold` / `check` / `clean`) is rendered via `VERDICT_LABEL` / `VERDICT_CLASS`, and a second row (`colSpan={4}`) renders `DocumentReadSummary` \u2014 the machine's structured reading of the document, shown directly under the document it belongs to (ADR-0019).\\n\\nActions per row:\\n\\n- **View evidence** \u2014 links to `/api/v1/artifacts/:artifactId/content` (opens new tab)\\n- **Reject reason** input \u2014 required before rejecting; validated client-side\\n- **Approve / Reject** \u2014 `POST /artifacts/:artifactId/review` with `{ decision, reason? }`. On success both the queue and overview are refreshed via `Promise.all([loadReviews(), loadOverview()])`.\\n\\n### Provisioning\\n\\nThe Provision PC tab is a form: org name, optional ABN (digits stripped), admin email, first/last name. `POST /admin/principal-contractors` returns a `ProvisionResult` with `inviteUrl` and `emailSent`. After success the form resets, the cached `pcs` and `overview` states are dropped to `null` (forcing refetch next time they render), and a \\\"Last provision\\\" panel appears with a **Copy set-up link** button.\\n\\n### Payments, ERD, Azure lineage\\n\\nThese three tabs delegate to self-contained components:\\n\\n- **`PaymentsConsole`** \u2014 order loading (`loadOrders`, `loadRest`), reconciliation actions (`reconcile`, `replay`, `decide`), refunds (`loadRefunds`), and an `OrderTable` for display using `formatMoney` / `formatDateTime`.\\n- **`SchemaErdStudio`** \u2014 receives a `SchemaErdModel` from `GET /admin/schema`. The call graph shows an interactive SVG renderer: `colsOf` / `nodeHeight` / `heights` for node layout, `elbowPath` for edge routing, `fit` for viewport fitting, and `toggleDomain` for collapsing/expanding entity domains. The panel has a Refresh button that re-fetches the live model.\\n- **`AzureLineageStudio`** \u2014 a deliberately non-scaled board of the Azure estate. The component comment explains the design history: a 34-node, six-level tree rendered ~2500px wide and had to shrink to 0.55 to fit, producing unreadable 7px labels. The replacement groups services by domain in a CSS-column layout at full type size, with a 340px detail pane. Structure: breadcrumb chain, domains with services, sub-resources (app registrations, alert rules, sender domains) on a hairline rail, an excluded-resources section, and status chips (`live` / `pending` / `ops`). Data comes from `azureLineageData.ts` (`AZURE_LINEAGE`, `findAzureNode`).\\n\\n## Support mailbox (`/support`)\\n\\n### Layout\\n\\n`SupportClient` renders a two-pane board: a scrollable ticket list (34% / 280px min), a 1px partition, and a focusable reading pane. Below 960px the board stacks \u2014 list on top (36%), detail below.\\n\\nThe list is server-seeded via `initialTickets`. When that prop is non-null, the initial list fetch is skipped but the admin monitor is still warmed by a separate `GET /support/monitor` call, since the monitor is not part of the server payload.\\n\\n### Ticket list and selection\\n\\n`GET /support` returns the requester's tickets for normal users and all tickets for `platform_admin` (server-scoped). Filters:\\n\\n- status: `open`, `in_progress`, `waiting`, `resolved`, `closed`\\n- admin-only `breached` filter \u2192 `GET /support?breached=1`\\n\\nNon-breached status filters are applied client-side via a `rows` memo. Each row shows ref, relative date, subject, priority, kind, an SLA pill, and (admin only) the requester.\\n\\nSelection is managed by effects: when the filtered list changes, the first row is auto-selected if the current selection no longer exists. A `focusedTicketKey` ref tracks which ticket the reading pane was focused for; focus moves to the pane only when the open ticket changes \u2014 never on keystrokes. The comment warns that a callback ref calling `focus()` was stealing the caret out of the reply textarea, which is why a plain ref + effect is used.\\n\\n### Ticket detail\\n\\n`GET /support/:id` returns `SupportTicketDetail`; the fetch effect uses a `cancelled` flag to discard stale responses. The detail view includes:\\n\\n- Facts grid: requester, organisation, opened, category, status, priority, response/resolve SLA with due dates\\n- Admin controls: status and priority selects (`PATCH /support/:id`), quick **Take ticket** (open \u2192 in_progress) and **Resolve** buttons shown only while the ticket is open\\n- Message stack: original request plus notes; internal notes are visually distinct (`.noteInternal`)\\n- Reply composer with an admin-only **Internal note (hidden from requester)** checkbox \u2192 `POST /support/:id/notes` with `{ body, internal }`\\n\\nEvery mutation calls `loadList()` afterwards to keep the left pane in sync.\\n\\n### Creation\\n\\nThe New ticket flow swaps the detail pane for a compose form: category, priority, subject, details. The submit button is disabled until the subject is \u22653 chars and the body \u226510 chars; `submit` re-checks the same guard. `POST /support` returns the created ticket, the list refreshes, and the new ticket is selected.\\n\\n### SLA display\\n\\n`SupportSlaState` maps to a class and label via `slaClass` / `slaLabel`: `met` \u2192 \\\"Met\\\", `breached` \u2192 error styling, `due_soon` \u2192 warning styling, otherwise \\\"On track\\\". The admin monitor shows open, in-progress, waiting, breached, due-soon, and resolved-today counts.\\n\\n## Settings (`/settings`)\\n\\n`SettingsClient` branches on `me.active?.role === \\\"tier1_admin\\\"`:\\n\\n- **Non-admins** see a Security panel, a placeholder \\\"Organisation settings\\\" panel, and Privacy/terms.\\n- **PC admins** additionally see the **Users** panel with an invite form and seat-invite table.\\n\\nThe invite form posts to `POST /memberships/seats` with `{ email, name?, role }` where role is `tier1_user` (Viewer) or `tier1_admin` (Admin). On success the list refreshes via `refresh()` from `useApi(\\\"/memberships/seats\\\", ...)`, which is only enabled for PC admins. Pending invites show a **Copy link** button using `inviteUrl`; `seatStatusChip` maps `pending` / `accepted` / `expired` to the appropriate `StatusChip` kind.\\n\\nThe Security panel reads `GET /auth/entra/status`. When Entra External ID is enabled it offers a forgot-password link and an **Open sign-in to change or reset password** button that calls `startEntraSignIn({ loginHint: me.email, returnTo: \\\"/settings\\\", prompt: \\\"login\\\" })` \u2014 forcing a fresh interactive sign-in so the user can change their password. When Entra is not configured, an `EmptyState` explains that development sign-in does not use passwords.\\n\\n## Account settings (`/settings/account`)\\n\\n`AccountClient` handles four self-service concerns.\\n\\n### Profile photo\\n\\nA hidden file input (visually clipped to 1px, `aria-hidden`) accepts JPEG/PNG/WebP. The selected file is run through `squareJpegFromFile` from `components/pass/pass-view` to produce a square data URL, then sent via `PUT /parties/me/photo`. After success the session is refreshed so the new avatar appears immediately.\\n\\nThe avatar renders the uploaded image unless it is a generated monogram (`isGeneratedMonogramUrl`), in which case initials are shown. For `trade_company_admin` and `trade_individual` roles, `passPhotoLocked` is true: the photo is replace-only, and the Remove button is hidden because DELETE is blocked server-side too.\\n\\n### Login email change\\n\\nA two-step flow modelled by `EmailChangeRequest` with statuses `pending` (confirmation sent to the old email) and `old_ok` (confirmation sent to the new email):\\n\\n- **Start** \u2014 `POST /auth/email-change` with `{ newEmail }`\\n- **Resend** \u2014 `POST /auth/email-change/resend`\\n- **Cancel** \u2014 `DELETE /auth/email-change`\\n\\nThe open request is loaded through `useApi(\\\"/auth/email-change\\\", { initialData: initialChange })`. While a change is pending, the form is replaced by a status block showing which inbox the confirmation was sent to, a copy-link button for the confirmation URL, and Resend/Cancel actions.\\n\\n### Security and erasure\\n\\nThe Security panel mirrors Settings (Entra status + forced sign-in), with `returnTo: \\\"/settings/account\\\"`.\\n\\n**Erase my personal details** is a destructive two-step flow: \\\"Request erasure\\\" reveals a confirmation panel; \\\"Confirm erasure\\\" posts `POST /parties/me/closure` with `{ confirm: true }`, then hard-redirects to `/auth/login`. The surrounding copy documents exactly what is removed immediately, what is retained for 90 days, and what is kept legally (invoices, audit trail). The panel is cancelled locally if the request fails so the user is not signed out unexpectedly.\\n\\n## Reports (`/reports`)\\n\\n`ReportsPage` is a route-level placeholder. It gates on `requirePortal(\\\"principal\\\")` and renders `ComingSoonPage` describing the planned report set \u2014 async compliance, expiry, training, and incident reports, all scoped to the requesting organisation.\\n\\n## API surface\\n\\n| Endpoint | Used by | Purpose |\\n|---|---|---|\\n| `GET /admin/overview` | `AdminPage`, `AdminClient` | Dashboard metrics + recent audit events |\\n| `GET /admin/principal-contractors` | `AdminClient` | PC list with trade/seat counts |\\n| `POST /admin/principal-contractors` | `AdminClient` | Provision a new PC + admin invite |\\n| `POST /admin/principal-contractors/:orgId/resend-setup` | `AdminClient` | Resend set-up; returns `inviteUrl` |\\n| `GET /admin/organisations?q=&amp;kind=` | `AdminClient` | Org search |\\n| `GET /admin/actors?q=` | `AdminClient` | Account search |\\n| `PATCH /admin/actors/:actorId` | `AdminClient` | Disable / enable account |\\n| `GET /admin/schema` | `AdminClient` | Live ERD model |\\n| `GET /artifacts/review-queue` | `AdminClient` | Document review queue |\\n| `POST /artifacts/:artifactId/review` | `AdminClient` | Approve / reject with reason |\\n| `GET /artifacts/:artifactId/content` | `AdminClient` (link) | Evidence file download |\\n| `GET /support` | `SupportPage`, `SupportClient` | Ticket list (server-scoped per role); `?breached=1` for admin filter |\\n| `GET /support/monitor` | `SupportClient` | Admin SLA monitor counts |\\n| `POST /support` | `SupportClient` | Create ticket |\\n| `GET /support/:id` | `SupportClient` | Ticket detail |\\n| `POST /support/:id/notes` | `SupportClient` | Add reply or internal note |\\n| `PATCH /support/:id` | `SupportClient` | Update status / priority |\\n| `GET /memberships/seats` | `SettingsPage`, `SettingsClient` | Seat invite list |\\n| `POST /memberships/seats` | `SettingsClient` | Create seat invite |\\n| `GET /auth/entra/status` | `SettingsPage`, `AccountPage`, client components | Entra External ID availability |\\n| `GET /auth/email-change` | `AccountPage`, `AccountClient` | Open email-change request |\\n| `POST /auth/email-change` | `AccountClient` | Start email change |\\n| `POST /auth/email-change/resend` | `AccountClient` | Resend confirmation |\\n| `DELETE /auth/email-change` | `AccountClient` | Cancel email change |\\n| `PUT /parties/me/photo` | `AccountClient` | Upload / replace profile photo |\\n| `DELETE /parties/me/photo` | `AccountClient` | Remove profile photo |\\n| `POST /parties/me/closure` | `AccountClient` | Request erasure |\\n\\n## Conventions worth preserving\\n\\nThe source comments document hard-won layout and interaction lessons. They are easy to break accidentally:\\n\\n- **Admin tab strip** \u2014 `admin.module.css` warns against pulling the tab strip up into the top bar: half the tabs clip under the top-bar hairline. The strip has `margin: 0` and relies on this.\\n- **Support pane focus** \u2014 do not replace the `readPaneRef` effect with a callback ref; it steals the caret from the reply textarea on every keystroke.\\n- **Azure lineage scaling** \u2014 `AzureLineageStudio` deliberately never scales or transforms. Fitting by shrinking is what made the previous tree view unreadable; keep full-size labels and structural layout. Its CSS also prefers `column-width` packing over grid rows because domain heights are uneven.\\n- **Dynamic imports** \u2014 `PaymentsConsole`, `SchemaErdStudio`, and `AzureLineageStudio` are kept out of the default chunk via `next/dynamic` with `ssr: false`. New heavy admin panels should follow the same pattern.\\n- **Mutation feedback** \u2014 every mutation either toasts or sets `formError`; busiest-row buttons use `busyId` rather than a global loading flag.\",\"admin-support-reporting\":\"# Admin, Support &amp; Reporting\\n\\n# Admin, Support &amp; Reporting\\n\\n## Purpose\\n\\nThe Admin, Support &amp; Reporting module group contains every operational surface of the AssureWorx platform: platform administration, tenant reporting, support ticketing, organisational settings, and self-service account management. It also owns the shared audit-event infrastructure that records and queries the immutable event stream used across the whole backend. On the frontend it exposes four routes \u2014 `/admin`, `/support`, `/settings`, and `/settings/account` \u2014 while the backend mounts three guarded Express routers and a scheduler-driven assurance worker.\\n\\n## Sub-modules\\n\\n### Backend\\n\\n- [Admin](admin.md) \u2014 `adminRouter`, restricted to `platform_admin`; covers tenant oversight, account enable/disable, document review, payments reconciliation, schema ERD generation, and Azure resource lineage.\\n- [Reporting](reporting.md) \u2014 `reportingRouter`, serves tenant-level reporting for `tier1_admin`/`tier1_user` roles; CSV export is built directly on the shared audit-query service (`eventsToCsv` \u2192 `csvCell`).\\n- [Support](support.md) \u2014 ticket management with SLA evaluation (`mapRow` \u2192 `evaluateSla`), breach stamping, and audit-event writes on note creation.\\n- [Assurance](assurance.md) \u2014 background sweeps driven by `startAssuranceSweepTimer`; `tick` invokes `runAssuranceSweep` on a jittered schedule, independent of any user request.\\n- [Audit](audit.md), [Audit Context](audit-context.md), [Audit Query](audit-query.md) \u2014 the shared event-recording and querying core; `listAuditEvents` applies `clampLimit` to bound result sets, and `eventsToCsv`/`csvCell` power export.\\n\\n### Frontend\\n\\n- [Platform Console](platform-console.md) \u2014 `/admin`, `platform_admin`-only workspace; `AdminClient` orchestrates `SchemaErdStudio`, `PaymentsConsole`, `AzureLineageStudio`, and `loadReviews`.\\n- [Support Mailbox](support-mailbox.md) \u2014 `/support`, a two-pane ticket system for signed-in users; `SupportClient` drives `submit`, `isOpen`, `slaClass`, and `sendNote`/`loadList`.\\n- [Settings](settings.md) \u2014 `/settings`, organisation settings for Principal Contractor admins, centred on seat invites via `SettingsClient`.\\n- [Account](account.md) \u2014 `/settings/account`, self-service profile photo, login email, and password changes via `AccountClient` (including `resend`).\\n\\n## How the sub-modules fit together\\n\\nThe audit services form the backbone: support's `addNote` writes an event through `audit` and then refreshes the ticket via `loadDetail`, reporting exports those same events as CSV, and the assurance sweep appends its own findings. The admin console is the heaviest consumer of cross-module logic \u2014 `PaymentsConsole.reconcile` pulls together `loadOrders`, `loadRefunds`, and `loadRest` before surfacing a result via `message`, while `SchemaErdStudio` renders the ERD produced by `buildSchemaErd`. On the user-facing side, every settings/account flow funnels through `useApi`, which feeds `trackNetworkInstant` \u2192 `push` into the performance store for `isRecording`/`emit` consumption.\\n\\n## Key workflows\\n\\n1. **SLA escalation** \u2014 `SupportClient` (via `slaClass`/`isOpen`) \u2192 support module `loadDetail` \u2192 `mapRow` \u2192 `evaluateSla`; `addNote` triggers `stampBreaches` plus an audit write.\\n2. **Payments reconciliation** \u2014 `PaymentsConsole.reconcile` \u2192 `loadOrders` + `loadRefunds` + `loadRest`, then `message` on completion.\\n3. **Schema exploration** \u2014 admin `index.ts` \u2192 `buildSchemaErd`; `AdminClient` renders `SchemaErdStudio`, which lays out columns (`colsOf`) and connector paths (`elbowPath`).\\n4. **Audit export** \u2014 reporting `index.ts` \u2192 `bumpSplit` / `daysLeftFor` \u2192 `eventsToCsv` \u2192 `csvCell`; `listAuditEvents` clamps large reads.\\n5. **Performance transparency** \u2014 `AccountSettingsPage` and `SettingsClient` \u2192 `useApi` \u2192 `trackNetworkInstant` \u2192 `push`, ending in `isRecording` or `emit`.\\n\\n## Relationship diagram\\n\\n```mermaid\\ngraph TD\\n    subgraph Backend\\n        A[Admin Router] --&gt; B[Audit Store]\\n        C[Reporting Router] --&gt; B\\n        D[Support Router] --&gt; B\\n        E[Assurance Scheduler] --&gt; F[Assurance Sweep]\\n        F --&gt; B\\n    end\\n    subgraph Frontend\\n        G[Platform Console] --&gt; A\\n        H[Support Mailbox] --&gt; D\\n        I[Settings / Account] --&gt; J[useApi]\\n        J --&gt; K[Perf Store]\\n    end\\n```\\n\\nThe audit store is the single source of truth for support, reporting, and assurance activity; the frontend routes map one-to-one onto the backend routers, with `useApi` as the common transport layer.\",\"api-backend-infrastructure-backend-src\":\"# API &amp; Backend Infrastructure \u2014 backend-src\\n\\n# API &amp; Backend Infrastructure \u2014 `backend-src`\\n\\n## Purpose\\n\\nThe backend is the Assureworx HTTP API and background-job host. It implements tenant-scoped construction compliance workflows: party onboarding, document/artifact evidence, identity verification, payments, wallet passes, notifications, and audit. It sits behind the Next.js BFF and is deployed to Azure Container Apps.\\n\\nThe module is built around three invariants:\\n\\n1. **The repository layer is the enforcement boundary.** Every query runs inside a transaction scoped to a tenant org via Postgres row-level security (RLS) session settings. The API layer never bypasses that scope.\\n2. **Config fails closed.** Anything that would silently weaken a deployed environment \u2014 a leaked session secret, a live Stripe key on DEV, a dev-auth flag on TEST/PROD \u2014 throws at boot.\\n3. **Expensive work leaves the request path.** Document extraction, geocoding, email/SMS delivery, and payment reconciliation run as background jobs or scheduled timers.\\n\\n## Request lifecycle\\n\\n`createApp()` (in `app.ts`) assembles a single Express app. Middleware order is deliberate and load-bearing; moving a webhook mount after the JSON body parser breaks HMAC verification.\\n\\n```mermaid\\nflowchart LR\\n    A[Request] --&gt; B[Middleware: helmet \u00b7 CORS \u00b7 globalLimiter]\\n    B --&gt; C{Provider webhook?}\\n    C --&gt;|yes| D[Raw-body HMAC authbefore body parsers]\\n    C --&gt;|no| E[JSON body \u00b7 cookies \u00b7 audit context]\\n    E --&gt; F[CSRF guard + mutation limiter]\\n    F --&gt; G[Module routers]\\n    G --&gt; H[Envelope + error handler]\\n```\\n\\nKey decisions in the chain, in order:\\n\\n1. **`trust proxy = 1`** \u2014 Azure Container Apps / Front Door terminate TLS. Without this, rate limiting keys every user on the proxy hop, collapsing them into one shared bucket (or trusting a spoofable `X-Forwarded-For`).\\n2. **Helmet + explicit `Permissions-Policy`** \u2014 Helmet 8 no longer ships the header; the middleware sets it manually (`camera=(), microphone=(), geolocation=(), payment=(), interest-cohort=()`).\\n3. **CORS** \u2014 only an explicit allowlist from `config.corsOrigin`; never reflected origin with credentials.\\n4. **`globalLimiter`** \u2014 the site-wide ceiling, running before any body parsing. Keyed per session (hashed raw cookie) rather than per IP, so an authenticated browser gets its own budget. See [Rate limiting](#rate-limiting).\\n5. **Raw-body webhooks mounted before `express.json`** \u2014 identity, Stripe, and Twilio webhooks authenticate by signature/HMAC over the untouched request body, and must not go through the CSRF guard. Each router applies its own body parser.\\n6. **`express.json({ limit: \\\"3mb\\\" })`** \u2014 enough for base64 selfie thumbnails in wallet passes.\\n7. **Entra OnOtpSend extension** \u2014 server-to-server, returns Entra JSON (not the `ApiEnvelope`), so it sits after the JSON parser but before the CSRF guard.\\n8. **Email webhook (ACS/Event Grid)** \u2014 authenticated by a high-entropy query secret, used for both subscription validation and delivery events.\\n9. **`cookieParser()` + `bindAuditContext`** \u2014 stamps IP/UA/`x-request-id` onto every audit write for the request.\\n10. **`csrfGuard`** \u2014 defence-in-depth on top of `SameSite=Lax` cookies. Rejects mutations whose `Origin` is not on the CORS allowlist or whose `Sec-Fetch-Site` indicates a foreign site.\\n11. **`/api/v1` router** \u2014 applies `mutationLimiter` to all non-GET/HEAD/OPTIONS requests (the site-wide write ceiling), then mounts the 21 feature routers.\\n12. **`errorHandler`** \u2014 the sole error middleware; converts `HttpError`, `ZodError`, malformed JSON, and database-unavailable into the error envelope.\\n\\n`/health` is mounted before the v1 router and returns deploy metadata (`commit`, `builtAt`, `dirty`, `promotable`) from config.\\n\\n## Boot and shutdown\\n\\n`server.ts` is the entry point. Import order is critical: `./shared/telemetry.js` is a side-effect import that must run first so OpenTelemetry patches `http`/`pg`/`redis` before anything uses them.\\n\\nBoot sequence:\\n\\n1. `registerJobHandlers()` \u2014 must run before `startJobWorkers()`. BullMQ only starts workers for handlers present at boot; lazy registration left email/SMS jobs sitting in `wait` forever.\\n2. `ensureSmsJob()` \u2014 ensures the recurring SMS pump job exists.\\n3. `startJobWorkers()` \u2014 starts BullMQ workers when Redis is configured; no-op otherwise.\\n4. Start the five schedulers, each a `setInterval`-backed timer:\\n   - `startPaymentReconcileTimer()` \u2014 backstop for every payment path that can be missed; intentionally runs in every replica.\\n   - `startIdentityReconcileTimer()` \u2014 polls for identity outcomes the person never returned to collect, and sweeps sessions that died mid-check.\\n   - `startAssuranceSweepTimer()` \u2014 Q20 calendar expiry \u2192 party status + link readiness (evidence / identity / consent).\\n   - `startRetentionSweepTimer()` \u2014 ADR-0027 retention clocks (artifacts, evidence, checks, events, consent, closures).\\n   - `startSmsSweepTimer()` \u2014 re-drives SMS rows staged but not pumped (Redis blip / crash between commit and enqueue).\\n5. `createApp()` and `app.listen()`.\\n\\nGraceful shutdown on `SIGTERM`/`SIGINT` runs in order: stop all schedulers, `server.close()` with a 10-second hard cap (so a stuck keep-alive client cannot hang the container), then `stopJobSystem()` to drain workers and close queues. A `shuttingDown` flag makes the handler idempotent.\\n\\n## Configuration\\n\\n`shared/config.ts` resolves the entire environment at boot. Nothing is read lazily; a bad value fails the process before it listens.\\n\\n**Tier resolution** \u2014 `ASSUREWORX_ENV` (`local | dev | test | prod`) is the authoritative signal, because Azure DEV runs with `NODE_ENV=production`. `isDeployed` is true if *either* `NODE_ENV=production` or `ASSUREWORX_ENV=test|prod`, and gates every development fallback. This closes a historical bug where a host could set `ASSUREWORX_ENV=test` without `NODE_ENV`, and the app booted with the committed session secret while reporting a hardened tier.\\n\\nNotable validation rules:\\n\\n| Setting | Rule |\\n|---|---|\\n| `SESSION_SECRET` | Required everywhere; \u226532 bytes; refused if in the known-leaked set (`dev-only-secret-change-in-azure`, `secret`, \u2026). Local dev generates one via `scripts/secure-local-infra.ps1`; unit tests get an isolated placeholder. |\\n| `DOC_INTEL_ENDPOINT` | If set, must be `https` and host must end in `.cognitiveservices.azure.com`. The endpoint decides where customer compliance documents are sent, so a typo would be an exfiltration channel. |\\n| `STRIPE_SECRET_KEY` | If set, must match `sk_\\\\|rk_` test/live shape. A **live key is refused outside the prod tier** \u2014 DEV/TEST run the same payment code paths as PROD, and a live key there would take real money during click-through testing. |\\n| `ALLOW_DEV_AUTH=1` | Boot-refused on `test`/`prod`. Passwordless persona login is local/DEV only, and still requires `DEV_AUTH_SECRET`. |\\n| `CORS_ORIGIN` | Required on deployed tiers; comma-separated allowlist. |\\n| `SMS_NOTIFICATIONS_ENABLED` | On test/prod, requires `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_MESSAGING_SERVICE_SID`, validated by shape (`AC\u2026`, `MG\u2026`). |\\n\\nThe config object also carries deliberate **non-configurable** values pinned in source, documented inline: the Document Intelligence model (`prebuilt-read`) and the Stripe price. Both are cost ceilings \u2014 an environment variable that can move between billing meters is an unbounded liability.\\n\\n`config.ts` exposes two testable pure functions: `assertAllowDevAuthPosture(deployEnv, requested)` and `resolveAllowDevAuth(deployEnv, requested)`, covered in `config-dev-auth.test.ts`.\\n\\n## Data access and RLS\\n\\n`shared/db/pool.ts` owns the single `pg.Pool`. The pool has an `error` listener because idle clients can emit fatal errors when the server terminates them (`admin_shutdown` / `57P01`); without the listener, Node treats it as an unhandled `error` event and kills the process \u2014 exactly what the local k6 stress run hit around 400 VUs.\\n\\nPool settings: `max` from `DB_POOL_MAX`, `connectionTimeoutMillis: 10_000` (fail fast when exhausted), `statement_timeout` and `query_timeout` both 30s (a stuck statement cannot pin a pool slot), `idleTimeoutMillis: 30_000`.\\n\\nThe transaction helpers are the core of tenant isolation. Each one opens a client, sets Postgres session GUCs inside the transaction, runs the callback, and commits/rolls back:\\n\\n| Helper | Sets | Used for |\\n|---|---|---|\\n| `withScope(scopePartyId, fn)` | `app.scope` | Tenant-scoped reads/writes on RLS-protected tables. Repositories remain the primary enforcement (ADR-0003); RLS is defence-in-depth. |\\n| `withBypass(fn)` | `app.bypass = on` | Platform-admin operations. Always pair with an audit event. |\\n| `withActor(actorId, fn, opts?)` | `app.actor`, optional `app.scope` | RLS tables keyed on actor (support tickets, membership loads). |\\n| `withTenant(actorId, scopePartyId, fn)` | both `app.actor` and `app.scope` | Authenticated tenant transactions where identity/evidence RLS needs self + network visibility. |\\n\\n`advisoryXactLock(tx, key)` takes a hash-derived `pg_advisory_xact_lock` inside the transaction for cross-row uniqueness races (e.g. ABN claims) where `SELECT`-then-`INSERT` is racy under concurrency. The lock auto-releases on commit/rollback.\\n\\nThe `Queryable` type (a `{ query }`-shaped object) is what callbacks receive, so the same repository code works under any scope helper or a bare client.\\n\\n## HTTP conventions\\n\\n**Envelope** (`shared/http/envelope.ts`) \u2014 every API response is either `ApiSuccess` (`{ success: true, data, meta? }`) or `ApiError` (`{ success: false, error: { code, message, details? } }`). Helpers: `ok(res, data, meta?)` \u2192 200, `created(res, data)` \u2192 201, `fail(res, status, code, message, details?)`.\\n\\n**Errors** (`shared/http/errors.ts`) \u2014\\n\\n- `HttpError(status, code, message, details?)` is the base class, with factories: `badRequest`, `unauthorized`, `forbidden`, `notFound`, `conflict`.\\n- `h(fn)` wraps async route handlers so rejections reach the error middleware.\\n- `errorHandler` maps, in order: `HttpError` \u2192 its status/code; `ZodError` \u2192 `400 VALIDATION_FAILED` with field paths only (never Zod internals); body-parser parse failures \u2192 `400 BAD_JSON`; Postgres/connection refusals (`ECONNREFUSED`, `57P01`, `57P03`) \u2192 `503 DATABASE_UNAVAILABLE` with a hint to start local Postgres; anything else \u2192 `500 INTERNAL_ERROR` with the error logged server-side.\\n\\n**Redirect safety** (`shared/http/safe-return-to.ts`) \u2014 `safeReturnTo(raw, fallback)` accepts only path-only values: must start with `/`, must not start with `//`, must not contain backslashes or `://`, and must match `/^\\\\/[\\\\w\\\\-./?&amp;=%#]*$/`. This blocks protocol-relative, scheme, and backslash open-redirect tricks on post-auth redirects.\\n\\n## Security middleware\\n\\n### CSRF (`shared/middleware/csrf.ts`)\\n\\n`csrfGuard` runs on every non-safe method after cookie parsing. `SameSite=Lax` is the primary control; the guard is defence-in-depth. Decision order:\\n\\n1. `Sec-Fetch-Site` of `same-origin` / `same-site` / `none` \u2192 allow.\\n2. No `Origin` at all: if a session cookie exists and Fetch Metadata is absent, refuse \u2014 legacy cross-site form posts used to slip through here. Otherwise allow (non-browser clients like curl often omit `Origin`).\\n3. `Origin` present: allow only if it is in `config.corsOrigin`.\\n\\n### Rate limiting (`shared/middleware/rate-limit.ts`)\\n\\nRate limiters use a `RedisStore` when Redis is configured (shared across replicas) and fall back to express-rate-limit's in-memory store locally. All responses use the `RATE_LIMITED` error code.\\n\\nThe key design decision is the **global limiter key**: `sessionOrIpKey` SHA-256-hashes the raw session cookie value (accepting both `awx_session` and `__Host-awx_session`) rather than the IP. A pure IP key puts every user behind one NAT \u2014 and in dev, the browser *plus* the Next.js BFF's own server-side fetches \u2014 into a shared bucket, so normal navigation tripped 429s. The hashed cookie never needs to be parsed or trusted; an attacker forging a cookie only splits their own bucket.\\n\\nLimiter inventory:\\n\\n- `globalLimiter` \u2014 600/min per session-or-IP, mounted first.\\n- `authLimiter` \u2014 40 per 15 min for sign-in paths.\\n- `mutationLimiter` \u2014 site-wide write ceiling applied on the v1 router to non-GET/HEAD/OPTIONS.\\n- Per-route budgets for expensive/spammy paths: `inviteLimiter`, `walletLimiter`, `walletIssueLimiter`, `walletAppleWebServiceLimiter`, `abrLookupLimiter`, `contentLimiter`, `uploadLimiter`, `extractLimiter`, `geocodeLimiter`, `identityStartLimiter` (actor-keyed, 5/hour \u2014 every call can cost money), `identityWebhookLimiter`, `supportLimiter`, `identityReconcileLimiter`, `paymentReconcileLimiter`, `checkoutLimiter`, `paymentWebhookLimiter`, `smsWebhookLimiter`.\\n\\nThe tight budgets for expensive routes are captured as data in `RATE_LIMIT_POLICY` so policy tests can assert the numbers without probing express-rate-limit internals \u2014 e.g. invite resend (5/15min) is capped far below invite create (60/15min), and the assurance sweep is 3/hour versus the prior effective ceiling of 600/min via the global limiter.\\n\\n`LOAD_TEST_RELAX_LIMITS=1` raises global/auth ceilings to 50,000 **only** when `ALLOW_DEV_AUTH=1` **and** `ASSUREWORX_ENV=local` \u2014 it can never arm on Azure.\\n\\n## Background jobs\\n\\n`shared/jobs/queue.ts` provides a single job abstraction with two transports:\\n\\n- **Redis configured** \u2192 BullMQ. Enqueue is fail-closed: any Redis/BullMQ error propagates to the caller; there is no silent in-process fallback. After `DEFAULT_ATTEMPTS` (3) with exponential backoff (2s base), failed jobs are copied to a `dlq` queue with the original payload, failure reason, and attempt count.\\n- **No Redis** \u2192 an in-process, concurrency-limited queue. Fire-and-forget with a loud warning that retries/DLQ don't exist. Concurrency is per-job-name (`LOCAL_CONCURRENCY`), e.g. 4 for email, 2 for artifact extraction, 1 for geocoding.\\n\\nThe `JobName` union is the contract; `registerJob(name, handler)` must happen before `enqueue(name, \u2026)` \u2014 enqueue throws `Job handler not registered` otherwise (asserted in `queue.test.ts`). `startJobWorkers()` launches a BullMQ `Worker` per registered handler at boot. `stopJobSystem()` drains workers, closes queues, and closes Redis.\\n\\n### Handlers (`shared/jobs/handlers.ts`)\\n\\n`registerJobHandlers()` is called once from server boot and registers, in order:\\n\\n- `email.send` (`registerEmailJob`) \u2014 branded email delivery via ACS, with recipient-hash logging and idempotency claim.\\n- `sms.send` (`registerSmsJob`) \u2014 transactional SMS pump.\\n- `payment.event` / `payment.reconcile` (`registerPaymentJobs` from `modules/payments/webhook.ts`) \u2014 Stripe event processing and reconciliation.\\n- `artifact.extract` \u2014 the document-extraction ladder. Reads the stored object from artifact storage, computes a SHA-256, checks the preview cache (`getPreviewExtraction` + `previewSatisfiesUpload`); if a satisfying live-preview extraction exists it is reused without cloud cost, otherwise `extractDocument` runs. The declared upload metadata is merged in (`withDeclared`), stored in `attrs.extraction` (`forStorage`), and the artifact status is decided (`decideArtifactStatus`). Crucially it **never reopens a human reject** (`F03`); a job arriving after a human decision does not override it. If the status changed, `evaluateAndPersistStatus` recomputes the party's verified status.\\n- `geocode.site` \u2014 resolves a site address via Nominatim and stores `attrs.geo`. It has a stale-job guard: before doing work, it compares the job's query against the live address in the DB and against any already-stored `geo.query`, and skips if a newer enqueue owns the work. The write path re-checks under `FOR UPDATE` before committing.\\n\\nAll job handlers run inside `withScope(job.scopeOrgId, \u2026)`, so even background work is tenant-scoped.\\n\\n## Schedulers\\n\\nThe five timers are thin wrappers that on each interval run a scoped DB sweep and are tolerant of being started in every replica (the work itself is idempotent or uses advisory locks). Config controls interval and batch size, e.g. `assurance.intervalMinutes` / `assurance.batchLimit`, `retention.*`, `identityReconcileIntervalMinutes`, `stripe.reconcileIntervalMinutes`, `sms.sweepIntervalSeconds`. Setting an interval to 0 disables that sweep \u2014 explicitly documented as acceptable only for retention (off by default until migration 030 and operator opt-in, running in dry-run by default).\\n\\n## Cache\\n\\n`shared/cache.ts` exposes four functions \u2014 `cacheGet`, `cacheSet` (with TTL seconds), `cacheDel`, `cacheDelPrefix` \u2014 backed by Redis when available, otherwise a process-local `Map` with TTL pruning (triggered when the map exceeds 500 entries). Local fallbacks exist for the membership cache and similar low-stakes reads. `cacheDelPrefix` uses Redis `SCAN` rather than `KEYS`, so it never blocks the Redis event loop.\\n\\n## Code translation\\n\\n`shared/codes.ts` is the single translation layer between physical DB codes (`S01`, `K02`, `Q03`, \u2026) and the logical API vocabulary (`verified`, `trade_company`, `sub_request`, \u2026). The module comment states the invariant: **physical codes never leave the repository layer; the API speaks logical.** The `invert()` helper derives reverse maps, so adding a code to the forward map automatically gets the backward lookup.\\n\\nCode groups covered: party status/kind, org class, app roles, request kind/status, requirement blocks, link readiness, node kind, check method, trade tags (with display names like `G01 \u2192 Electrician`), artifact classes (with display names like `A004 \u2192 Public Liability Insurance (\u2265 $10M)`), support tickets, and the paid-verification domain (verification cases, eligibility, funded-by, payer kind, order kind/status, allocation/attempt status, provider events, ledger entry kinds, refunds, disputes).\\n\\n## Seed data\\n\\n`seed.ts` is an idempotent dev seed for realistic Australian construction data. Run via `npm run seed --workspace @assureworx/backend`.\\n\\n- **Sentinel**: it checks for actor `jordan@campbell.dev`; if present, it patches demo avatars, normalises legacy em/en dashes in notification subjects, and ensures the depth/portability graph \u2014 it does not duplicate data.\\n- **Platform admin** (`founder@assureworx.com.au`) is created with role `R01` and a 10-year ledger, and is safe to re-run.\\n- **Tier 1**: Campbell Constructions (principal), Russo Electrical (verified trade), Harbour Plumbing (suspended), Skyline Scaffolding (pending), Tommo Carpentry (sole trader), plus projects (`Riverside Quarter`, `Northgate Logistics Hub`), sites, trade link states, requests in various statuses, notifications, and audit events.\\n- **Depth** (company-under-company): Campbell \u2192 Russo \u2192 Voltaic Services \u2192 Kirra Nguyen, derived through the request chain with `parent_request_id`, not from `t_rel`.\\n- **Portability** (ADR-0012): Tom Delaney holds one profile engaged by two unrelated principals \u2014 Campbell via the original seed, Meridian Group via a `Q04` connection request \u2014 so no document was re-uploaded and no second subscription exists.\\n- **Validity discipline**: demo identity documents and licences are seeded two years out so profiles read as `current` rather than tripping the 30-day `expiring_soon` window on every run. ABNs are checksum-valid (`makeAbn` derives a valid ABN from a 9-digit stem); the seed asserts `isValidAbn` before inserting.\\n\\nThe final `console.table` lists personas by role and org \u2014 the canonical login map for local demos.\\n\\n## Module layout\\n\\n```\\nbackend/src/\\n\u251c\u2500\u2500 app.ts                  # Express app factory\\n\u251c\u2500\u2500 server.ts               # Boot, schedulers, graceful shutdown\\n\u251c\u2500\u2500 seed.ts                 # Idempotent dev seed\\n\u251c\u2500\u2500 modules/                # Feature routers (auth, parties, requests, payments, \u2026)\\n\u2514\u2500\u2500 shared/\\n    \u251c\u2500\u2500 cache.ts            # Redis / in-memory cache\\n    \u251c\u2500\u2500 codes.ts            # Physical \u2194 logical code maps\\n    \u251c\u2500\u2500 config.ts           # Fail-closed environment config\\n    \u251c\u2500\u2500 db/pool.ts          # pg Pool + RLS transaction helpers\\n    \u251c\u2500\u2500 http/               # envelope, errors, safe-return-to\\n    \u251c\u2500\u2500 jobs/               # queue (BullMQ + local fallback), handlers\\n    \u251c\u2500\u2500 middleware/         # csrf, rate-limit\\n    \u2514\u2500\u2500 services/           # email, sms, extraction, idempotency, audit, \u2026\\n```\\n\\n## Contributing notes\\n\\n- **Adding a route**: mount the router in `app.ts` under `/api/v1`. If it is a write path, the global `mutationLimiter` already applies; add a tighter per-route limiter for expensive/spammy operations and record its budget in `RATE_LIMIT_POLICY` with a companion assertion in `rate-limit-policy.test.ts`.\\n- **Adding a background job**: extend the `JobName` union, register the handler in `registerJobHandlers()` (before `startJobWorkers()` at boot), and call `enqueue` only after registration. If the job mutates tenant data, run its body inside `withScope`/`withTenant`.\\n- **Adding a config value**: resolve it eagerly in `config.ts`, fail closed on deployed tiers, and document any cost/security implications inline (the module convention is that anything that moves money or customer data is pinned in source, not env).\\n- **Adding a DB code**: extend the relevant map in `codes.ts`; the `invert()`-derived reverse map is automatic.\\n- **Transaction scope**: never call `pool.query` directly for tenant data \u2014 use one of the `with*` helpers so RLS sees the correct `app.scope` / `app.actor`. Platform-admin mutations must go through `withBypass` and always write an audit event.\",\"api-backend-infrastructure-frontend-src\":\"# API &amp; Backend Infrastructure \u2014 frontend-src\\n\\n# API &amp; Backend Infrastructure \u2014 frontend-src\\n\\n## Overview\\n\\nThe frontend talks to the AssureWorx Express API over two distinct paths:\\n\\n- **Server components** fetch during the Next.js render. `server-api.ts` forwards the browser's session cookie directly to `API_ORIGIN` and returns the data to the page loader, which serializes it into the page props.\\n- **Client components** fetch through the Next.js BFF rewrite (same-origin `/api/v1/*`) via `api.ts`, or use the `useApi` hook, which layers request deduplication, a tab-lifetime response cache, and server-seeded data on top of `api.ts`.\\n\\nBoth paths share one contract: every endpoint returns an `ApiEnvelope` \u2014 `{ success: true, data, meta }` or `{ success: false, error: { code, message, details } }` \u2014 and both layers translate that envelope into typed errors: `ApiClientError` on the client, `ServerApiError` on the server.\\n\\nThe module also provides `load-app-page.ts` (server-side page-loading helpers with auth redirects and soft-fail semantics) and dev-only performance instrumentation that traces server API calls per request and marks client network activity.\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    subgraph Browser\\n        React[React Components]\\n        UseApi[use-api.ts]\\n        ApiClient[api.ts]\\n        React --&gt;|useApi / api| UseApi\\n        UseApi --&gt;|sharedGet / api.get| ApiClient\\n    end\\n    subgraph Next.js Server\\n        Loaders[load-app-page.ts]\\n        ServerApi[server-api.ts]\\n        BFF[BFF rewrite /api/v1/*]\\n        Loaders --&gt;|serverGet| ServerApi\\n    end\\n    subgraph Express API\\n        API[Express API /api/v1]\\n    end\\n    ApiClient --&gt;|same-origin fetch| BFF\\n    BFF --&gt; API\\n    ServerApi --&gt;|API_ORIGIN + session cookie| API\\n```\\n\\nThree ways data flows:\\n\\n1. **Server-rendered pages.** Page loaders call `loadAppPage` / `loadAppPageOptional`, which call `serverGet`. The Next server fetches `${API_ORIGIN}/api/v1${path}` with the request's cookies forwarded, and the resulting data is passed into client components as `initialData`.\\n2. **Client-side data.** Components use `useApi(path)`. The hook serves from its cache immediately, dedupes in-flight GETs, and revalidates in the background.\\n3. **Mutations &amp; uploads.** Components call `api.post / put / patch / del` for JSON mutations and `api.upload` for multipart `FormData` uploads with progress callbacks and cancellation.\\n\\n## The envelope contract\\n\\nBoth `request()` in `api.ts` and `serverRequest()` in `server-api.ts` parse the same `ApiEnvelope`:\\n\\n- `success: true` \u2192 returns `{ data, meta }`\\n- `success: false` \u2192 throws a typed error using the API's `code`, `message`, and `details`\\n- JSON parse failure \u2192 throws `BAD_RESPONSE` with the HTTP status\\n\\n`ApiClientError` and `ServerApiError` have identical shapes: `status`, `code`, `message`, and optional `details`. The only difference is where they are thrown.\\n\\n| Code | Raised by | Meaning |\\n|---|---|---|\\n| `BAD_RESPONSE` | `request`, `serverRequest`, `upload` | Server returned non-JSON or a non-envelope payload |\\n| `REQUEST_CANCELLED` | `request` | Caller aborted via `AbortSignal` \u2014 expected, not a fault |\\n| `UPLOAD_CANCELLED` | `upload` | Upload aborted via `AbortSignal` |\\n| `NETWORK_ERROR` | `upload` only | XHR `onerror` \u2014 connection dropped |\\n| `envelope.error.code` | any | The API's own error code, passed through verbatim |\\n\\nNote that for plain `fetch`, a transport-level failure is rethrown raw \u2014 it is not wrapped into `ApiClientError`. Only uploads normalize network failures to `NETWORK_ERROR`.\\n\\n## Client API \u2014 `api.ts`\\n\\n### `request(method, path, body?, signal?)`\\n\\nCore fetch wrapper used by everything except uploads.\\n\\n- URL: `/api/v1${path}`, same-origin, `credentials: \\\"same-origin\\\"`.\\n- `Content-Type: application/json` is set only when a body is present.\\n- In dev, opens a perf span via `perf.beginNetwork({ method, path, source: \\\"fetch\\\", cache: \\\"n/a\\\" })`, ended with status `ok`, `error`, or `abort`.\\n- If the response body fails to parse as an envelope, throws `ApiClientError(res.status, \\\"BAD_RESPONSE\\\", ...)`.\\n- If `envelope.success === false`, throws `ApiClientError(res.status, envelope.error.code, envelope.error.message, envelope.error.details)`.\\n- An `AbortError` DOMException is converted to `ApiClientError(0, \\\"REQUEST_CANCELLED\\\", \\\"Request cancelled.\\\")`. Aborts are recorded as `abort` status in the perf span \u2014 never as errors \u2014 so they don't count toward error rates or duplicate findings.\\n- All other errors rethrow unchanged.\\n\\n### `upload(path, body, options?)`\\n\\nXHR-based multipart upload, because XHR (unlike `fetch`) exposes upload progress. Options:\\n\\n- `onProgress?: (percent: number) =&gt; void`\\n- `signal?: AbortSignal` \u2014 if already aborted, rejects immediately with `UPLOAD_CANCELLED`; otherwise calls `xhr.abort()` on abort.\\n\\nNotable design decisions:\\n\\n- **Progress throttling.** XHR emits progress far faster than the screen updates, and each callback becomes a `setState` in the consuming form. Unthrottled, a crew uploading at once is the dominant source of UI jank. The implementation fires the leading edge immediately, drops duplicate percentages, and enforces a 100 ms minimum interval between callbacks. The final `100` is always delivered separately from `onload`, so dropping a trailing tick is safe.\\n- **Error mapping.** `onerror` \u2192 `ApiClientError(0, \\\"NETWORK_ERROR\\\", ...)`; `onabort` \u2192 `ApiClientError(0, \\\"UPLOAD_CANCELLED\\\", ...)`; parse failure \u2192 `BAD_RESPONSE`; envelope failure \u2192 API error passed through.\\n\\n### The `api` object\\n\\n| Member | Signature | Returns |\\n|---|---|---|\\n| `get` | `(path, signal?)` | `data` |\\n| `getWithMeta` | `(path, signal?)` | `{ data, meta }` |\\n| `post` | `(path, body?)` | `data` |\\n| `put` | `(path, body?)` | `data` |\\n| `patch` | `(path, body?)` | `data` |\\n| `del` | `(path)` | `data` |\\n| `upload` | `(path, body, options?)` | `data` |\\n\\nOnly GETs accept an `AbortSignal` on the public API object, though `request` itself supports it.\\n\\n## Server API \u2014 `server-api.ts`\\n\\nThis module is marked `\\\"server-only\\\"` \u2014 it can only be imported from Server Components or other server-side code.\\n\\n### `serverRequest(method, path, body?)`\\n\\nThe server-side counterpart to `request`, with two critical differences:\\n\\n- **Base URL.** Uses `process.env.API_ORIGIN` (default `http://localhost:4000`) + `/api/v1${path}` \u2014 a direct server-to-server call, not through the BFF rewrite.\\n- **Cookie forwarding.** Reads the request cookie jar via `cookies()` from `next/headers`, joins all cookies into a single `Cookie` header, and sends it to Express. The cookie is httpOnly and opaque to Next \u2014 never decoded. Express validates it exactly as it would a browser call through the BFF rewrite.\\n\\n`cache: \\\"no-store\\\"` is mandatory: session-scoped responses must never be shared across users or requests.\\n\\nError semantics mirror the client: `BAD_RESPONSE` on parse failure, `ServerApiError` on `envelope.success === false`, raw rethrow on transport failure.\\n\\n### Request tracing \u2014 `AsyncLocalStorage`\\n\\nEach server API call is logged in dev via `traceServerRequest`. Grouping calls by the page render that triggered them is non-trivial:\\n\\n- A module-level counter would be shared across concurrent users.\\n- `React.cache()` does not work here \u2014 outside a render scope it returns a fresh value every call, so the count never accumulates.\\n- `AsyncLocalStorage` actually follows one request across its awaits.\\n\\n`withServerTrace(fn)` runs `fn` inside the trace store (dev only; production is a pass-through). Page loaders wrap their data loading in it via `traceAppPage` to see the whole waterfall as one numbered sequence.\\n\\nDev logging format:\\n\\n```\\n[perf:server] #2 GET /projects 124ms 200 (+18ms into render)\\n```\\n\\nWhen a call runs outside a trace wrapper, it logs ungrouped. At 6+ calls in one render, a warning fires:\\n\\n```\\n[perf:server] 6+ server API calls in one render \u2014 check for a waterfall in the page loader or (app)/layout.\\n```\\n\\n### The `serverApi` object\\n\\n| Member | Signature | Returns |\\n|---|---|---|\\n| `get` | `(path)` | `data` |\\n| `getWithMeta` | `(path)` | `{ data, meta }` |\\n| `post` | `(path, body?)` | `data` (defaults body to `{}`) |\\n\\n`serverGet` is an exported alias for `serverApi.get`, used by page loaders.\\n\\n## Server page loaders \u2014 `load-app-page.ts`\\n\\nThese are the server-side entry points for page data, also marked `\\\"server-only\\\"`.\\n\\n### `loadAppPage(path)`\\n\\nFetches data for an authenticated page. `401` \u2192 `redirect(\\\"/auth/login\\\")`; every other error rethrows so the nearest `error.tsx` handles it.\\n\\nDeliberately does **not** map `403 \u2192 home`. Role mismatches are expected to be caught by `requirePortal` before this runs \u2014 a principal denied on their own home page would redirect-loop if this function tried to compensate.\\n\\n### `loadAppPageOptional(path)`\\n\\nSoft-fail variant for optional secondary fetches. Returns `null` on `403`/`404` \u2014 \\\"nothing to seed here\\\" \u2014 and the client hook fetches the data itself. Everything else propagates:\\n\\n- `401` \u2192 redirect to login\\n- Rate limits, server faults, and other auth failures rethrow\\n\\nThe source comment explains why the error handling is deliberately narrow: swallowing rate limits or server faults rendered a silently empty page, indistinguishable from \\\"you genuinely have no requests\\\" \u2014 which made throttling look like a broken app. Only a clean `403`/`404` means \\\"nothing to seed\\\".\\n\\n### `traceAppPage(fn)`\\n\\nWraps `fn` (typically a `Promise.all` of page loaders) in `withServerTrace` so all server API calls made during that render are logged as one numbered sequence. Dev only. `withServerTrace` is re-exported from this module.\\n\\n## Client data hook \u2014 `use-api.ts`\\n\\n`useApi` is the primary client-side data path. It layers four behaviors on top of `api.get`:\\n\\n1. **In-flight deduplication** \u2014 two components mounting at once share one request.\\n2. **Tab-lifetime response cache** \u2014 navigating away and back doesn't refetch from zero.\\n3. **Server seeding** \u2014 data already fetched by a Server Component is reused client-side.\\n4. **Background revalidation** \u2014 cached data shows immediately, then a fresh GET runs in the background.\\n\\n### Module-level caches\\n\\n| Structure | Purpose |\\n|---|---|\\n| `inflight: Map&gt;` | One pending GET per path. `sharedGet` returns the existing promise instead of issuing a second fetch. |\\n| `cache: Map` | Last known response per path, kept for the life of the tab. Entries are only replaced by newer responses, so a stale read is at worst one revalidation behind \u2014 and a revalidation is always in flight when a cached value is served. |\\n| `cacheWrittenAt: Map` | Wall-clock write time, used for the shell TTL. |\\n| `freshFromServer: Set` | Paths whose cached value came from a Server Component on this navigation. Server-fetched data is fresh by definition, so re-GETting it on client mount is a wasted round-trip. Flags are consumed on use so later remounts revalidate normally. |\\n\\n### Shell paths\\n\\n`SHELL_PATHS = { \\\"/notifications\\\", \\\"/auth/entra/status\\\" }` are shared across every `(app)` route's shell chrome. They get a `SHELL_STALE_MS = 60_000` TTL: remounts and soft-navigations trust a cache entry younger than 60 s instead of re-hitting the API on every click.\\n\\n### `useApi(path, options?)`\\n\\n- `path: string | null` \u2014 `null` yields `{ data: null, loading: false, error: null }` and never fetches.\\n- `options.initialData` \u2014 a server-fetched payload from a Server Component; seeds the cache before the first paint.\\n\\nBehavior in detail:\\n\\n- **Initial state** \u2014 seeds the cache from `initialData` if provided, then serves `cache.get(path)` first, falling back to `initialData`.\\n- **Path changes** \u2014 adopts the cached value synchronously (data instead of a skeleton), then revalidates in the background.\\n- **Seed skip** \u2014 if `freshFromServer.delete(path)` succeeds and a cached value exists, the immediate GET is skipped (logged as `cache: \\\"seed-skip\\\"` in dev). Consuming the flag means remounts revalidate normally.\\n- **Shell hit** \u2014 cached, within `SHELL_STALE_MS`, and on `SHELL_PATHS` \u2192 no revalidation (logged as `cache: \\\"hit\\\"`).\\n- **Loading flag** \u2014 `loading` is true only when there is no data at all. Background revalidation (`silent = true`) never flashes a skeleton.\\n- **Errors** \u2014 if a background revalidation fails, the existing data stays and `error` is set to the `ApiClientError` message, or a generic fallback.\\n- **Stale-path guard** \u2014 the component tracks its current path in a ref and compares it after each await, so a slow response for a previous route can never overwrite the new route's state.\\n- **Return value** \u2014 `{ data, loading, error, refresh }`, where `refresh()` is a silent refetch that writes through to the cache. Call it after mutations.\\n\\n### `sharedGet(path, source)`\\n\\nInternal deduping fetch. Returns the in-flight promise if one exists (logged as `cache: \\\"deduped\\\"` in dev); otherwise calls `api.get`, writes the result to the cache, and clears the in-flight entry in `finally`. `source` is `\\\"useApi\\\"` or `\\\"prefetch\\\"` for perf logs.\\n\\n### `seedApiCache(path, data)`\\n\\nWrites into the cache and marks the path as server-fresh. Used by Server Components passing data down to client components.\\n\\n### `prefetchApi(path)`\\n\\nStarts a GET now so a component that needs it later mounts against a warm cache \u2014 used to flatten the boot waterfall: the shell's endpoints don't have to wait for `/auth/me` to resolve and the shell to mount before they're requested. No-ops if the path is already cached or in flight. Failures are swallowed \u2014 this is an optimisation, and the real `useApi` call still runs and surfaces the error properly.\\n\\n### `invalidateApiCache(path?)`\\n\\nDrops cached responses after a mutation. With no argument, clears all three maps (cache, timestamps, fresh-from-server flags). With a path, clears just that entry. Use when a mutation invalidates unrelated paths.\\n\\n## Performance observability\\n\\nAll instrumentation is gated on `process.env.NODE_ENV === \\\"development\\\"` \u2014 production ships with zero spans and zero log statements.\\n\\n- **`api.ts`** \u2014 `perf.beginNetwork` spans for JSON requests and uploads; `perf.trackMarker(\\\"upload-progress\\\", ...)` in dev for throttled upload progress.\\n- **`use-api.ts`** \u2014 `perf.trackNetworkInstant` for deduped, seed-skip, and shell cache-hit events.\\n- **`server-api.ts`** \u2014 `[perf:server]` numbered logs per render, with a warning at 6+ server API calls in one render.\\n\\nThe browser PerfRecorder cannot see server-side calls \u2014 they happen during the server render, before any client JS exists \u2014 which is why server tracing uses `AsyncLocalStorage` and `console.log` rather than the client perf store.\\n\\n## Integration with the rest of the codebase\\n\\n- **Server-rendered pages.** Authenticated routes (`(app)/dashboard`, `(app)/projects`, `(app)/team`, `sites/[sid]`, `projects/[id]`, `settings/account`, and similar) call `loadAppPage` for required data and `loadAppPageOptional` for secondary data. `(app)/trades` wraps its loading in `traceAppPage` to see the server-side waterfall.\\n- **Client components.** `TopBar`, `SiteWorkforceManager`, `FundingCallout`, `VerifyTradeClient`, `DocumentsClient`, and others use `useApi` to load data client-side, passing `initialData` when the page loader already seeded the cache.\\n- **Mutations.** Forms call `api.post / put / patch / del`, then either `refresh()` from `useApi` or `invalidateApiCache()` to revalidate affected paths.\\n- **Uploads.** Verification evidence and document onboarding use `api.upload` with progress callbacks wired into form state.\\n- **Prefetching.** Shell chrome uses `prefetchApi` to start `/notifications` and `/auth/entra/status` before the shell mounts, flattening the boot waterfall.\",\"api-backend-infrastructure\":\"# API &amp; Backend Infrastructure\\n\\n# API &amp; Backend Infrastructure\\n\\nThe API &amp; Backend Infrastructure module is the serving layer for Assureworx. It sits behind the Next.js BFF and is responsible for every tenant-scoped construction compliance workflow \u2014 party onboarding, artifact evidence, identity verification, payments, wallet passes, notifications, and audit \u2014 plus the frontend data-access machinery that talks to it.\\n\\n## Sub-modules\\n\\n- [backend-src](backend-src.md) \u2014 Express HTTP API and background-job host. Owns business logic, persistence, and external integrations behind a strict tenant-enforcement boundary.\\n- [frontend-src](frontend-src.md) \u2014 All frontend data access: server-component fetching, client-side hooks, response caching, and the BFF rewrite path.\\n\\n## How the sub-modules fit together\\n\\nThe frontend never calls the Express API directly. Client components issue same-origin requests that the Next.js BFF rewrites to `API_ORIGIN`; server components instead forward the browser's session cookie to the API during render via `server-api.ts`. Both paths converge on a single contract: every endpoint returns an `ApiEnvelope`.\\n\\n`backend-src` is the enforcement boundary behind that contract. Every query executes inside a Postgres transaction scoped to the tenant org via RLS session settings, and the API layer never bypasses that scope. A shared infrastructure core \u2014 tenant-context resolution, idempotency, the job queue, Redis-backed cache, CSRF guard, and HTTP envelope/error helpers \u2014 underpins both sub-modules and is referenced by each.\\n\\n## Key cross-module workflows\\n\\n- **Request lifecycle**: browser \u2192 Next.js BFF (`api.ts` on the client, or `server-api.ts` during render) \u2192 Express handler \u2192 RLS-scoped transaction \u2192 `ApiEnvelope` response.\\n- **Tenant resolution**: `resolveTenantContext` loads memberships from the database (with a Redis membership cache) and scopes the entire request; `csrfGuard` and shared error middleware wrap every route.\\n- **Idempotent operations**: `claimIdempotency` / `releaseIdempotency` guard critical mutations like email invites and OTP sending, keyed per tenant with Redis as the backing store.\\n- **Background jobs**: handlers registered via `registerJobHandlers` run inside `withScope` transactions; jobs are enqueued to Redis, or run locally when Redis is unavailable.\\n- **Client data fetching**: `useApi` deduplicates in-flight requests, caches responses for the tab lifetime, and seeds its cache from server-rendered data \u2014 keeping page flows like Projects, Account Settings, and Site Detail consistent across server and client renders.\\n\\n```mermaid\\nflowchart TB\\n    subgraph frontend[\\\"frontend-src\\\"]\\n        ServerComponents[\\\"Server components(server-api.ts)\\\"]\\n        ClientComponents[\\\"Client components(api.ts / useApi)\\\"]\\n    end\\n    subgraph bff[\\\"Next.js BFF\\\"]\\n        Rewrite[\\\"/api/v1/* rewrite\\\"]\\n    end\\n    subgraph backend[\\\"backend-src\\\"]\\n        Express[\\\"Express API\\\"]\\n        Jobs[\\\"Background job handlers\\\"]\\n        DB[(\\\"Postgres(RLS-scoped)\\\")]\\n    end\\n    subgraph shared[\\\"Shared infrastructure\\\"]\\n        Tenant[\\\"Tenant context(resolveTenantContext)\\\"]\\n        Idem[\\\"Idempotency(claim/releaseIdempotency)\\\"]\\n        Redis[(\\\"Redis\\\")]\\n    end\\n\\n    ServerComponents --&gt;|session cookie| Express\\n    ClientComponents --&gt; Rewrite --&gt; Express\\n    Express --&gt; Tenant\\n    Express --&gt; Idem --&gt; Redis\\n    Express --&gt; DB\\n    Jobs --&gt; DB\\n    Jobs --&gt; Redis\\n```\",\"authentication-session-management-backend-src\":\"# Authentication &amp; Session Management \u2014 backend-src\\n\\n# Authentication &amp; Session Management \u2014 backend-src\\n\\n## Overview\\n\\nThis module owns every way a user gets into Assureworx: Entra External ID hosted OAuth, branded Entra Native Authentication (sign-up, sign-in, password reset), a DEV-only passwordless path, trade self-registration, session issuance/revocation, and login email changes.\\n\\nAll authentication paths converge on the same primitive: **a signed `awx_session` HTTP-only cookie** whose claims identify an `actor` (the login row backed by `core.t_actor`). The module never stores passwords, never exposes IdP tokens to the browser, and keeps session revocation immediate via a per-actor session version counter.\\n\\n```\\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\\n\u2502                     routes /auth/*                          \u2502\\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\\n\u2502  Entra OAuth \u2502  Native Auth   \u2502  DEV login    \u2502  email      \u2502\\n\u2502  /entra/*    \u2502  /native/*     \u2502  /login       \u2502  change     \u2502\\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\\n\u2502                     shared/auth/*                           \u2502\\n\u2502  entra.ts \u00b7 native-auth.ts \u00b7 link-actor.ts \u00b7 session.ts     \u2502\\n\u2502  ensure-trade-profile.ts \u00b7 graph-email.ts                   \u2502\\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\\n\u2502            core.t_actor \u00b7 core.t_grant \u00b7 core.t_party       \u2502\\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n```\\n\\nThe router lives in `modules/auth/index.ts` and is mounted as `authRouter`. Supporting logic is split between two layers:\\n\\n- **`modules/auth/index.ts`** \u2014 HTTP routes, request validation, cookie handling, orchestration.\\n- **`shared/auth/*`** \u2014 protocol clients and domain logic reused by other modules (memberships, onboarding, admin, retention).\\n\\n## Session model\\n\\nSessions are JWTs (`HS256`, issuer `assureworx-api`) stored in an HTTP-only cookie. The claims are defined by `SessionClaims` in `shared/auth/session.ts`:\\n\\n| Claim | Meaning |\\n|---|---|\\n| `actorId` | `core.t_actor.id` |\\n| `sub` | IdP subject \u2014 `dev|` for DEV actors, the Entra `sub` for real accounts |\\n| `email` | Login email |\\n| `activeOrgId` | Organisation party the user is currently operating as; drives tenant-scoped RLS |\\n| `sv` | `session_ver` \u2014 must match the row in `t_actor` |\\n\\nKey invariants:\\n\\n- **Revocation is a DB bump, not a JWT blacklist.** `bumpSessionVersion(actorId)` increments `t_actor.session_ver` on logout and account closure. `requireAuth` compares `claims.sv` against the current row value on every request; a mismatch rejects the cookie.\\n- **Membership never comes from the JWT.** `activeOrgId` only picks the active org; the actual grant rows are loaded from DB (cached) by `resolveTenantContext`.\\n- **The cookie carries no long-lived refresh token.** When it expires (`config.sessionTtlHours`), the user signs in again.\\n\\n### Session issuance\\n\\nAll login paths call `issueSessionCookie(res, { actorId, sub, email, activeOrgId })`. This helper reads the current `session_ver` inside `withActor(actorId, \u2026)` and sets the cookie:\\n\\n```ts\\nawait issueSessionCookie(res, {\\n  actorId: linked.actorId,\\n  sub: linked.subject,\\n  email: linked.email,\\n  activeOrgId: me.active?.orgId ?? null,\\n});\\n```\\n\\nNote the `withActor` wrapper: the actor row read runs with actor-scoped RLS context, so migrations that restrict `t_actor` visibility (migration 028) do not break session issuance.\\n\\n`requireAuth` (`shared/middleware/require-auth.ts`) performs the per-request check \u2014 verify JWT, load `t_actor` row, confirm `status_cd = 'RS1'`, compare `session_ver`. It attaches `req.auth` (a `RequestAuth` = `SessionClaims` + `personPartyId`) for downstream middleware and handlers. `personPartyId` is loaded in the same round-trip so `resolveTenantContext` does not re-query the actor.\\n\\n## Sign-in paths\\n\\n```mermaid\\nflowchart TD\\n    A[Client] --&gt;|GET /entra/start| B[Entra hosted OAuthPKCE + state]\\n    B --&gt;|code + state| C[POST /entra/exchange]\\n    A --&gt;|email + password| D[POST /native/signin]\\n    D --&gt;|OTP challenge| E[POST /native/signin/continue]\\n    A --&gt;|email + DEV_AUTH_SECRET| F[POST /login]\\n    C --&gt; G[linkActorFromEntra]\\n    E --&gt; G\\n    D --&gt;|id_token| G\\n    F --&gt; H[actor lookup by dev| subject]\\n    G --&gt; I[issueSessionCookie]\\n    H --&gt; I\\n    I --&gt; J[awx_session cookie]\\n```\\n\\n### Entra hosted OAuth (PKCE)\\n\\nRoutes: `GET /entra/start`, `POST /entra/exchange`.\\n\\nThe hosted flow is used when the branded Native Auth screen is unavailable or the user chooses \u201cContinue with Microsoft\u201d. It is a standard OAuth 2.0 authorization-code + PKCE exchange against the Entra External ID tenant, with one wrinkle: the `redirect_uri` is **per-request** and stored in a cookie (`awx_entra_redirect`) so a phone on the LAN can use `public_origin` and still land back on a reachable callback URL.\\n\\n`GET /entra/start`:\\n\\n1. Validates Entra is configured; throws `ENTRA_DISABLED` otherwise.\\n2. Reads `login_hint`, `prompt`, `returnTo`, `public_origin` from query params.\\n3. Calls `resolveEntraRedirectUri(publicOrigin)` \u2014 returns the configured redirect URI unless the origin is loopback/RFC1918 and `allowDevAuth` is on, in which case it synthesises `/auth/callback`.\\n4. Mints OAuth `state` and a PKCE verifier/challenge (`newOAuthState`, `newPkcePair`).\\n5. Sets four 10-minute cookies: `awx_entra_state`, `awx_entra_verifier`, `awx_entra_return`, `awx_entra_redirect`.\\n6. 302s to the authorize URL built by `buildAuthorizeUrl`.\\n\\n`POST /entra/exchange`:\\n\\n1. Parses `{ code, state }` from the body.\\n2. Re-derives the token-exchange `redirect_uri` from the cookie (must match authorize), then validates `state` and the verifier cookie.\\n3. Calls `exchangeAuthorizationCode` \u2014 POSTs to the Entra token endpoint, then verifies the ID token (`verifyIdToken` inside `shared/auth/entra.ts`).\\n4. Resolves the actor via `linkActorFromEntra(identity)`.\\n5. Clears the four OAuth cookies, issues the session cookie, audited as `auth.login` with `method: \\\"entra\\\"`.\\n\\nThe ID-token verifier `verifyIdToken` is deliberately tolerant of issuer hosts: CIAM can mint `iss` on the tenant-guid hostname even when authorize used the friendly subdomain. It builds an issuer candidate list from discovery plus known CIAM/Microsoft shapes and, on failure, reports the actual `iss` it received so the next missing shape is obvious.\\n\\n### Native Authentication (branded)\\n\\nRoutes: `POST /native/signup/*`, `POST /native/signin*`, `POST /native/reset/*`.\\n\\nThis is a BFF proxy to the Entra Native Authentication API (`shared/auth/native-auth.ts`). The browser never calls `ciamlogin.com` directly (no CORS). Passwords transit the API exactly once and are never logged. Continuation tokens live in five 15-minute HTTP-only cookies:\\n\\n| Cookie | Contents |\\n|---|---|\\n| `awx_native_cont` | Entra continuation token |\\n| `awx_native_flow` | `signup` \\\\| `signin` \\\\| `reset` |\\n| `awx_native_email` | email being authenticated |\\n| `awx_native_return` | sanitised `returnTo` |\\n| `awx_native_step` | `otp` \\\\| `new_password` \\\\| `done` \u2026 |\\n\\nAll native handlers guard on `entraEnabled()` first and on the flow/step cookies for multi-step continuation. The three flows:\\n\\n**Sign-up** (`/native/signup/start` \u2192 `/native/signup/continue`, optional `/native/signup/resend`):\\n\\n- `signup/start` validates the password pair, then calls `ensureTradeProfileForCredentials(email, profileFields)` \u2014 this heals the case where the user skipped `/auth/register` by creating the orphan trade profile (party + actor + grant + ledger) on the spot.\\n- `nativeSignupStart` starts the CIAM sign-up with password and user attributes, then challenges for OTP. A `redirect` challenge type is surfaced as `NATIVE_FALLBACK_HOSTED` (the UI falls back to the hosted page).\\n- `signup/continue` submits the OTP. If Entra mints an ID token, it is verified and the actor is linked immediately via `linkActorFromEntra`. If no token is minted (CIAM user created but token endpoint failed), the provisional `pending|email` actor is linked on the user's first password sign-in \u2014 the flow is explicitly designed not to dead-end the user.\\n- Success queues a welcome email (`welcomeEmailBodies`) and returns `accountCreated: true` with no session \u2014 the user goes to the login page.\\n\\n**Sign-in** (`/native/signin`, `/native/signin/continue`):\\n\\n- `nativeSigninStart` initiates the OAuth2 flow and challenges.\\n- `nativeSigninWithPassword` submits the password. If the response carries an `id_token`, the user is signed in immediately. If Entra responds with an OTP challenge (MFA), the handler stores the continuation token in cookies and returns `step: \\\"otp\\\"` so the UI can prompt for the code.\\n- `/native/signin/continue` submits the OTP via `nativeSigninWithOob` and completes the same `linkActorFromEntra` \u2192 `buildMe` \u2192 `issueSessionCookie` path. Audited as `method: \\\"native\\\"` or `\\\"native_otp\\\"`.\\n\\n**Password reset** (`/native/reset/start` \u2192 `/native/reset/continue` \u2192 `/native/reset/submit`):\\n\\n- `start` calls the SSPR start + challenge endpoints.\\n- `continue` verifies the email OTP and advances the step cookie to `new_password`.\\n- `submit` validates the password pair, calls `nativeResetSubmitPassword` (which polls `poll_completion` up to 8 times), then clears native cookies. No session is issued \u2014 the user signs in with the new password.\\n\\nError translation is centralised in `humanizeNativeError`, which maps Entra `suberror` / `error_description` strings to user-facing messages (wrong code, weak password, banned password, user already exists, account not found, consent missing, invalid credentials).\\n\\n### DEV passwordless login\\n\\nRoute: `POST /login`. Gated by `config.allowDevAuth` (local or Azure DEV with `ALLOW_DEV_AUTH=1`).\\n\\n`assertDevAuthSecret(req)` (`shared/auth/dev-auth-gate.ts`) requires the `x-assureworx-dev-auth` header to match `DEV_AUTH_SECRET` via `timingSafeEqual`. The Next.js middleware / gate-proxy injects this header so browsers never hold the secret; direct API calls must supply it.\\n\\nThe handler looks up an actor with `subject_txt LIKE 'dev|%'` and active status. This deliberately excludes Entra-provisioned accounts \u2014 a laptop API cannot enumerate real customer emails. On success it issues the session cookie and audits `auth.login` with `method: \\\"dev\\\"`. Failures are audited as `auth.login_failed` with the reason (`unknown_email` or `inactive`).\\n\\n`GET /dev-personas` uses the same gate to list DEV actors for the local persona picker, returning email, display name, first role, and first org \u2014 never raw subject values or personal data of real accounts.\\n\\n## Actor resolution and provisioning\\n\\n`linkActorFromEntra` (`shared/auth/link-actor.ts`) is the single place where a verified Entra identity is resolved to an `actor`:\\n\\n1. **Email allowlist check** \u2014 `assertEntraEmailAllowed` rejects sign-ins from emails not in `entraEmailAllowlist` when that config is set.\\n2. **Exact `sub` match** \u2014 if an active actor already has this Entra `sub`, use it.\\n3. **Provisional link by email** \u2014 if an active actor exists with the same email and a provisional subject (`pending|` or `dev|`), the `subject_txt` is updated to the Entra `sub`. This is how a trade who registered via `/auth/register` or the native sign-up screen becomes a real Entra-linked account on first sign-in.\\n4. **Conflict guard** \u2014 an email already bound to a *different* non-provisional `sub` is rejected with a support message.\\n5. **JIT provisioning** \u2014 when no actor exists, `provisionSelfServeTradie` creates an orphan trade individual (`K03` person party, `R05` grant, `PL1` ledger, `D03` party identifier) bound to the Entra `sub`. This is controlled by `entraJitProvision` (default off on test/prod, on in DEV) and prevents an Entra-first sign-up from being a dead end if `/auth/register` was skipped.\\n\\nProvisional subjects are minted by `actorSubjectForEmail(email)`: `pending|` when Entra is configured, `dev|` when it is not. `isProvisionalSubject` is used by `graph-email.ts` to decide whether an email change needs a Graph call.\\n\\n## Self-registration\\n\\n`POST /auth/register` (ADR-0012) creates a self-serve trade profile. Key behaviours:\\n\\n- Serialised with `advisoryXactLock(tx, 'register:')` to avoid duplicate-profile races.\\n- If `resolveIdentity` finds an existing party, the handler checks for a provisional pending actor on that party; if found, it returns `credentialSetupPayload(email, \\\"/verify\\\")` so the user resumes credential setup instead of hitting a dead-end `PROFILE_EXISTS` error.\\n- Inserts a person party (`K03`) with class `M01` (company) or `M02` (individual), an optional company party (`K02`) with an `L01` relation, a `D03` party identifier, the actor (with `pending|` or `dev|` subject), an `R04`/`R05` grant, and a `PL1` ledger.\\n- **No `L02`/`L03` edge is created** \u2014 an orphan party not attached to any principal contractor's network is a valid, supported state. Principal contractors do not self-register (ADR-0010).\\n- Audits `party.self_registered`, `role.granted`, and `subscription.started` in the same transaction.\\n- Behaviour after insert depends on configuration: with Entra enabled, returns `credentialSetupPayload` (no session \u2014 password is set via Native Auth); with DEV auth only and a non-hardened env, issues a session directly; otherwise throws `ENTRA_REQUIRED`.\\n\\n`credentialSetupPayload` is exported from `modules/auth/index.ts` and reused by memberships and onboarding modules for the same resume-credential-setup purpose.\\n\\n## Session middleware\\n\\n`requireAuth` (`shared/middleware/require-auth.ts`) is the canonical auth guard. It:\\n\\n1. Reads the session cookie (name from `config.sessionCookie`).\\n2. Verifies the JWT (`verifySession` \u2014 checks signature, issuer, algorithm, claim shapes, integer `sv \u2265 1`).\\n3. Loads the actor row with `withActor(claims.actorId, \u2026)` so the query is subject to actor-scoped RLS.\\n4. Rejects if the actor row is missing, inactive (`status_cd !== 'RS1'`), or the stored `session_ver` differs from `claims.sv`.\\n5. Attaches `req.auth` and calls `next()`.\\n\\n`POST /switch` composes `requireAuth` with `resolveTenantContext`, then re-issues the session cookie with a new `activeOrgId` (preserving `sv`) and returns the rebuilt `Me`.\\n\\n`buildMe(actorId, activeOrgId)` is the shared user-model builder. It loads the actor + party row and all active grants, maps role/status codes through `ROLE`, `PARTY_KIND`, and `PARTY_STATUS`, computes initials from display name, and picks the active membership (requested org first, else first by creation). It is used by every login path, `/me`, `/switch`, and by memberships/onboarding modules.\\n\\n## Email change (ADR-0013)\\n\\nRoutes: `GET/POST /email-change`, `POST /email-change/resend`, `DELETE /email-change`, `POST /email-change/confirm-old`, `POST /email-change/confirm-new`. Service logic lives in `shared/services/email-change.ts`.\\n\\nThe flow proves control of **both** inboxes before committing:\\n\\n```mermaid\\nflowchart LR\\n    A[POST /email-change] --&gt; B[pendingold-email token sent]\\n    B --&gt;|POST confirm-old| C[old_oknew-email token sent]\\n    C --&gt;|POST confirm-new| D{Entra-linked?}\\n    D --&gt;|yes| E[Graph PATCHsign-in email]\\n    D --&gt;|no / provisional| F[Commit DB]\\n    E --&gt; F\\n    F --&gt; G[committedactor + party + D03 updated]\\n    B --&gt;|resend| B\\n    C --&gt;|resend| C\\n    B --&gt;|DELETE| H[cancelled]\\n    C --&gt;|DELETE| H\\n```\\n\\n- Tokens are 32-byte random hex, stored **hashed** (SHA-256). The raw token is only returned in the API body on `local` env when email delivery is unset; elsewhere a missing send fails closed with `EMAIL_UNAVAILABLE`.\\n- `startEmailChange` validates the new email, rejects a taken email, cancels any open request, inserts a `pending` row with a 24-hour TTL (`expires_at`), and emails the old address.\\n- `confirmOldEmail(token)` marks `old_ok`, mints a **fresh** new-email token (so the new-inbox link is only usable after the old inbox is proven), and emails the new address.\\n- `confirmNewEmail(token)` first PATCHes the Entra CIAM user via Microsoft Graph (`syncEntraLoginEmail` in `shared/auth/graph-email.ts`) for non-provisional subjects \u2014 Graph failure aborts outside DEV. Then it commits the DB change: actor `email_txt` + `subject_txt` (re-derived for provisional subjects), party `email_txt`, `D03` identifier, request status \u2192 `committed`.\\n- If the caller is signed in with a session for the affected actor, the handler re-issues the session cookie with the new email.\\n- `resendEmailChange` mints a fresh token for whichever step is pending; `cancelEmailChange` cancels any `pending`/`old_ok` request.\\n\\n`expireStale` is invoked at the start of every read/confirm/resend to flip expired rows to `expired`.\\n\\n## OTP email extension\\n\\n`modules/auth/otp-email.ts` is the Entra External ID `OnOtpSend` custom authentication extension handler. Entra calls it when it needs to deliver a one-time code, and Assureworx sends its own branded email via ACS instead of the default Microsoft template.\\n\\nDistinctive properties:\\n\\n- **Mounted ahead of the CSRF guard**, because Entra calls it server-to-server with an OAuth bearer token, not a browser session.\\n- **Authenticated by `assertOtpCaller`** \u2014 `verifyOtpCallbackToken` (`shared/auth/otp-callback-auth.ts`) validates an RS256 JWT from `login.microsoftonline.com` against the configured `entraOtpExtensionAudience`, with 5s clock tolerance and cached OIDC discovery.\\n- **Response shape is Entra's**, not the app's `ApiEnvelope` \u2014 `entraOk` returns `microsoft.graph.OnOtpSendResponseData` with `continueWithDefaultBehavior`.\\n- Idempotency: `otpEmailIdemKey(config.sessionSecret, { code, recipient, correlationId })` with a 30-minute TTL prevents duplicate sends on Entra retries.\\n- Missing/invalid codes log a warning and return `entraOk` (let Entra decide), whereas ACS send failures return a 500 with `OTP_EMAIL_SEND_FAILED`.\\n\\n## Route reference\\n\\n| Method &amp; path | Auth | Description |\\n|---|---|---|\\n| `GET /health` | \u2014 | Module liveness |\\n| `GET /entra/status` | \u2014 | Public capability flags: `enabled`, `nativeAuth`, `allowDevAuth`, authority/tenant, password hints |\\n| `GET /entra/start` | rate-limited | Start Entra PKCE flow, redirect to authorize URL |\\n| `POST /entra/exchange` | rate-limited | Redeem code \u2192 link actor \u2192 session cookie |\\n| `POST /login` | rate-limited + DEV secret | DEV passwordless sign-in |\\n| `POST /register` | rate-limited | Trade self-registration (ADR-0012) |\\n| `POST /native/signup/start` | rate-limited | Begin branded sign-up (password + profile) |\\n| `POST /native/signup/continue` | rate-limited | Submit sign-up OTP |\\n| `POST /native/signup/resend` | rate-limited | Resend sign-up OTP |\\n| `POST /native/signin` | rate-limited | Branded sign-in (password) |\\n| `POST /native/signin/continue` | rate-limited | Submit sign-in OTP |\\n| `POST /native/reset/start` | rate-limited | Begin password reset |\\n| `POST /native/reset/continue` | rate-limited | Submit reset OTP |\\n| `POST /native/reset/submit` | rate-limited | Set new password |\\n| `POST /logout` | \u2014 | Bump session version, clear cookies |\\n| `GET /me` | `requireAuth` | Current user `Me` |\\n| `POST /switch` | `requireAuth` + tenant ctx | Switch active org, re-issue cookie |\\n| `GET /dev-personas` | rate-limited + DEV secret | DEV actor list for persona picker |\\n| `GET /email-change` | `requireAuth` | Open email-change request |\\n| `POST /email-change` | `requireAuth` + rate-limited | Start email change |\\n| `POST /email-change/resend` | `requireAuth` + rate-limited | Resend current confirmation token |\\n| `DELETE /email-change` | `requireAuth` | Cancel open request |\\n| `POST /email-change/confirm-old` | rate-limited | Prove old inbox |\\n| `POST /email-change/confirm-new` | rate-limited | Prove new inbox and commit |\\n| `POST /` (otp-email router) | Entra bearer token | `OnOtpSend` extension |\\n\\n## Configuration surface\\n\\n| Config | Used by |\\n|---|---|\\n| `entraTenantId`, `entraClientId`, `entraClientSecret`, `entraAuthority`, `entraRedirectUri` | `getEntraConfig` / `entraEnabled` \u2014 all Entra flows |\\n| `entraEmailAllowlist` | `linkActorFromEntra` \u2014 environment email restriction |\\n| `entraJitProvision` | `linkActorFromEntra` \u2014 allow JIT orphan provisioning |\\n| `entraOtpExtensionAudience` | `verifyOtpCallbackToken` |\\n| `allowDevAuth`, `devAuthSecret` | `assertDevAuthSecret`, `resolveEntraRedirectUri` LAN support |\\n| `sessionSecret`, `sessionCookie`, `sessionTtlHours`, `cookieSecure` | `session.ts`, `requireAuth`, cookie base |\\n| `publicAppUrl` | welcome emails, email-change confirm URLs |\\n| `deployEnv` | `clientConfirmUrl` \u2014 raw confirm link only on `local` |\\n| `isHardenedEnv` | `POST /register` \u2014 block session minting without Entra |\\n\\n## Cross-module integration\\n\\nOther modules consume this module's primitives rather than reimplementing auth:\\n\\n- **Memberships** (`modules/memberships/index.ts`) calls `entraEnabled`, `actorSubjectForEmail`, `credentialSetupPayload`, `issueSessionCookie`, and `buildMe` when accepting invitations and issuing the first session.\\n- **Onboarding** (`modules/onboarding/index.ts`) uses `entraEnabled`, `credentialSetupPayload`, `issueSessionCookie`, `buildMe`, and `actorSubjectForEmail` for the wizard flow.\\n- **Admin** (`modules/admin/index.ts`) uses `actorSubjectForEmail` when provisioning actors.\\n- **Retention** (`modules/retention/closure.ts`) calls `bumpSessionVersion` and `clearSessionCookie` to kill sessions on account closure.\\n- **`otp-callback-auth`** is unit-tested directly (`otp-callback-auth.test.ts`) for `verifyOtpCallbackToken` and `bearerToken`.\\n\\nThe dependency direction is one-way: other modules import from `shared/auth/*` and `modules/auth/index.ts`, never the reverse.\",\"authentication-session-management-frontend-src\":\"# Authentication &amp; Session Management \u2014 frontend-src\\n\\n# Authentication &amp; Session Management \u2014 frontend-src\\n\\n## Module overview\\n\\nThis module implements every authentication surface in the Assureworx frontend: the login, registration, password reset, email-change confirmation, and seat-invitation flows, plus the client- and server-side session plumbing that the rest of the app depends on.\\n\\nThe module has three layers:\\n\\n- **Route components** under `frontend/src/app/auth/*` \u2014 each route is a thin server component page that wraps a `\\\"use client\\\"` component in a `Suspense` boundary (required because every flow reads `useSearchParams`).\\n- **Session infrastructure** in `frontend/src/lib/` \u2014 `session.tsx` (client `SessionProvider`), `server-session.ts` (server helpers), and `session-core.ts` (role/redirect rules).\\n- **Auth support libraries** \u2014 `entra.ts` (Microsoft Entra redirects), `credential-setup.ts` (post-registration password draft), `safe-return-to.ts` (open-redirect guard), `login-reveal.ts` (sign-in animation), and `middleware.ts` (dev-only Basic Auth gate).\\n\\nAll authenticated HTTP calls go through `@/lib/api`; the module never talks to the backend directly except through that client and through `serverGet` on the server side.\\n\\n---\\n\\n## Route map\\n\\n| Route | Client component | Purpose |\\n|---|---|---|\\n| `/auth/login` | `LoginClient` | Primary sign-in. Hosts Entra, native password, and dev-persona entry points. |\\n| `/auth/register` | `RegisterClient` | Self-service profile creation (sole trader or company). |\\n| `/auth/credentials` | `CredentialsClient` | Password set-up after registration, when the API asks for credential setup. |\\n| `/auth/verify-code` | `VerifyCodeClient` | OTP entry shared by signup, signin, and reset flows (`?flow=`). |\\n| `/auth/created` | `CreatedClient` | Post-signup confirmation screen; auto-redirects to login. |\\n| `/auth/reset` | `ResetClient` | Starts the password reset flow. |\\n| `/auth/reset/new-password` | `NewPasswordClient` | Sets a new password after OTP verification. |\\n| `/auth/callback` | `AuthCallbackClient` | OAuth redirect target after Microsoft Entra sign-in. |\\n| `/auth/invite/[token]` | `InviteClient` | Accepts an organisation seat invitation. |\\n| `/auth/email-change/[kind]` | `EmailChangeClient` | Confirms an email change (`kind = old` or `new`). |\\n\\nEvery client component is wrapped in `Suspense` because it calls `useSearchParams()`, which in Next.js requires a boundary so the page can be statically rendered.\\n\\n---\\n\\n## Authentication flows\\n\\n```mermaid\\nflowchart TD\\n    L[\\\"/auth/login\\\"] --&gt;|\\\"startEntraSignIn()\\\"| S[\\\"/api/v1/auth/entra/start\\\"]\\n    S --&gt;|\\\"302 to Microsoft\\\"| CB[\\\"/auth/callback\\\"]\\n    CB --&gt;|\\\"POST /auth/entra/exchange\\\"| R[\\\"coerceReturnTo(me, returnTo)\\\"]\\n    L --&gt;|\\\"POST /auth/native/signin\\\"| NR{result.step}\\n    NR --&gt;|\\\"otp\\\"| VC[\\\"/auth/verify-code?flow=signin\\\"]\\n    NR --&gt;|\\\"done\\\"| R\\n    VC --&gt;|\\\"POST /auth/native/signin/continue\\\"| R\\n    R --&gt;|redirect| SHELL[\\\"(app) SessionProvider\\\"]\\n    SHELL --&gt;|\\\"GET /auth/me\\\"| ME{401?}\\n    ME --&gt;|yes| LG[\\\"/auth/login\\\"]\\n    ME --&gt;|no| UI[\\\"App shell\\\"]\\n```\\n\\n### 1. Microsoft Entra (OAuth) sign-in\\n\\nStarted from `LoginClient` via `startEntraSignIn()` in `src/lib/entra.ts`. The function builds a query string with `login_hint`, `returnTo`, and `prompt`, then does a full-page `window.location.assign()` to `/api/v1/auth/entra/start` \u2014 the backend sets the PKCE cookies and issues the 302 to Microsoft.\\n\\n```ts\\nstartEntraSignIn({ loginHint: email, returnTo: next ?? undefined });\\n```\\n\\nIt also sets `public_origin` to `window.location.origin` so that LAN-device callbacks (the `run-mobile-demo.bat` workflow) return to the phone's IP rather than `localhost`.\\n\\nThe callback route (`/auth/callback`) has two responsibilities:\\n\\n1. **Handle provider-side errors.** `humanizeEntraError()` maps common `AADSTS*` codes to user-facing messages: consent cancellation, unknown account, missing MFA, cross-tenant guest access, and a generic fallback.\\n2. **Exchange the code.** `POST /auth/entra/exchange` with `{ code, state }` returns `{ me, returnTo }`. The client then validates `returnTo` with `safeReturnToOrNull()`, arms the login-reveal animation, and redirects via `coerceReturnTo()`.\\n\\nThe `cancelled` flag in the effect prevents state updates if the user navigates away before the exchange completes.\\n\\n### 2. Native password + OTP sign-in\\n\\nNative auth is the password path served by the BFF. `LoginClient` fetches `GET /auth/entra/status` on mount and decides which UI to render:\\n\\n- `entra.enabled &amp;&amp; nativeAuth !== false` \u2192 password form (with a secondary \\\"Continue with Microsoft\\\" button).\\n- `entra.enabled` only \u2192 hosted Entra form.\\n- Neither \u2192 dev email-only form (see Development tooling).\\n\\n`POST /auth/native/signin` returns a discriminated union:\\n\\n```ts\\ntype SigninResult =\\n  | { step: \\\"done\\\"; me: Me; returnTo: string }\\n  | { step: \\\"otp\\\"; email: string; maskedEmail: string; codeLength: number; returnTo: string };\\n```\\n\\nWhen the API answers `step: \\\"otp\\\"` (MFA required), the client redirects to `/auth/verify-code?flow=signin`. `VerifyCodeClient` then posts to `/auth/native/signin/continue` with the code. On success it uses `coerceReturnTo()` for the final redirect \u2014 this checks the signed-in user's role against the requested `next` path and drops it if disallowed, preventing role-bounce loops.\\n\\n### 3. Registration and credential setup\\n\\n`RegisterClient` posts profile data to `POST /auth/register` and expects either a complete `Me` (direct to `/verify`) or, more commonly, a `RequireCredentialSetup` response:\\n\\n```ts\\ntype RequireCredentialSetup = {\\n  requireCredentialSetup?: true;\\n  requireEntraSignIn?: true;\\n  email: string;\\n  returnTo: string;\\n  mode?: \\\"signup\\\" | \\\"login\\\";\\n  prompt?: \\\"create\\\" | \\\"login\\\";\\n};\\n```\\n\\n`isRequireCredentialSetup()` (in `src/lib/credential-setup.ts`) is the type guard used everywhere. When it matches, `startCredentialSetup()`:\\n\\n1. Stashes a `CredentialSetupDraft` in `sessionStorage` under `awx.credentialSetup` \u2014 this carries `firstName`, `lastName`, `phone`, `companyName`, and `returnTo` from the registration form so the user doesn't re-enter them.\\n2. Does `window.location.assign(\\\"/auth/credentials?email=...&amp;returnTo=...\\\")`.\\n\\n`CredentialsClient` reads the draft back with `readCredentialSetupDraft(email)` and merges it into the `POST /auth/native/signup/start` payload. On success it clears the draft and forwards to `/auth/verify-code?flow=signup`.\\n\\n`VerifyCodeClient` in signup mode posts to `/auth/native/signup/continue`, then redirects to `/auth/created` with the email and return target.\\n\\n### 4. Password reset\\n\\nThree steps, all reading the `flow=reset` query parameter on `/auth/verify-code`:\\n\\n1. `ResetClient` \u2192 `POST /auth/native/reset/start` \u2192 redirect to `/auth/verify-code?flow=reset`.\\n2. `VerifyCodeClient` (reset) \u2192 `POST /auth/native/reset/continue` \u2192 redirect to `/auth/reset/new-password`.\\n3. `NewPasswordClient` \u2192 `POST /auth/native/reset/submit` \u2192 redirect to `/auth/login?reset=1`, which renders the \\\"Password updated\\\" notice.\\n\\nNote that the reset flow never uses `safeReturnTo` \u2014 the return target is intentionally pinned to the login page.\\n\\n### 5. Seat invitations\\n\\n`InviteClient` (route `/auth/invite/[token]`) fetches `GET /memberships/seats/token/[token]` and renders one of four states:\\n\\n- **Not found / invalid** \u2192 non-retryable error; \\\"Try again\\\" button is only shown for network-style failures.\\n- **Loading** \u2192 `SkeletonRows`.\\n- **Already used / expired** \u2192 status message plus a link to login.\\n- **Pending** \u2192 the acceptance form.\\n\\nEmployee invites (`seat.kind === \\\"employee\\\"`) additionally collect suburb/postcode/state and validate the postcode as 4 digits. On accept, the client posts to `/memberships/seats/token/[token]/accept`. The response is either:\\n\\n- `Me` \u2192 redirect via `router.replace(isEmployee ? \\\"/verify/details\\\" : homeFor(result))`, or\\n- `RequireCredentialSetup` \u2192 `startCredentialSetup()`, routing the user into the password set-up flow with the invite's context preserved.\\n\\nFor employee invites the client also calls `createSession(\\\"account\\\", ...)` from `@/lib/verify-demo` \u2014 this is the demo-session seed used to pre-fill the verification walkthrough on the frontend only; it leaves the real backend session untouched.\\n\\n### 6. Email change confirmation\\n\\n`/auth/email-change/[kind]` handles the two halves of the email-change handshake:\\n\\n- `kind = \\\"old\\\"` \u2192 `POST /auth/email-change/confirm-old` with the token. Success shows one of two outcomes: if the backend already emailed the next link, a confirmation message; otherwise a \\\"copy the next confirmation link\\\" button rendering `confirmUrl`.\\n- `kind = \\\"new\\\"` \u2192 `POST /auth/email-change/confirm-new`. Success shows \\\"Your login email is updated\\\" and auto-redirects to `/settings/account` after 1.6s.\\n\\nThe route page validates `kind` strictly (`\\\"new\\\"` or `\\\"old\\\"` only, else `null`), and the client refuses to work with token lengths below 16 characters.\\n\\n---\\n\\n## Session management\\n\\n### Server side \u2014 `src/lib/server-session.ts`\\n\\n`getServerMe()` is the canonical server-side way to obtain the current user. It is wrapped in React `cache()`, so every layout and page loader in a single request shares one `GET /auth/me` round trip.\\n\\nThe error policy is deliberate and documented in the code: **only a 401 means \\\"signed out\\\"**. A 401 triggers `redirect(\\\"/auth/login\\\")`; anything else (429, 5xx, network) propagates rather than bouncing the user into a re-auth storm on top of a rate limit.\\n\\n`requirePortal(gate)` is the server-side role gate, mirroring what the old client `AppShell` did. Gates:\\n\\n| Gate | Allows |\\n|---|---|\\n| `\\\"principal\\\"` | Principal-contractor portal only |\\n| `\\\"trade\\\"` | Trade portal only |\\n| `\\\"platform_admin\\\"` | Console (`/admin`) only |\\n| `\\\"member\\\"` | Any non-platform org role |\\n| `\\\"any\\\"` | Every authenticated role, including platform admin |\\n\\nWrong-role visitors are redirected to `homeFor(me)` \u2014 the role-appropriate home \u2014 instead of being allowed to trip a 403 from the API.\\n\\n`homeFor` and `roleFromMe` live in `session-core.ts`:\\n\\n- `roleFromMe`: `\\\"principal\\\"` when `me.active.orgKind === \\\"principal_contractor\\\"`, else `\\\"trade\\\"`.\\n- `homeFor`: platform admin \u2192 `/admin`; principal \u2192 `/dashboard`; trade \u2192 `/profile`.\\n\\n### Client side \u2014 `src/lib/session.tsx`\\n\\n`SessionProvider` holds the authenticated session in React context. Key design points:\\n\\n- **Server seeding.** The `(app)` layout passes a server-fetched `Me` as `initialMe`, so the shell paints immediately without a client splash. When `initialMe` is absent, the provider falls back to a client `GET /auth/me`.\\n- **Fingerprinting.** `meFingerprint()` collapses the identity-relevant fields into a string. The `(app)` layout re-renders on every soft navigation and passes a fresh `initialMe` object; the provider only adopts it when the fingerprint changes. This prevents layout re-seeds from remounting the whole shell on every route change.\\n- **Boot and refresh.** `boot()` runs once for the provider lifetime, warms `SHELL_PREFETCH` (`/notifications`, `/auth/entra/status`), and fetches `/auth/me`. `refresh()` is the soft-revalidation path used after mutations; the 401-handling rule is identical to the server side.\\n- **Anonymous redirect.** When state becomes `\\\"anonymous\\\"`, the provider redirects to `/auth/login?next=` \u2014 but only for non-auth paths and only when `pathname` isn't `/`.\\n- **Degraded mode.** If the initial boot fails with a non-401 error and we never had a `me`, it renders an inline \\\"Can't reach Assureworx\\\" panel with a retry button instead of redirecting. If a session was already established, the error is swallowed and the app keeps running on stale session data.\\n\\nThe context value exposes `{ me, role, refresh, switchOrg, signOut }`:\\n\\n- `switchOrg(orgId)` \u2192 `POST /auth/switch`, adopts the new `Me`, and pushes `homeFor(data)`.\\n- `signOut()` \u2192 `POST /auth/logout`, clears the context, and replaces the URL with `/auth/login`.\\n\\n`useSession()` is the consumer hook and throws if used outside the provider.\\n\\n### Post-login redirect safety \u2014 `src/lib/session-core.ts`\\n\\nTwo rules protect post-auth navigation:\\n\\n1. **`safeReturnTo(raw, fallback)`** rejects anything that is not a path-only string: it must start with `/`, must not start with `//`, must not contain `\\\\` or `://`, and must match `/^\\\\/[\\\\w\\\\-./?&amp;=%#]*$/`. `safeReturnToOrNull()` returns `null` for unusable input instead of a fallback, which callers use to distinguish \\\"no target given\\\" from \\\"target forced to `/`\\\".\\n2. **`coerceReturnTo(me, next)`** runs after login and checks the requested target against the user's portal role:\\n   - Principal-only paths (`/dashboard`, `/trades`, `/projects`, `/reports`) are rejected for trade users.\\n   - Trade-only paths (`/profile`, `/team`, `/jobs`) are rejected for principal users.\\n   - `next === \\\"/\\\"` or a disallowed path falls back to `homeFor(me)`.\\n\\nThis is the guard that stops a trade user with `?next=/dashboard` from starting a bounce loop.\\n\\n### Login reveal animation\\n\\n`armLoginReveal()` writes a flag to `sessionStorage` immediately before a successful sign-in redirect; `LoginReveal` (mounted in the `(app)` layout) consumes it once with `consumeLoginReveal()` and plays a 1.1s screen animation. The flag being in `sessionStorage` means it survives the full-page navigation that follows most auth redirects, but not a browser restart. Both functions swallow storage errors for hardened/private browsing contexts.\\n\\n---\\n\\n## Supporting libraries\\n\\n### `src/lib/entra.ts`\\n\\nCentral helper for Microsoft Entra interactions:\\n\\n- `EntraStatus` \u2014 the shape returned by `GET /auth/entra/status`, including `enabled`, `nativeAuth`, `allowDevAuth`, `authority`, `tenantId`, and `passwordHelp`.\\n- `startEntraSignIn()` \u2014 navigates to the backend's `/api/v1/auth/entra/start` with optional `login_hint`, `returnTo`, and `prompt`. Used by login, settings, account, and verification-details pages.\\n- `startEntraSignUp()` \u2014 deprecated shim that forwards to `startCredentialSetup()` (branded Native Auth screens per ADR-0025).\\n- `isLanDevOrigin()` \u2014 detects Wi-Fi IP origins (`10.*`, `192.168.*`, `172.16-31.*`) used by the mobile demo script.\\n- `isRequireEntraSignIn` \u2014 deprecated re-export of `isRequireCredentialSetup`.\\n\\nThe module deliberately re-exports `isRequireCredentialSetup` / `startCredentialSetup` / `RequireCredentialSetup` so existing importers of `entra.ts` keep working without chasing the type move.\\n\\n### `src/lib/credential-setup.ts`\\n\\nOwns the sessionStorage draft for credential setup:\\n\\n- `isRequireCredentialSetup(value)` \u2014 type guard accepting either `requireCredentialSetup` or `requireEntraSignIn` (must have a non-empty `email`).\\n- `stashCredentialSetupDraft()` / `readCredentialSetupDraft(email?)` / `clearCredentialSetupDraft()` \u2014 the draft store. `read` returns `null` if the stored email doesn't match the caller's email.\\n- `startCredentialSetup(opts)` \u2014 stashes the draft and hard-navigates to `/auth/credentials?email=...&amp;returnTo=...`.\\n\\nThe storage key is `awx.credentialSetup`. All storage access is wrapped in try/catch because `sessionStorage` can be unavailable in hardened browsers; the flow still works, the user just re-enters their profile details.\\n\\n### `src/middleware.ts`\\n\\nEdge middleware with two independent jobs:\\n\\n1. **Dev Basic Auth gate.** When `DEV_SITE_GATE_ENABLED` is truthy, every browser-reachable path except static assets requires `Basic` auth with `DEV_SITE_BASIC_USER` / `DEV_SITE_BASIC_PASSWORD`. It returns 503 (`misconfigured`) if the env vars are not set, uses a constant-time string compare (no Node crypto on Edge), and exempts `PUBLIC_ASSET` paths (`png`, `jpg`, `webp`, `svg`, `ico`, fonts) because Next's image optimizer fetches them without an `Authorization` header. The comment warns this is secondary to `frontend/gate-proxy.mjs` for Azure Container Apps, since Edge middleware inlines env at build time.\\n2. **Dev auth header injection.** When `DEV_AUTH_SECRET` is set, it injects `x-assureworx-dev-auth` into requests to `POST /api/v1/auth/login` and `GET /api/v1/auth/dev-personas` \u2014 the endpoints that power local passwordless sign-in. The browser never sees the secret; it lives in `.env.local`.\\n\\nThe matcher excludes `_next/static`, `_next/image`, and `favicon.ico` to avoid paying Edge invocation cost on every JS chunk and font file.\\n\\n---\\n\\n## Development tooling\\n\\nThe module carries a small amount of dev-only surface that is invisible in production:\\n\\n- **Dev personas.** `GET /auth/dev-personas` returns a list of `{ email, name, roleCode, orgName }` entries. `LoginClient` renders them as one-click sign-in buttons, but only when `GET /auth/entra/status` reports `allowDevAuth: true`. On LAN origins (`isLanDevOrigin()`), personas render above the form so a phone-side demo session can be established without typing.\\n- **Email-only dev sign-in.** When neither native auth nor Entra is enabled, the login form degrades to a bare email field posting to `POST /auth/login` \u2014 the BFF's dev-login endpoint guarded by `DEV_AUTH_SECRET`.\\n- **`createSession` from `verify-demo`.** Used by `InviteClient` to seed the frontend verification walkthrough with employee-invite context. This is client-side-only state for the demo journey, not a real session.\\n\\n---\\n\\n## Error handling conventions\\n\\nThe module treats login failures in three tiers:\\n\\n| Tier | Handling |\\n|---|---|\\n| Validate client-side | Password mismatch, missing email, postcode format \u2192 inline `setError`, no network call. |\\n| API validation errors | `ApiClientError` with `status === 400` or `VALIDATION`/`INVALID` codes \u2192 normalized to \\\"Enter a valid email address.\\\" |\\n| Everything else | `err.message` when available; otherwise a generic connection message. |\\n\\nThe OTP verify page additionally contains a compatibility shim: if the sign-in continue call fails with a message matching `/sign-?in tokens failed|try signing in/i`, it assumes an older backend dead-ended after Entra account creation and forwards the user to `/auth/created` instead of showing an unrecoverable error.\\n\\nEvery async flow in the module sets a `cancelled` flag (or guards with `if (cancelled) return`) inside `useEffect` so that a component unmount doesn't produce a state update after navigation.\",\"authentication-session-management\":\"# Authentication &amp; Session Management\\n\\n# Authentication &amp; Session Management\\n\\n## Purpose\\n\\nThis module owns every way a user gets into Assureworx \u2014 Entra External ID hosted OAuth, branded Entra Native Authentication, a DEV-only passwordless path, trade self-registration, session issuance/revocation, and login email changes. Every flow converges on the same primitive: a signed `awx_session` HTTP-only cookie whose claims identify an `actor` backed by `core.t_actor`. No passwords are stored, IdP tokens never reach the browser, and revocation is immediate via a per-actor session version counter.\\n\\nTwo sub-modules implement this:\\n\\n- [Backend source](backend-src.md) \u2014 the server-side auth handlers, session issuance/revocation, and shared auth primitives (`shared/auth/*`, `shared/services/email-change.ts`).\\n- [Frontend source](frontend-src.md) \u2014 the route components under `app/auth/*` plus client/server session plumbing in `lib/` (`SessionProvider`, `server-session.ts`, `session-core.ts`).\\n\\n## How the sub-modules fit together\\n\\n```mermaid\\nflowchart TB\\n    subgraph FE[\\\"frontend-src\\\"]\\n        Routes[\\\"Auth routes (app/auth/*)Suspense-wrapped server pages\\\"]\\n        Lib[\\\"Session infrastructureSessionProvider \u00b7 server-session \u00b7 session-core\\\"]\\n    end\\n    subgraph BE[\\\"backend-src\\\"]\\n        Handlers[\\\"Auth handlersEntra OAuth \u00b7 Native Auth \u00b7 dev login \u00b7 invites \u00b7 email change\\\"]\\n        Cookie[\\\"awx_session cookieissue / revoke via version counter\\\"]\\n    end\\n    Routes --&gt;|wraps client flows| Lib\\n    Lib --&gt;|calls shared/auth primitives| Handlers\\n    Handlers --&gt;|issues| Cookie\\n    Cookie --&gt;|validated on every request| Lib\\n```\\n\\nThe frontend route pages are intentionally thin: each wraps a `\\\"use client\\\"` component in `Suspense` (required because every flow reads `useSearchParams`). Those client components delegate to the shared auth utilities that live on the backend side, which perform the actual IdP/Entra work and hand back a session. After the session exists, the frontend `SessionProvider` drives role-based redirects and org switching via `roleFromMe` and `meFingerprint` from `session-core.ts`.\\n\\n## Key workflows spanning both sub-modules\\n\\n- **Login &amp; session issuance** \u2014 `LoginClient` calls the shared auth module index (`modules/auth/index.ts`), which dispatches to `nativeSigninWithPassword`, `nativeSigninWithOob`, or the Entra OAuth exchange (`exchangeAuthorizationCode` \u2192 `verifyIdToken`). Success converges on `issueSessionCookie` (`shared/auth/session.ts`) from the backend; the frontend then resolves the actor's landing page through `coerceReturnTo` / `homeFor` / `roleFromMe`.\\n\\n- **Native Authentication flows** \u2014 sign-up, sign-in, and password reset all share `postForm`-based request/response handling (`native-signup-start`, `nativeSignupResend`, `nativeResetSubmitPassword`) with errors normalized by `humanizeNativeError`. OOB-code steps use `requireContinuation` to preserve flow state, and the verify-code screen clears the credential-setup draft on completion.\\n\\n- **Seat invitations &amp; trade self-registration** \u2014 `InviteClient` accepts a token, then runs `connect` \u2192 `startEntraSignUp` \u2192 `startCredentialSetup`, finishing with either `safeReturnTo` or `stashCredentialSetupDraft` depending on whether the invited user can complete setup immediately. `linkActorFromEntra` (guarded by `assertEntraEmailAllowed`) binds the Entra subject to the login row, and `ensureTradeProfileForCredentials` provisions the trade profile.\\n\\n- **Email change** \u2014 `confirmNewEmail` / `resendEmailChange` (`shared/services/email-change.ts`) manage verification-token lifecycle (`expireStale`, `toRow`), gated by `verifyOtpCallbackToken` from `shared/auth/otp-callback-auth.ts`, with the backend's `email-change/[kind]` route mounting the confirm UI.\\n\\n- **Session validation &amp; revocation** \u2014 every authenticated request validates the `awx_session` cookie server-side; because revocation uses a per-actor version counter, sign-out or an email change invalidates all previously issued sessions immediately, with no token blacklist needed.\",\"core-backend-infrastructure-shared-types\":\"# Core Backend Infrastructure \u2014 shared-types\\n\\n# Core Backend Infrastructure \u2014 shared-types\\n\\n## Overview\\n\\n`packages/shared-types` is the single source of truth for the API contracts shared by the Assureworx frontend (React) and backend (Node). It contains TypeScript type declarations only, plus one runtime constant (`AU_REGIONS`): no functions, no classes, no business logic, no imports of its own. Types are erased at compile time, so the module has no runtime footprint.\\n\\nIts job is to keep the frontend and backend from drifting. A route handler's response type and the `fetch` call's generic parameter resolve to the same declaration; when the contract changes, the compiler finds every consumer.\\n\\nWhat the module deliberately does *not* contain is the physical database vocabulary. The file header states this directly:\\n\\n&gt; Everything here is LOGICAL vocabulary \u2014 physical DB codes (S01/K02/\u2026) are translated at the repository layer per `wiki/architecture/schema-mapping.md`.\\n\\nEach logical union carries its physical counterpart as a JSDoc comment \u2014 e.g. `VerifiedStatus` \u2194 `t_party.status_cd S01\u2013S04`, `AppRole` \u2194 `t_grant.role_cd R01\u2013R05`, `RequestKind` \u2194 `Q01\u2013Q05`. Services and routes never see physical codes.\\n\\n## Design principles\\n\\n### The vocabulary contract\\n\\nEvery cross-cutting enum is declared once as a string-literal union:\\n\\n```ts\\nexport type VerifiedStatus = \\\"pending\\\" | \\\"verified\\\" | \\\"action_required\\\" | \\\"suspended\\\";\\n```\\n\\nWhere an enum carries semantics beyond a label, the JSDoc says so. `RequestKind` is the best example: `connection` targets a party that already exists on the platform and shares the portable profile instead of running the wizard (ADR-0012); `top_up` goes further \u2014 the party is already engaged, and only the requirements missing for one placement are asked for (ADR-0018).\\n\\n### Facts versus derived state\\n\\nA repeated pattern in this module is the split between what is stored and what is computed on read.\\n\\n- `IdentityCheckState` is an **immutable historical fact** \u2014 `\\\"verified\\\" | \\\"unverified\\\" | \\\"check_failed\\\"`. It never decays, and `check_failed` is never a negative finding about the person.\\n- `IdentityStatus` is **derived on read** from the check outcome, consent, and today's date. It is never stored. This is what lets a check remain a true historical fact while no longer being relied upon. The `consent_lapsed` state exists precisely for this: the document is still valid, but the permission to use the result is not (ADR-0015 \u00a73).\\n\\nThe same split appears elsewhere:\\n\\n- `EngagementReadiness.state` is computed from the union of every applicable requirement, \\\"rather than copied from the portable profile status.\\\"\\n- `WalletPass.validity` is portable Verified status only. Site readiness is never claimed on the pass face \u2014 the gate resolves it live against the site (ADR-0018).\\n- `FundingLabel` is derived on the server \\\"so the browser cannot invent 'Paid'\\\", and never reports `paid_*` while the provider or the internal allocation is still outstanding.\\n\\n### Snapshots versus live references\\n\\nSeveral types carry immutable snapshots of what was true at a particular moment, so history survives later edits:\\n\\n- `RequestRow.verificationRequirementKeys` is an \\\"immutable copy of what the sender asked this recipient to complete\\\" \u2014 not a live reference to the sender's current template.\\n- `VerificationRequirement.key` is a \\\"stable snapshot key\\\": either a system check or `artifact:`, so requirement unions can dedupe across sources.\\n- `SeatInviteRow.verificationRequirementKeys`, present only for employee seats created after migration 034, snapshots what the employer asked for.\\n- `ExtractionPreview` is the response from `POST /artifacts/extract` \u2014 nothing is persisted by that call. `ExtractionSummary` is the shape attached to a document once it enters the platform review queue.\\n\\n### Null, absent, and optional\\n\\nThe types use the `null` vs. absent distinction deliberately:\\n\\n- `null` means \\\"required but missing\\\" \u2014 e.g. `EvidenceRow.artifactId: string | null` where null means the document is required but has not been uploaded.\\n- Absent (optional) means \\\"not applicable in this context\\\" \u2014 e.g. `classificationCodes?` appears only on directory rows; `verificationRequirementKeys?` only on employee seat invites.\\n- `token` fields appear only when the viewer is entitled to see them: `RequestRow.token` is \\\"only present for senders\\\"; `LineageInvite.token` is \\\"only present when the viewer's org sent the invite.\\\"\\n- `photoUrl: null` is the universal signal to render initials instead of an image.\\n- `emailSent` is set on create/resend (when ACS attempted) and absent on list responses.\\n\\n### View separation\\n\\nMany shapes exist in pairs so a caller can only ever see what it is entitled to:\\n\\n- `FundingView` is beneficiary-facing and \\\"carries no amount and no payer person.\\\" `OrderView` \\\"carries money because it is their money\\\" \u2014 it is the payer's own order, which a beneficiary never sees.\\n- `AdminRefundView` and `AdminOrderView` extend the payer views with the provider references and requester identity an operator needs to reconcile against Stripe \u2014 information that belongs to nobody else.\\n- `PaymentsStatusView.detail` names environment variables and source files, \\\"which is what the person fixing it needs and what a tenant must never see\\\"; the route behind it is platform-admin only.\\n- `DirectoryLookupResult` is \\\"deliberately thin: existence and portable status only, never the underlying profile \u2014 a principal has no relationship with this party yet.\\\"\\n- `IdentityDiagnostics` (ADR-0026) is non-identifying telemetry: no document numbers, names, dates of birth, MRZ data, images, or media URLs. It exists so the product can say \\\"your document photo was too dark, redo that one step\\\" instead of \\\"check failed.\\\"\\n\\n## The API envelope\\n\\nEvery endpoint response is expected to conform to `ApiEnvelope`:\\n\\n```ts\\nexport type ApiEnvelope = ApiSuccess | ApiError;\\n```\\n\\nIt is a discriminated union on `success`. `ApiSuccess` carries `data` plus optional pagination metadata (`page`, `pageSize`, `total`, with an index signature for extension). `ApiError` carries a stable machine-readable `code`, a human `message`, and optional structured `details`.\\n\\nPagination appears in two forms: page-based metadata in the envelope's `meta`, and keyset pagination in `AuditEventPage.nextBefore` \u2014 \\\"Pass as `before` for the next keyset page (ISO timestamp of last item).\\\"\\n\\n## Domain map\\n\\nThe file is organized into the sections below. Each section's exported types:\\n\\n| Section | Key types |\\n|---|---|\\n| Envelope | `ApiSuccess`, `ApiError`, `ApiEnvelope` |\\n| Logical enums | `VerifiedStatus`, `AppRole`, `PartyKind`, `OrgClass`, `RequestKind`, `IdentityAnchor`, `RequestStatus`, `EvidenceStatus`, `ReadinessState`, `NodeKind` |\\n| Requirements catalogue | `VerificationRequirement`, `VerificationTemplate` |\\n| Engagement requirements (ADR-0005, ADR-0018) | `RequirementSource`, `RequirementBlock`, `EngagementRequirement`, `EngagementReadiness` |\\n| Identity verification (ADR-0014/0015) | `IdentityCheckState`, `IdentityStatus`, `IdentityStepKey`, `IdentityStepState`, `IdentityDiagnostics`, `IdentitySummary` |\\n| Auth / session | `Membership`, `Me`, `LoginRequest`, `SwitchOrgRequest`, `EmailChangeStatus`, `EmailChangeRequest`, `StartEmailChangeBody` |\\n| Dashboard / reporting | `StatusCount`, `ExpiringItem`, `EvidenceAttentionItem`, `StatusSplit`, `ActivityItem`, `AuditEventItem`, `AuditEventPage`, `DashboardCompanyRow`, `DashboardSummary` |\\n| Directory | `DirectoryRow`, `DirectoryQuery`, `IndividualSummary`, `PcArtifactReview`, `EvidenceRow`, `CompanyProfile` |\\n| Network lineage | `LineageRelation`, `LineageIndividual`, `LineageInvite`, `LineageCompany`, `NetworkLineage`, `IndividualProfile` |\\n| Requests | `RequestRow`, `SeatInviteKind`, `SeatInviteRow`, `EmployeeOnboardingView`, `CreateRequestBody`, `CreateProjectInviteBody`, `ProjectInviteRecipient`, `ProjectInviteTradeBundle`, `CreateProjectInviteResult` |\\n| Onboarding wizard | `OnboardingContext`, `AcceptInviteBody`, `AbnVerifyBody`, `AbnVerifyResult`, `TradeTypesBody`, `TradeClassification`, `ProjectInviteOptions`, `RequirementChecklist`, `UploadArtifactBody` |\\n| Document extraction (ADR-0019) | `ExtractionStatus`, `ExtractionVerdict`, `ExtractionReadValue`, `ExtractionFinding`, `ExtractionPreview`, `ExtractionSummary` |\\n| Projects &amp; sites | `SiteSummary`, `PortfolioSite`, `GeocodePlaceRequest`, `GeocodePlaceResult`, `ProjectRow`, `ProjectDetail`, `CreateProjectBody`, `UpdateProjectBody`, `SiteAssignment`, `SiteDetail`, `CreateSiteBody`, `UpdateSiteBody`, `AssignTradeBody` |\\n| Trade self-service | `MyProfile`, `TradeTypeCount`, `WorkforceBreakdown`, `TradeAvailability`, `UpdateLocationBody`, `UpdateAvailabilityBody`, `UpdatePhotoBody`, `TeamMember`, `TeamMemberProject`, `AssignableSite`, `AssignEmployeeBody`, `CreateEmployeeInviteBody` |\\n| Engagements | `Engagement`, `MyEngagements` |\\n| Regions | `AU_REGIONS` (const), `AuRegion` |\\n| Notifications | `NotificationRow` |\\n| Wallet passes | `WalletStatus`, `WalletPass`, `WalletPassIssueBody`, `WalletGateResolve`, `WalletPassIssueResult` |\\n| Support desk | `SupportKind`, `SupportPriority`, `SupportStatus`, `SupportSlaState`, `SupportTicket`, `SupportNote`, `SupportTicketDetail`, `CreateSupportTicketBody`, `SupportMonitor` |\\n| Schema ERD | `SchemaDomain`, `SchemaColumn`, `SchemaTable`, `SchemaEdge`, `SchemaErdModel` |\\n| Paid verification (migrations 023\u2013025) | `EligibilityState`, `EligibilityReason`, `VerificationCaseStatus`, `FundedBy`, `PayerKind`, `OrderKind`, `TaxMode`, `OrderStatus`, `AllocationStatus`, `AttemptStatus`, `ProviderEventStatus`, `LedgerEntryKind`, `RefundStatus`, `RefundReason`, `DisputeStatus`, `FundingLabel`, `FundingView`, `OrderItemView`, `OrderView`, `RefundView`, `AdminRefundView`, `CheckoutStart`, `AdminOrderView`, `PaymentInboxProblem`, `PaymentSweepResult`, `PaymentsStatusView`, `PaymentsConfigView` |\\n\\n## Key domains in depth\\n\\n### Identity verification\\n\\n`IdentityCheckState` is the stored outcome of one check. It is immutable and never decays. `IdentityStatus` is the currency view, derived on read from the check outcome, whether consent to rely on it is still granted, and today's date. The full union is:\\n\\n`not_checked` \u00b7 `in_progress` \u00b7 `current` \u00b7 `expiring_soon` \u00b7 `expired` \u00b7 `superseded` \u00b7 `did_not_pass` \u00b7 `could_not_complete` \u00b7 `consent_lapsed`\\n\\n`IdentitySummary` bundles the status with everything a UI needs to display it responsibly: `checkedAt` (\\\"Must always be displayed alongside the status\\\"), the document expiry (the only document field the platform holds), consent state, whether a new check may start and why not (`blockedReason`), and `sessionExpiresAt` so the UI can say \\\"finish by Sunday\\\" instead of letting a user discover a dead session. `diagnostics` carries the ADR-0026 telemetry: per-step outcomes, capture quality scores (0\u2013100), liveness/face-match confidence, provider warning codes, `\\\"live\\\" | \\\"sandbox\\\"` provenance, and document type/country \u2014 never identifiers.\\n\\n### Engagement readiness\\n\\n`EngagementReadiness` answers \\\"is this trade ready for this placement?\\\" It is computed per placement from the union of every applicable `EngagementRequirement`, across all five `RequirementSource` values: `portable`, `organisation`, `project`, `site`, `placement`.\\n\\n```mermaid\\nflowchart LR\\n    P[\\\"portable requirements\\\"] --&gt; U[\\\"Union, dedupe by stable key\\\"]\\n    O[\\\"organisation / project / site / placement requirements\\\"] --&gt; U\\n    U --&gt; R[\\\"EngagementRequirement[]\\\"]\\n    R --&gt; G{\\\"met?\\\"}\\n    G -- \\\"no\\\" --&gt; H[\\\"gaps\\\"]\\n    H --&gt; S[\\\"ReadinessState: ready / blocked / action_required / pending\\\"]\\n```\\n\\nKey properties:\\n\\n- **Dedupe by stable key.** `EngagementRequirement.key` is `doc:A101` or `course:`, stable across sources, so the same requirement imposed by both a site and a principal collapses into one row.\\n- **Scoping by classification.** `appliesTag` limits a requirement to placements engaged for that classification; `engagedTags` on the readiness records what the principal actually engaged the placement for.\\n- **Gaps are the top-up ask.** `gaps` is the subset of requirements not yet satisfied, with `hardGapCount` and `softGapCount` separating hard blocks (`block_cd` B01) from soft ones (B02). This feeds the ADR-0018 top-up flow.\\n- **Not copied from the portable profile.** A trade can be portably `verified` and still `blocked` for a specific site.\\n\\n### Onboarding and invites\\n\\n`OnboardingContext` is what the invite page renders. It carries the exact `verificationRequirements` snapshot selected when the invitation was sent \u2014 \\\"The exact questions and evidence selected when this invitation was sent.\\\"\\n\\nWhen the invited identity already exists, `existingProfile` is non-null and the page offers a one-step connect instead of the wizard (ADR-0012). The subtle field here is `existingProfile.credentialPending`: the profile exists but nobody has finished setting up a sign-in, so there is no credential to authenticate against yet. It decides whether to send the person to Entra sign-up or sign-in \u2014 offering sign-in for an account that was never created fails with \\\"we couldn't find an account.\\\"\\n\\n`RequirementChecklist` gates submission on two things: whether the core checks pass and whether `tradeTypesSelected` is true \u2014 \\\"False when the invitation asks for trade types and none are saved yet. True when trade types are not required, or at least one classification is selected.\\\" `AbnVerifyResult` distinguishes `valid` (found on the ABR with a valid checksum; cancelled/historical statuses still return `true`) from `active` (ABR status `Active`; compliance only passes when this is true).\\n\\n### Paid verification and funding\\n\\nThe paid-verification section is the largest and the most careful about who may see what. The lifecycle is a chain of statuses: `VerificationCaseStatus` (`open` \u2192 `funded` \u2192 `in_verification` \u2192 `completed` \u2192 `closed`) for the chargeable unit; `OrderStatus` for the payer's order; `AllocationStatus` for each beneficiary on an order; `AttemptStatus` and `ProviderEventStatus` for provider interactions; `RefundStatus` and `DisputeStatus` for post-payment events.\\n\\n`EligibilityState` is the server-computed position on whether paid verification may be sold. The JSDoc is explicit: \\\"`eligible_for_payment` is not `verified` and never implies it.\\\" Each position is backed by `EligibilityReason[]` \u2014 machine-readable `code` plus `subject` and `detail` \u2014 so the UI can tell a person exactly which artifact or check is blocking them.\\n\\nThe view separation is this module's best example:\\n\\n- `FundingView` \u2014 what a beneficiary is told. `label` is a `FundingLabel` derived on the server, and it never reports `paid_*` while the provider or the internal allocation is still outstanding. It carries no amount and no payer person; `funderName` is an organisation display name, present only when `fundedBy === \\\"sponsor\\\"`.\\n- `OrderView` \u2014 what a payer sees. It carries money (integer AUD cents throughout) because it is the payer's own money.\\n- `AdminOrderView` / `AdminRefundView` \u2014 what an operator sees. They add `payerPartyId`, `payerName`, `sessionRef`, `intentRef`, `attempts`, `providerRef`, `refundableAmount`, and `nextTryAt` \u2014 the reconciliation surface that belongs to nobody else.\\n- `RefundView.amount` is `null` until a reviewer approves one, \\\"because the payer does not choose it: an approved refund is always a whole number of verifications.\\\" The `RefundReason` catalogue lives in `meta.code_dict` (migration 025); the union mirrors it.\\n- `CheckoutStart.redirectUrl` is the \\\"Provider-hosted page. The only place card details are ever entered.\\\"\\n\\nMoney is integer AUD cents everywhere (`unitAmount`, `taxAmount`, `totalAmount`, `subtotal`, `tax`, `total`, `displayAmountC`). `TaxMode` and `taxRateBp` (basis points; 1000 = 10%) ride along on orders and config views.\\n\\n## Connections to the codebase\\n\\n```mermaid\\nflowchart LR\\n    subgraph fe[\\\"Frontend (React)\\\"]\\n        UI[\\\"UI components / pages\\\"]\\n    end\\n    subgraph be[\\\"Backend (Node)\\\"]\\n        ROUTES[\\\"API routes\\\"]\\n        SVC[\\\"Services / domain logic\\\"]\\n        REPO[\\\"Repository layer\\\"]\\n        DB[(\\\"PostgreSQL\\\")]\\n    end\\n    subgraph st[\\\"packages/shared-types\\\"]\\n        T[\\\"Logical API contracts(type-only, compile-time)\\\"]\\n    end\\n    UI -- \\\"imports types\\\" --&gt; T\\n    ROUTES -- \\\"imports types\\\" --&gt; T\\n    ROUTES --&gt; SVC --&gt; REPO --&gt; DB\\n    REPO -- \\\"maps S01/K02/\u2026 \u2194 logical unions\\\" --&gt; T\\n```\\n\\n- **Consumers.** Frontend pages and API route handlers import these types and use them as the boundary contract. Because the module is type-only, there is no runtime coupling \u2014 the types compile away.\\n- **Physical translation.** The repository layer translates physical DB codes (`S01`, `K02`, `R03`, `Q05`, \u2026) into the logical unions declared here, per `wiki/architecture/schema-mapping.md`. Services and routes should never see physical codes.\\n- **ADR traceability.** The JSDoc carries ADR references where a type encodes a decision: ADR-0005 (engagement requirement layers), ADR-0012 (connection requests, identity-anchor resolution ladder, credential-pending sign-up), ADR-0014/0015 (check history vs. currency, consent model), ADR-0018 (top-up requirements, trade-type scoped bundles, live wallet-gate readiness), ADR-0019 (extraction proposes, never verifies), ADR-0026 (non-identifying diagnostics).\\n- **Mutation bodies.** Request bodies follow one convention: create bodies are the full input (`CreateProjectBody`, `CreateSiteBody`, `CreateRequestBody`), update bodies are the same shape as create and used by PATCH (`UpdateProjectBody`, `UpdateSiteBody`), and small action bodies carry only what the action needs (`AssignTradeBody`, `AssignEmployeeBody`, `SwitchOrgRequest`).\\n- **Runtime surface.** The only runtime export is `AU_REGIONS` (`as const`), from which the `AuRegion` literal type is derived. Everything else is erased at compile time.\",\"core-backend-infrastructure-src\":\"# Core Backend Infrastructure \u2014 src\\n\\n# Core Backend Infrastructure \u2014 `src`\\n\\n## Overview\\n\\nThe `src` directory is the Express application core of the Assureworx API. It owns process boot and shutdown, the HTTP middleware pipeline, configuration loading, database access scoping, the background job system, and the shared primitives every feature module (`src/modules/*`) builds on.\\n\\nThe design is organized around three principles:\\n\\n- **Fail closed.** Configuration errors, mis-set environment flags, and missing secrets throw at boot, not at request time. Live credentials are refused outside the prod tier.\\n- **Defence in depth.** RLS-scoped transactions back repository code, CSRF protection supplements SameSite cookies, and rate limiting keys sessions rather than raw IPs where possible.\\n- **Backstops over guarantees.** Webhooks are not the only path to a confirmed state \u2014 reconciliation timers poll providers for outcomes the webhook never delivered.\\n\\n## Process Lifecycle\\n\\n### Boot sequence (`server.ts`)\\n\\n`server.ts` is the entry point. The first line is a side-effect import of `./shared/telemetry.js` \u2014 it must stay first so OpenTelemetry patches `http`, `pg`, and `redis` before any of them are used.\\n\\nAfter telemetry, boot proceeds in a fixed order:\\n\\n1. `registerJobHandlers()` \u2014 registers all background job handlers (email, SMS, payment events, artifact extraction, site geocoding). This must happen before `startJobWorkers()`, because BullMQ only starts workers for handlers present at boot.\\n2. `ensureSmsJob()` \u2014 schedules the recurring SMS send job.\\n3. `startJobWorkers()` \u2014 starts BullMQ workers when Redis is configured; no-op otherwise (the in-process fallback queue is used instead).\\n4. Five scheduler timers are started, each a periodic backstop:\\n   - `startPaymentReconcileTimer()` \u2014 reconciles unsettled payment orders against Stripe.\\n   - `startIdentityReconcileTimer()` \u2014 polls Didit for identity outcomes that the return trip never delivered.\\n   - `startAssuranceSweepTimer()` \u2014 recomputes party status / link readiness for calendar-expired evidence, identity, and consent (Q20).\\n   - `startRetentionSweepTimer()` \u2014 ADR-0027 retention clocks (artifacts, evidence, checks, events, consent, closures).\\n   - `startSmsSweepTimer()` \u2014 re-drives SMS rows staged but not pumped (e.g. a Redis blip between commit and enqueue).\\n5. `createApp()` builds the Express app and `app.listen()` binds it to `config.host` / `config.port`.\\n\\nBoot logs the ABN verifier provider, DB pool size, and Redis state. The `LOAD_TEST_RELAX_LIMITS=1` escape hatch is logged when active, but only takes effect when `ALLOW_DEV_AUTH=1` and `ASSUREWORX_ENV=local` are also set.\\n\\n### Shutdown\\n\\n`SIGTERM` and `SIGINT` both trigger a single-flight `shutdown()`:\\n\\n1. Stop all five scheduler timers.\\n2. `server.close()` with a 10-second hard cap (`setTimeout(...).unref()`) so a stuck keep-alive client cannot hang the container.\\n3. `stopJobSystem()` to drain workers and close queues; a drain failure is logged but does not block exit.\\n4. `process.exit(0)`.\\n\\nThe `shuttingDown` guard makes repeated signals idempotent.\\n\\n## Request Pipeline (`app.ts`)\\n\\n`createApp()` returns a configured Express app. Middleware order is deliberate \u2014 each layer is positioned relative to the ones that must run before or after it.\\n\\n```mermaid\\nflowchart TD\\n    A[helmet + Permissions-Policy] --&gt; B[CORS]\\n    B --&gt; C[globalLimiter]\\n    C --&gt; D{Provider webhooks?}\\n    D --&gt;|/api/v1/identity/webhook| E[identityWebhookRouter raw body]\\n    D --&gt;|/api/v1/payments/webhook| F[paymentsWebhookRouter raw body]\\n    D --&gt;|/api/v1/sms/webhook| G[smsWebhookRouter urlencoded]\\n    D --&gt;|no| H[express.json 3mb]\\n    H --&gt; I[otpEmailRouter]\\n    I --&gt; J[emailWebhookRouter]\\n    J --&gt; K[cookieParser]\\n    K --&gt; L[bindAuditContext]\\n    L --&gt; M[csrfGuard]\\n    M --&gt; N{/api/v1 router}\\n    N --&gt; O[mutationLimiter on non-GET]\\n    O --&gt; P[Feature routers]\\n    P --&gt; Q[errorHandler]\\n```\\n\\nKey ordering decisions:\\n\\n- **`app.set(\\\"trust proxy\\\", 1)`** is set first. The deployment sits behind Azure Container Apps / Front Door, which terminate TLS and forward client IPs. Without this, `express-rate-limit` would key every user on the proxy hop, collapsing them into one shared bucket \u2014 or worse, trust a spoofable `X-Forwarded-For`.\\n- **Provider webhooks are mounted before `express.json`.** The identity, Stripe, and SMS webhook routers each apply their own body parser (raw / urlencoded) because HMAC or Stripe signature verification needs the untouched bytes. These routes are also mounted before `csrfGuard` \u2014 the signature *is* the authentication; they are not cookie-authenticated mutations.\\n- **`express.json({ limit: \\\"3mb\\\" })`** follows, sized for base64 data-URL selfie thumbnails in wallet passes.\\n- **`otpEmailRouter`** (Entra OnOtpSend custom extension) sits after the JSON parser but **before** `csrfGuard` \u2014 it is a server-to-server Entra callback returning Entra JSON, not an `ApiEnvelope`.\\n- **`emailWebhookRouter`** (ACS/Event Grid delivery reports) authenticates via a high-entropy query secret.\\n- **`bindAuditContext`** stamps IP / user-agent / `x-request-id` onto every audit write for the request.\\n- **`csrfGuard`** runs after cookie parsing so it can inspect the session cookie.\\n\\nThe `/api/v1` router applies `mutationLimiter` to non-GET/HEAD/OPTIONS requests as a site-wide write ceiling, then mounts all feature routers under their path prefixes (`/auth`, `/memberships`, `/parties`, `/directory`, `/requests`, `/onboarding`, `/nodes`, `/links`, `/artifacts`, `/checks`, `/identity`, `/verification`, `/training`, `/cases`, `/notifications`, `/billing`, `/payments`, `/reporting`, `/admin`, `/support`, `/wallet`). Tighter per-route limiters in the feature modules stack on top of this ceiling.\\n\\n`/health` returns service name, `ok`, and deployment version metadata (`commit`, `builtAt`, `dirty`, `promotable`).\\n\\n`errorHandler` is mounted last and converts every thrown error into the standard error envelope.\\n\\n## Configuration (`shared/config.ts`)\\n\\nThe `config` object is the single source of environment truth. It is computed at module load; invalid configuration throws immediately.\\n\\n### Deployment tier resolution\\n\\n`deployEnv()` reads `ASSUREWORX_ENV` (local | dev | test | prod). If unset, it falls back to `NODE_ENV === \\\"production\\\"` \u2192 `prod`, otherwise `local`. The distinction matters because Azure DEV often runs with `NODE_ENV=production` \u2014 `isProd` alone would be wrong.\\n\\nTwo derived flags gate behaviour throughout the codebase:\\n\\n- `isDeployed` \u2014 true when `NODE_ENV=production` **or** `ASSUREWORX_ENV` is `prod`/`test`. Gates whether environment fallbacks are withdrawn.\\n- `isHardenedEnv` \u2014 true only for `prod`/`test`. Gates live-ABR requirement, no token echo, and Entra-only registration.\\n\\nThe documentation in `config.ts` is emphatic: gate anything that must not silently fall back to a development default on `isDeployed`, never on `isProd`.\\n\\n### Fail-closed validation\\n\\nSeveral config values are validated at boot:\\n\\n- **`sessionSecret()`** \u2014 refuses known-leaked values (`\\\"secret\\\"`, `\\\"changeme\\\"`, `\\\"password\\\"`, the committed dev secret), requires \u2265 32 bytes, and has no fallback outside local/test.\\n- **`stripeSecretKey()`** \u2014 rejects non-secret shapes (`pk_\u2026` is not accepted) and **refuses live keys unless `ASSUREWORX_ENV=prod`**. A live key on DEV/TEST would take real money from real cards during a click-through; this check is the only thing that can catch it.\\n- **`docIntelEndpoint()`** \u2014 validates the URL is HTTPS and the hostname ends in `.cognitiveservices.azure.com`. This value decides where customer compliance documents are sent, so a typo would be an exfiltration channel.\\n- **`corsOrigin()`** \u2014 requires an explicit comma-separated allowlist in deployed environments; never reflects arbitrary origins with credentials.\\n- **`assertAllowDevAuthPosture()`** \u2014 throws at boot if `ALLOW_DEV_AUTH=1` is set on test/prod. Passwordless login is local/DEV only.\\n\\n### Deliberate absences\\n\\nSome values are **not** configurable by environment variable because they are prices:\\n\\n- The Stripe price is pinned in `modules/payments/pricing.ts`.\\n- The Document Intelligence model is pinned in the client \u2014 `prebuilt-read` bills US$1.50 per 1,000 pages while layout/extractors bill US$10 and query fields US$200. An env var that can move between meters is an unbounded cost.\\n\\n### Monetary hard stops\\n\\n`config.didit.monthlyCap` (default 450) and `config.docIntel.monthlyPageCap` (default 2000) are enforced in the database before any outbound billable call. These are deliberately low defaults so a runaway loop is an annoyance, not an invoice.\\n\\n## Database Access (`shared/db/pool.ts`)\\n\\nThe module exports a shared `pg.Pool` plus transaction helpers that manage RLS session context.\\n\\n### The pool\\n\\n```ts\\nexport const pool = new Pool({\\n  connectionString: config.databaseUrl,\\n  max: config.dbPoolMax,\\n  idleTimeoutMillis: 30_000,\\n  connectionTimeoutMillis: 10_000,  // fail fast, don't hang\\n  statement_timeout: 30_000,\\n  query_timeout: 30_000,\\n});\\n```\\n\\nA pool-level `error` listener logs idle client failures (e.g. `admin_shutdown` / `57P01`) **without** crashing the process. Checked-out clients get the same treatment in `withClient`. This was a production incident: without the listener, Node treats the pool's `error` event as unhandled and kills the process \u2014 which is what the local k6 stress run hit around 400 VUs.\\n\\n### Transaction scoping helpers\\n\\nAll helpers run `BEGIN` / `COMMIT` (or `ROLLBACK`) and release the client in `finally`. The `setup` callback sets Postgres session config before the user function runs:\\n\\n| Helper | Session config set | Purpose |\\n|---|---|---|\\n| `withScope(scopePartyId, fn)` | `app.scope` | Tenant-scoped transaction for RLS-protected tables. Repositories remain the primary enforcement (ADR-0003); RLS is defence in depth. |\\n| `withBypass(fn)` | `app.bypass = 'on'` | Platform-admin bypass. Always pair with an audit event. |\\n| `withActor(actorId, fn, opts?)` | `app.actor` (+ optional `app.scope`) | Actor-scoped transaction for RLS tables keyed on `app.actor` (support tickets, membership loads). |\\n| `withTenant(actorId, scopePartyId, fn)` | `app.actor` + `app.scope` | Authenticated tenant transaction so identity/evidence RLS can see self + network. |\\n\\n`query(text, params)` is a one-off query outside any tenant scope for catalog reads and auth lookups.\\n\\n`advisoryXactLock(tx, key)` takes a transaction-scoped advisory lock \u2014 used for cross-row uniqueness races (e.g. ABN claim) where SELECT-then-INSERT is racy under concurrency. The key is hashed with SHA-256 and split into two int32 lock keys.\\n\\n## Background Jobs (`shared/jobs/`)\\n\\nThe job system runs in two modes:\\n\\n- **With `REDIS_URL` set:** BullMQ queues and workers, fail-closed enqueue (throws on Redis/BullMQ errors \u2014 no silent fallback), retries with exponential backoff (`DEFAULT_ATTEMPTS = 3`, `BACKOFF_MS = 2000`), and exhausted jobs copied to a `dlq` queue for inspection/replay.\\n- **Without Redis:** a volatile in-process queue with per-job concurrency limits (`LOCAL_CONCURRENCY`). It logs a warning on every enqueue that there are no retries or DLQ, and handler failures are logged but not retried.\\n\\n`enqueue(name, payload)` throws if the handler was not registered first \u2014 this catches the class of bug where email/SMS jobs sat in `wait` forever because the handler was registered lazily after workers started.\\n\\nRegistered job names (`JobName`):\\n\\n- `email.send`\\n- `sms.send`\\n- `artifact.extract`\\n- `wallet.build`\\n- `geocode.site`\\n- `payment.event`\\n- `payment.reconcile`\\n\\n### `registerJobHandlers()`\\n\\nCalled once at boot. It registers:\\n\\n- `registerEmailJob()`, `registerSmsJob()`, `registerPaymentJobs()` \u2014 module-owned handlers.\\n- `artifact.extract` \u2014 fetches the artifact from storage, runs the extraction ladder (with a preview-cache shortcut when the upload already produced a satisfactory preview), merges declared values via `withDeclared`, decides the new status with `decideArtifactStatus`, and persists extraction + status inside `withScope`. It locks the artifact row (`SELECT ... FOR UPDATE`), and deliberately **does not reopen a human reject** (`F03`). When the status changes, it calls `evaluateAndPersistStatus()`.\\n- `geocode.site` \u2014 geocodes a site address. It has a **stale-job guard**: if the site's current address no longer matches the job's query, or it was already geocoded for that query, the job skips. The guard is checked before the outbound geocode call and again before the write, because the write path must not overwrite a newer address change.\\n\\n`enqueueArtifactExtract(job)` and `enqueueSiteGeocode(job)` are the public enqueue wrappers.\\n\\n`stopJobSystem()` closes workers, queues, the DLQ, and Redis. It is safe to call when Redis is off.\\n\\n## Security Middleware\\n\\n### CSRF guard (`shared/middleware/csrf.ts`)\\n\\n`SameSite=Lax` is the primary CSRF control; `csrfGuard` is defence in depth. Logic:\\n\\n1. Safe methods (GET/HEAD/OPTIONS) pass.\\n2. `Sec-Fetch-Site` of `same-origin`, `same-site`, or `none` passes.\\n3. If no `Origin` header:\\n   - A session cookie **present** with no Fetch Metadata \u2192 blocked (legacy cross-site form posts).\\n   - No session cookie and no Fetch Metadata (curl / server-to-server) \u2192 passes.\\n4. Otherwise the `Origin` must be in `config.corsOrigin`.\\n\\n### Rate limiting (`shared/middleware/rate-limit.ts`)\\n\\nThe module exports a large family of `express-rate-limit` instances. All use a `RedisStore` when Redis is available and fall back to the default memory store otherwise. The response body is the standard error envelope with code `RATE_LIMITED`.\\n\\nThe two most architecturally significant limiters:\\n\\n- **`globalLimiter`** (600/min, relaxed to 50,000 under load-test mode) is mounted before `cookieParser`, so it cannot read `req.ctx.actorId`. Instead, `sessionOrIpKey()` hashes the raw `awx_session` cookie value from the header. It never decodes the cookie \u2014 hashing is enough to separate one signed-in browser from another, and a forged cookie only splits the attacker's own bucket. Pure IP keying put everyone behind one NAT (plus the Next.js BFF's own fetches) into a single bucket, which caused spurious 429s during normal navigation.\\n- **`mutationLimiter`** (60/min) is the site-wide write ceiling applied in `app.ts` to non-GET requests on `/api/v1`. It uses the same `sessionOrIpKey`.\\n\\nPer-route limiters cover:\\n\\n- Auth (`authLimiter`, 40 / 15 min), invites (create 60 / 15 min, resend 5 / 15 min), OTP email (30 / min, IP-keyed)\\n- Wallet (pass requests, signed-pass issues per actor, Apple PassKit web service)\\n- Content downloads (60/min \u2014 slows IDOR probing), uploads (20/min), extraction preview (30/min)\\n- Identity (start 5/hour per actor, reconcile 20/min, webhook 120/min)\\n- Payments (checkout 15 / 15 min per actor, reconcile 20/min, webhook 600/min)\\n- Geocode (30 / 5 min), support tickets (20 / 15 min), heavy reads (20/min), exports (6 / 15 min), assurance sweeps (3/hour)\\n- SMS webhook (600/min)\\n\\n`RATE_LIMIT_POLICY` is exported as **data** so policy tests can assert the numbers without probing `express-rate-limit` internals.\\n\\n`loadTestRelax` is the local k6 escape hatch. It arms only when `LOAD_TEST_RELAX_LIMITS=1` **and** `ALLOW_DEV_AUTH=1` **and** `ASSUREWORX_ENV=local` \u2014 it can never arm on Azure.\\n\\n### Safe redirects (`shared/http/safe-return-to.ts`)\\n\\n`safeReturnTo(raw, fallback)` accepts only path-only targets. It rejects protocol-relative (`//`), schemes (`://`), backslashes, and control characters. Used for post-auth redirect targets.\\n\\n## API Conventions\\n\\n### Response envelope (`shared/http/envelope.ts`)\\n\\nAll API responses use the shared `ApiSuccess` / `ApiError` shapes:\\n\\n- `ok(res, data, meta?)` \u2192 200 `{ success: true, data, meta? }`\\n- `created(res, data)` \u2192 201 `{ success: true, data }`\\n- `fail(res, status, code, message, details?)` \u2192 `{ success: false, error: { code, message, details? } }`\\n\\n### Error handling (`shared/http/errors.ts`)\\n\\n- **`HttpError`** \u2014 the base error class carrying `status`, `code`, `message`, `details`.\\n- Factories: `badRequest(code, message)`, `unauthorized()`, `forbidden()`, `notFound()`, `conflict(code, message)`.\\n- **`h(fn)`** \u2014 wraps async route handlers so rejections reach the error middleware. Standard Express 4 doesn't catch async throws; this is the required glue for every async route.\\n- **`errorHandler`** \u2014 the terminal middleware. It maps:\\n  - `HttpError` \u2192 its status/code.\\n  - `ZodError` \u2192 400 `VALIDATION_FAILED` with field paths only (never Zod internals).\\n  - `entity.parse.failed` \u2192 400 `BAD_JSON`.\\n  - `ECONNREFUSED` / `57P01` / `57P03` \u2192 503 `DATABASE_UNAVAILABLE` with a local-dev hint (the error message mentions the Docker container `assureworx-pg` on `:5433`).\\n  - Anything else \u2192 500 `INTERNAL_ERROR`, logged with method + URL.\\n\\n## Code Translation (`shared/codes.ts`)\\n\\nPhysical database codes never leave the repository layer; the API speaks logical codes. `codes.ts` holds the bidirectional maps, e.g.:\\n\\n- `PARTY_STATUS`: `S01` \u2194 `pending`, `S02` \u2194 `verified`, `S03` \u2194 `action_required`, `S04` \u2194 `suspended`\\n- `PARTY_KIND`: `K01` \u2194 `principal_contractor`, `K02` \u2194 `trade_company`, `K03` \u2194 `individual`\\n- `ROLE`: `R01`\u2013`R05` \u2194 `platform_admin` / `tier1_admin` / `tier1_user` / `trade_company_admin` / `trade_individual`\\n- `REQUEST_KIND`, `REQUEST_STATUS`, `LINK_STATE`, `NODE_KIND`, `ORDER_STATUS`, `ALLOC_STATUS`, `ATTEMPT_STATUS`, `REFUND_STATUS`, `DISPUTE_STATUS`, `CYCLE_STATUS`, `ELIGIBILITY`, and more.\\n\\nEach forward map has an `invert()`-derived reverse map (`PARTY_STATUS_CD`, etc.). The file also carries human-readable display names:\\n\\n- `TAG_NAMES` \u2014 trade classification labels (`G01` \u2192 \\\"Electrician\\\", \u2026)\\n- `ARTIFACT_CLASS_NAMES` \u2014 document type labels (`A001` \u2192 \\\"White Card (Construction Induction)\\\", \u2026)\\n\\nThe comment notes physical codes are obfuscated in `t_tag` and `t_artifact_class`; these maps are the only place the display names live.\\n\\n## Caching (`shared/cache.ts`)\\n\\nA small dual-backend cache:\\n\\n- **Redis** when `getRedis()` returns a client \u2014 `cacheGet` / `cacheSet` (with TTL seconds) / `cacheDel`, and `cacheDelPrefix` which uses `SCAN` with `MATCH` rather than blocking `KEYS`.\\n- **Process memory** otherwise \u2014 a `Map` with expiry timestamps. `pruneMemory()` triggers when the map exceeds 500 entries; `cacheDelPrefix` iterates keys with the prefix.\\n\\nThis is used for the membership cache (`memberships:`), invalidated by `invalidateMembershipCache()` in `resolve-tenant-context.ts` after grant/role changes.\\n\\n## Seed Data (`seed.ts`)\\n\\n`seed.ts` is an idempotent development seed producing a realistic Australian construction supply-chain network. Idempotency is keyed on the sentinel actor `jordan@campbell.dev`.\\n\\n### What it creates\\n\\n**Tier 1 \u2014 Campbell Constructions Pty Ltd** (principal contractor):\\n\\n- Admin `jordan@campbell.dev`, user `sam@campbell.dev`\\n- Projects (Riverside Quarter, Northgate Logistics Hub) and sites (Tower A/B, Warehouse 3)\\n- Trade assignments (`link` rows) to the companies below\\n\\n**Tier 2 \u2014 Russo Electrical Pty Ltd** (verified trade company):\\n\\n- Admin `maya@russo.dev`, employee `dean@russo.dev`, pending employee `liam.osei@russo.dev` (White Card expiring in 10 days)\\n- Documents: COI, ABN, insurance, workers comp, electrical licence; checks via `abr-mock`\\n- A pending sub-request to `jess.subbie@example.dev`\\n\\n**Other Tier 1-adjacent parties:**\\n\\n- Harbour Plumbing Group \u2014 **suspended** (`S04`), with an expired insurance certificate (5 days ago)\\n- Skyline Scaffolding \u2014 **pending** (`S01`), missing a SafeWork NSW licence (`A105` at `F01`)\\n- Tom Delaney \u2014 sole trader, verified, engaged by Campbell\\n\\n**Depth &amp; portability (`ensureDepthAndPortability`)** \u2014 keyed on Meridian Group existing:\\n\\n- **Voltaic Services Pty Ltd** \u2014 Tier 2, sub-contracted by Russo, with admin `priya@voltaic.dev`\\n- **Kirra Nguyen** \u2014 Tier 3, sub-contracted by Voltaic, sole-trader electrician with a White Card expiring in 18 days\\n- **Meridian Group Pty Ltd** \u2014 a second principal contractor with admin `alex@meridian.dev`, projects (Docklands Stage 2, Berth 14 Fitout)\\n- Tom Delaney appears **under both** Campbell and Meridian via a `connection` request \u2014 demonstrating ADR-0012 portability: one profile, one document set, engaged by two unrelated principals, nothing re-uploaded.\\n\\n### ABN generation\\n\\n`makeAbn(stem9)` deterministically derives a checksum-valid 11-digit ABN by trying leading pairs 10\u201399 against `isValidAbn()` (the standard ABN weighting algorithm). Campbell uses a fixed valid ABN (`51824753556`); all other orgs use `makeAbn`.\\n\\n### Idempotent re-runs\\n\\nOn subsequent runs (sentinel exists), it:\\n\\n1. Ensures the platform admin (`founder@assureworx.com.au`) exists.\\n2. Patches demo face photos onto known individuals.\\n3. Normalizes legacy em/en dashes in notification subjects to ` | `.\\n4. Ensures depth + portability.\\n5. Untagged employees inherit their employer's trade classifications (a single SQL `INSERT ... SELECT` with `NOT EXISTS`).\\n\\nPersonas are printed as a `console.table` at the end, along with pending invite tokens (`demo-invite-token-brightspark`, `demo-invite-token-jess`).\\n\\n## Connections to the Rest of the Codebase\\n\\nFeature modules under `src/modules/*` consume these shared primitives in consistent patterns:\\n\\n- **Routes** are `express.Router()` instances exported from `modules//index.ts`, mounted in `app.ts`. They use `h()` from `shared/http/errors.ts` to wrap async handlers and `ok()` / `created()` / `fail()` from `shared/http/envelope.ts` for responses.\\n- **Repositories** receive a `Queryable` (the `{ query }`-shaped tx/client) from `withScope` / `withBypass` / `withTenant` / `withActor`. They translate physical codes to logical codes via `shared/codes.ts` and never leak physical codes.\\n- **Tenant context** is resolved by `resolveTenantContext` and attached as `req.ctx` (the `TenantContext` type is augmented onto Express's `Request` interface). The membership cache backing it lives in `shared/cache.ts`.\\n- **Background work** is enqueued via `shared/jobs/queue.ts`'s `enqueue()`, with job payload types defined in `shared/jobs/handlers.ts` (`ArtifactExtractJob`, `GeocodeSiteJob`, `WalletBuildJob`).\\n- **Security-sensitive routes** (invites, uploads, identity start, checkout, exports, sweeps) apply the appropriate limiter from `shared/middleware/rate-limit.ts` in addition to the global and mutation ceilings.\\n- **Schedulers** in modules (`modules/payments/scheduler.ts`, `modules/identity/scheduler.ts`, `modules/assurance/scheduler.ts`, `modules/retention/scheduler.ts`, `modules/sms/scheduler.ts`) expose `start*Timer` / `stop*Timer` pairs driven by `server.ts`. They are the backstop layer for everything that can be missed on the request path.\",\"core-backend-infrastructure\":\"# Core Backend Infrastructure\\n\\n# Core Backend Infrastructure\\n\\nThe **Core Backend Infrastructure** module provides the runtime heart and the contract backbone of the Assureworx API. It is split into two sub-modules with deliberately different jobs:\\n\\n- **[`src`](src.md)** \u2014 the Express application core. It owns process boot and shutdown, the HTTP middleware pipeline, configuration loading, RLS-scoped database access, the background job system, and the shared primitives every feature module builds on.\\n- **[`shared-types`](shared-types.md)** \u2014 the single source of truth for API contracts shared by the React frontend and the Node backend. It contains type declarations only, plus the `AU_REGIONS` runtime constant \u2014 no functions, no logic, no imports, so the module has zero runtime footprint.\\n\\n## How the sub-modules fit together\\n\\n`shared-types` defines *what* the API speaks; `src` implements *how* it speaks. Route handlers in `src` type their responses against `shared-types` declarations, and the frontend's `fetch` calls reference the same generics \u2014 so when a contract changes, the compiler surfaces every consumer on both sides of the wire. `shared-types` deliberately stops short of the physical database vocabulary, keeping the two layers decoupled.\\n\\nInside `src`, the shared infrastructure forms one pipeline:\\n\\n- **Boot** \u2014 `server.ts` validates configuration (fail-closed: missing secrets, mis-set env flags, and live credentials outside prod throw at startup), assembles the middleware chain, then starts workers via `startJobWorkers` and registers handlers via `registerJobHandlers`.\\n- **Request path** \u2014 tenant-context resolution (`resolveTenantContext`, backed by membership caching) runs first, then Redis-keyed rate limiting (`redisStore`), then the CSRF guard. Route handlers execute inside RLS-scoped pool transactions (`withScope`, `withClient`, `advisoryXactLock`) and surface errors through the shared `HttpError` hierarchy (`badRequest`, `forbidden`, `unauthorized`).\\n- **Background jobs** \u2014 Bull/Redis workers handle email delivery, site geocoding (`enqueueSiteGeocode`), and meter unit reservation (`reserveUnits` / `asMeterAccess`).\\n- **Seeding** \u2014 `seed.ts` builds a depth-and-portability-checked dataset for dev/test tiers (`ensureDepthAndPortability` \u2192 `insertActor`, `insertArtifact`, `insertCheck`, `insertParty`, `insertTag`, `insertEvent`, with `insertAbn` guarded by `isValidAbn`).\\n\\n```mermaid\\nflowchart LR\\n    subgraph ST[shared-types]\\n        Contracts[API contract types \u00b7 AU_REGIONS]\\n    end\\n    subgraph SRC[src]\\n        Boot[server.ts]\\n        Conf[config.ts \u2014 fail-closed]\\n        MW[tenant-context \u00b7 rate-limit \u00b7 CSRF]\\n        Pool[RLS-scoped pool transactions]\\n        Jobs[queue.ts / handlers.ts]\\n        Services[email \u00b7 geocode \u00b7 meter services]\\n        Boot --&gt; Conf\\n        Boot --&gt; MW\\n        MW --&gt; Pool\\n        Boot --&gt; Jobs\\n        Jobs --&gt; Services\\n    end\\n    Contracts -. compile-time constraints .-&gt; SRC\\n```\\n\\n## Key cross-module workflows\\n\\n1. **Email delivery** \u2014 `registerJobHandlers` \u2192 `registerEmailJob` \u2192 `sendEmail`, which branches into `deliverViaAcs` (via `getEmailClient`, with HTML escaped through `escapeHtml`) and `createEmailDelivery` (hashing the recipient as `emailRecipientHash`).\\n2. **Boot-to-shutdown** \u2014 startup wires config \u2192 middleware \u2192 workers; shutdown calls `stopJobSystem` and `closeRedis` so the process tears down cleanly.\\n3. **Contract enforcement** \u2014 every request/response type originates in `shared-types` and flows into `src` handlers, keeping the backend honest about what it ships to the frontend.\\n\\nFor details on each layer, see [`src`](src.md) and [`shared-types`](shared-types.md).\",\"database-migrations-schema-migrations\":\"# Database Migrations &amp; Schema \u2014 migrations\\n\\n# Database Migrations &amp; Schema\\n\\n## Overview\\n\\nThe `db/migrations` directory contains the complete PostgreSQL schema for the Assureworx platform (database `assureworx`). The visible set spans 23 migrations, from the original prototype schema through the enterprise core rewrite, security hardening, and the engagement-requirements and verification-case models.\\n\\nThe schema is built around four architectural decisions that shape everything else:\\n\\n1. **Generic party model** (migration 002). Physical table names are deliberately obfuscated (`t_party`, `t_node`, `t_link`) rather than domain-revealing (`trade_organisations`, `projects`, `engagements`). The logical meaning of every table, column, and code value lives in the `meta` schema \u2014 the in-DB dictionary that is the source of truth for what the physical schema means (ADR-0007).\\n2. **Soft delete everywhere.** Every mutable table has `deleted_at TIMESTAMPTZ NULL`, and uniqueness is enforced with *partial* unique indexes (`WHERE deleted_at IS NULL`) so soft-deleted rows never block re-use of a key.\\n3. **RLS as defence-in-depth.** Row-level security policies protect scoped data, backed by `FORCE ROW LEVEL SECURITY` on sensitive tables. Primary enforcement remains in the application repository layer (ADR-0003); RLS is the second line of defence.\\n4. **Code values over enums.** Most `*_cd` columns are constrained by `CHECK` but their *meaning* is documented in `meta.code_dict`. The physical schema is not self-documenting by design.\\n\\n## Schema layout\\n\\n| Schema | Purpose | Contents |\\n|---|---|---|\\n| `core` | Application data | All `t_*` tables, sequences, trigger functions, RLS policies |\\n| `meta` | Restricted dictionary | `map_dict` (table meanings) and `code_dict` (code meanings) |\\n| `public` | Migration tracking | `schema_migrations` only |\\n\\nA single runtime role, `app_rt`, connects to the database. It is `NOLOGIN`; the API connects as a member. `app_rt` has `SELECT/INSERT/UPDATE/DELETE` on `core` tables (with targeted `REVOKE`s for immutable tables), but **no access to `meta`** \u2014 the dictionary is readable only through the SECURITY DEFINER functions `core.fn_map_dict()` and `core.fn_schema_migrations()`.\\n\\n## Conventions\\n\\nThese conventions are applied uniformly across the schema:\\n\\n- **Primary keys**: `UUID PRIMARY KEY DEFAULT gen_random_uuid()`\\n- **Timestamps**: `TIMESTAMPTZ`; `created_at` and `updated_at` on mutable tables, `deleted_at` for soft delete\\n- **Suffixes**: `*_cd` = code value, `*_on` = date, `*_at` = timestamp, `*_txt` = free text\\n- **Extension bag**: `attrs JSONB NOT NULL DEFAULT '{}'` on most entity tables, for additive fields without migrations\\n- **Soft-delete-safe uniqueness**: unique indexes are partial, e.g. `ux_party_ident_live ON core.t_party_ident (kind_cd, value_txt) WHERE deleted_at IS NULL`\\n- **Deterministic ordering**: tables where \\\"latest row\\\" matters (`t_consent`, `t_check`) carry a `seq_n BIGSERIAL` column. `created_at` defaults to `NOW()`, which is the *transaction start time*, so rows written in the same transaction tie \u2014 `seq_n` breaks the tie deterministically\\n- **Append-only immutability**: enforced twice \u2014 `REVOKE UPDATE, DELETE ON ... FROM app_rt` (privilege) plus a `BEFORE UPDATE OR DELETE` trigger calling `core.tg_no_touch()` (cannot be bypassed by the table owner)\\n- **`updated_at` maintenance**: all mutable tables get a `BEFORE UPDATE` trigger calling `core.tg_touch()`; these are generated in a `DO` block in migration 002\\n\\n## Core data model\\n\\nThe domain model centres on `t_party` \u2014 every organisation and person in the system. Physical names are generic; the logical names below come from `meta.map_dict`.\\n\\n```mermaid\\ngraph LR\\n    Party[\\\"t_partyparty \u2014 org or person\\\"]\\n    Ident[\\\"t_party_identABN \u00b7 email \u00b7 licence \u00b7 KYC subject\\\"]\\n    Auth[\\\"t_actor + t_grantlogin principal \u00b7 membership\\\"]\\n    Rel[\\\"t_relemployment \u00b7 network\\\"]\\n    Node[\\\"t_nodeproject \u00b7 site\\\"]\\n    Link[\\\"t_linkengagement\\\"]\\n    Req[\\\"t_requestinvitation \u00b7 top-up\\\"]\\n    Art[\\\"t_artifactevidence document\\\"]\\n    Check[\\\"t_checkverification outcome\\\"]\\n\\n    Party --&gt; Ident\\n    Party --&gt; Auth\\n    Party --&gt; Rel\\n    Rel --&gt; Party\\n    Node --&gt; Link\\n    Link --&gt; Party\\n    Req --&gt; Link\\n    Party --&gt; Art\\n    Art --&gt; Check\\n```\\n\\n### Parties, identities, and access\\n\\n| Table | Logical name | Role |\\n|---|---|---|\\n| `core.t_party` | party | All organisations and people. `kind_cd`: `K01` principal contractor (Tier 1), `K02` trade org, `K03` individual. `status_cd` is the Assureworx Verified lifecycle. Carries `public_ref` (the public AWX ID) and `pass_ver` (digital pass generation counter) |\\n| `core.t_party_ident` | party identifier | External identifiers: `D01` ABN, `D02` ACN, `D03` login email, `D04` KYC subject (`provider:subject`), `D05` trade licence (`jurisdiction:number`). Unique among live rows per kind |\\n| `core.t_actor` | user | Login principal; `subject_txt` is the IdP `sub` claim. `session_ver` is bumped on logout/admin disable to invalidate outstanding JWTs |\\n| `core.t_grant` | membership | Role of an actor within an org party (RBAC). Roles `R01`\u2013`R05` map platform admin, Tier 1 admin/user, trade admin/individual |\\n| `core.t_rel` | party relationship | Typed edges between parties: `L01` employment, `L02` network membership, `L03` direct person\u2192Tier 1, `L04` subcontract |\\n\\n### Projects, sites, and engagements\\n\\n| Table | Logical name | Role |\\n|---|---|---|\\n| `core.t_node` | project / site | Owner-scoped hierarchy: `N01` = project (no parent), `N02` = site (parent = project). Two levels today, extensible |\\n| `core.t_link` | engagement | A party's participation under an owner, optionally at a node. `state_cd` (`J01` pending, `J02` ready, `J03` blocked, `J04` action required) plus stored gap counts (`gap_hard_n`, `gap_soft_n`) and `evaluated_at` |\\n| `core.t_link_tag` | engagement classification | The classifications a principal engaged a party **for on a specific placement** \u2014 the correct audience test for site requirements (distinct from `t_party_tag`, which is what the trade says it does) |\\n\\n### Invitations and onboarding\\n\\n| Table | Logical name | Role |\\n|---|---|---|\\n| `core.t_request` | onboarding request | Invitation chain: `Q01` direct company, `Q02` direct individual, `Q03` sub-request, `Q04` connection to existing party (no wizard), `Q05` top-up against an existing placement. Snapshot of verification requirements in `verification_keys` |\\n| `core.t_seat` | account invite | PC bootstrap (`I01`), PC colleague seat (`I02`), trade employee (`I03`). Carries `attrs` for deferred site placement |\\n| `core.t_vfy_tpl` | verification template | Reusable per-org default for invitation checklists; each request snapshots it immutably |\\n\\n### Compliance evidence and verification\\n\\n| Table | Logical name | Role |\\n|---|---|---|\\n| `core.t_artifact_class` | document type | Catalog of required document types (`A001`\u2013`A116`). `universal_flag` marks docs required for all trades; `block_cd`/`method_cd`/`alert_days` drive enforcement and expiry alerts |\\n| `core.t_tag`, `core.t_tag_class`, `core.t_party_tag` | trade classification | The 17 trade categories (seeded as `G01`\u2013`G17`, names in `meta.code_dict`), required doc classes per category, and a party's selected categories |\\n| `core.t_artifact` | document | Uploaded evidence. `store_key` is a blob path (never a signed URL), `hash_txt` is SHA-256. One live artifact per `(party_id, class_cd)` |\\n| `core.t_check` | verification | Verification events: `C01` registry, `C02` identity/KYC, `C03` manual review, `C04` licence API. Stores provider reference and outcome only \u2014 never raw media. `doc_expires_on` drives re-verification; `seq_n` breaks same-transaction ties |\\n| `core.t_cycle` | verification case | The chargeable unit for paid verification (migration 023). One live cycle per party; status `VC1`\u2013`VC5`, eligibility `EL1`\u2013`EL6`, funding snapshot, checkout reservation, optimistic locking via `ver_n` |\\n| `core.t_consent` | consent event | Append-only consent trail (`N01` identity, `N02` network disclosure). Withdrawal is a new `Y02` row, never an update |\\n| `core.t_meter` | provider meter | Monthly hard cap on billable third-party calls, enforced with a conditional `UPDATE ... WHERE used_n &lt; cap_n` |\\n| `core.t_req_set`, `core.t_req_item` | requirement sets/items | Engagement-scoped requirements (ADR-0018): layers 4\u20135 of the compliance model. Bind to an org, node (project/site), or single placement |\\n\\n### Training, operations, and audit\\n\\n| Table | Logical name | Role |\\n|---|---|---|\\n| `core.t_course`, `core.t_task`, `core.t_result` | training module / assignment / completion | Owner-authored content, scoped assignment, immutable completion records (`t_result` is append-only) |\\n| `core.t_case`, `core.t_case_file` | incident / attachment | Owner-private operational records; `note_txt` is internal and never shared with trades |\\n| `core.t_msg` | notification | In-app (`Z01`) and email (`Z02`) messages |\\n| `core.t_ledger` | subscription | Commercial standing; `PL1` trade annual, `PL2` complimentary |\\n| `core.t_event` | audit log | Append-only activity record with correlation ID, user-agent, and client IP. Never updated or deleted |\\n| `core.t_idem` | idempotency key | API idempotency keys with expiry |\\n| `core.t_pass_dl` | wallet download | Ephemeral one-shot Apple Wallet `.pkpass` tokens (BYTEA payload, ~15 min TTL) |\\n| `core.t_sr`, `core.t_sr_note` | support ticket / note | In-app helpdesk with SLA clocks and internal (platform-only) notes |\\n| `core.t_email_change` | email change | Dual-verify login email change requests (ADR-0013) |\\n\\n## Security model\\n\\n### Runtime role and grants\\n\\nAll application connections use role `app_rt`. Migration 002 creates it and grants:\\n\\n- `USAGE ON SCHEMA core`\\n- `SELECT, INSERT, UPDATE, DELETE` on all `core` tables (via `GRANT ... ON ALL TABLES` and `ALTER DEFAULT PRIVILEGES`)\\n- **Revoked** on immutable tables: `core.t_event` and `core.t_result` have `UPDATE` and `DELETE` revoked\\n\\nLater migrations grant incrementally: new tables such as `t_seat`, `t_sr`, `t_consent`, `t_req_set`, and `t_pass_dl` get explicit `GRANT`s in their migration. Sequences used by the application (`seq_sr_ref`, `t_consent_seq_n_seq`, `t_check_seq_n_seq`) receive `USAGE, SELECT`.\\n\\n### Session context GUCs\\n\\nRLS policies read three session-level settings that the API sets per request/transaction:\\n\\n- `app.scope` \u2014 the owning organisation's party UUID (the \\\"tenant\\\")\\n- `app.actor` \u2014 the current actor UUID\\n- `app.bypass` \u2014 `'on'` for platform-admin paths only, always audited\\n\\nThe repository layer sets these via `withScope`, `withActor`, `withBypass`, and `withTenant` wrappers.\\n\\n### Helper functions\\n\\nRLS policies use SECURITY DEFINER functions to avoid RLS recursion on the tables they query:\\n\\n| Function | Purpose |\\n|---|---|\\n| `core.fn_bypass()` | Reads `app.bypass` |\\n| `core.fn_scope()` | Reads `app.scope` as `uuid` |\\n| `core.fn_actor()` | Reads `app.actor` as `uuid` |\\n| `core.fn_in_network(party, scope)` | True when a party is in the Tier 1 network: direct `L02`/`L03` edge to scope, or `L01` employment under a network trade org. Used by party, artifact, check, ledger, and tag policies |\\n| `core.fn_employs(org, person)` | True when the person is a live `L01` employee of the trade org. Used by trade-side engagement visibility |\\n| `core.fn_node_engages(node, scope)` | True when the scope (or one of its employees) is engaged at the node or a site beneath it. Used by `t_node` read policy |\\n\\nAll helpers `REVOKE ALL ... FROM PUBLIC` and `GRANT EXECUTE ... TO app_rt`.\\n\\n### Policies by table group\\n\\n- **Owner-scoped tables** (`t_node`, `t_link`, `t_course`, `t_task`, `t_case`, `t_case_file`, `t_req_set`, `t_req_item`, `t_seat`, `t_vfy_tpl`): owner can read/write; engaged trade parties can read where applicable (migration 015 added the trade-side read arm on `t_link` and `t_node`)\\n- **Evidence tables** (`t_artifact`, `t_party_ident`, `t_check`, `t_ledger`, `t_party_tag`): the party itself, its actors, and network members can read; only the party (or bypass) can write\\n- **Party visibility** (`t_party`, `t_rel`, `t_grant`): self, grant orgs, network, and (for `t_party`) the reverse network arm so a trade can read the Tier 1 it belongs to\\n- **Personal data** (`t_consent`): party and its actors only \u2014 no network arm, by design\\n- **Support tickets** (`t_sr`, `t_sr_note`): requester or bypass, checked via `app.actor`\\n- **Ephemeral wallet downloads** (`t_pass_dl`): bypass only \u2014 token entropy is the authz boundary\\n\\nPolicies prefer cheap equality on indexed columns before invoking the SECURITY DEFINER network checks.\\n\\n### FORCE RLS\\n\\nMigrations 008, 009, 011, 014, 015, 017, and 022 enable and **force** RLS on sensitive tables, so even a connection with table-owner privileges cannot bypass policies. The append-only tables additionally have `tg_no_touch` triggers, so immutability holds at the SQL level regardless of grants.\\n\\n## Migration history\\n\\n| Migration | Focus |\\n|---|---|\\n| `001_core_orgs_and_people` | Prototype schema with domain-revealing names (`tier1_organisations`, `trade_organisations`, `individuals`, `projects`, `sites`, `engagements`) |\\n| `002_enterprise_core` | **Full rewrite.** Drops all 001 tables, creates `core`/`meta` schemas, `app_rt` role, the entire party-model schema, seed catalogs, and the meta dictionary |\\n| `003_hardening` | Unique live artifact per party+class; `t_pass_dl` wallet download store |\\n| `004_account_invites` | `t_seat` invite tokens (PC bootstrap, PC seats, trade employees) |\\n| `005_support_requests` | In-app support desk: `t_sr`, `t_sr_note`, `seq_sr_ref` |\\n| `006_identity_portability` | `public_ref` AWX ID generation + backfill, `D04` KYC subject identifier, `Q04` connection requests |\\n| `007_email_change` | Dual-verify email change workflow |\\n| `008_security_hardening` | `t_actor.session_ver` session revocation; `FORCE RLS` on scoped tables |\\n| `009_rls_identity_evidence` | SECURITY DEFINER helpers; RLS on `t_party`, `t_party_ident`, `t_grant`, `t_rel`, `t_artifact` |\\n| `010_sync_link_readiness` | Backfill `t_link.state_cd` from `t_party.status_cd` (ongoing sync lives in `evaluateAndPersistStatus \u2192 syncLinkReadiness`) |\\n| `011_identity_consent_and_meter` | `t_consent` (append-only), `t_meter` (provider spend cap), `t_check.doc_expires_on`/`reason_cd` |\\n| `012_check_sequence` | `t_check.seq_n` for deterministic \\\"latest check\\\" ordering |\\n| `013_check_contact_email` | `t_check.contact_email_txt` for reconciliation |\\n| `014_rls_remaining_tables` | RLS on `t_check`, `t_seat`, `t_msg`, `t_ledger`, `t_party_tag`, `t_pass_dl` |\\n| `015_trade_engagement_visibility` | `fn_employs`/`fn_node_engages`; trade-side read arm on `t_link`/`t_node`; reverse network arm on `t_party` |\\n| `016_audit_enrichment` | `t_event.corr_id`, `ua_txt`; timeline/export indexes |\\n| `017_engagement_requirements` | `t_req_set`/`t_req_item`/`t_link_tag`; `t_link` gap columns; `Q05` top-ups; `D05` licence identifier; `t_party.pass_ver` |\\n| `018_meta_pass_dl` | Close meta dictionary gap for `t_pass_dl` |\\n| `019_meta_read_via_definer` | `fn_map_dict()`/`fn_schema_migrations()` so the platform console ERD can read the dictionary without opening `meta` |\\n| `020_perf_indexes` | Login, webhook, review-queue, and list-query indexes |\\n| `021_party_locality_and_seat_attrs` | Home locality/postcode/state on `t_party`; extension bag on `t_seat` |\\n| `022_invitation_verification_templates` | `t_vfy_tpl` templates; immutable `verification_keys` snapshot on `t_request` |\\n| `023_verification_cases` | `t_cycle` \u2014 the chargeable verification case, with funding and reservation state. Forward-references order tables planned for migration 024 |\\n\\nMigrations are **re-runnable by convention**: `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `DO` blocks that check for the `app_rt` role before granting, `ON CONFLICT DO NOTHING/UDPATE` for dictionary and seed inserts, and `DROP POLICY IF EXISTS` before re-creating policies.\\n\\n## Integration with the application code\\n\\nThe schema connects to the codebase through a small number of contracts:\\n\\n- **Migration runner** reads `schema_migrations` (in `public`) to track applied versions; each migration inserts its version at the end with `ON CONFLICT (version) DO NOTHING`.\\n- **Repository layer** (`shared/services/*`) does the primary data-shaping and access filtering. RLS is the second line of defence, keyed on the GUCs set by `withScope` / `withActor` / `withBypass` / `withTenant`.\\n- **Readiness computation**: `evaluateAndPersistStatus` is the single writer of `t_party.status_cd`; it calls `syncLinkReadiness` to keep `t_link.state_cd` and the gap counters current. Migration 010 was the one-time backfill of that logic.\\n- **Evidence flow**: services upload blobs and store paths in `t_artifact.store_key`; verification providers are referenced in `t_check.provider_ref_txt` with outcomes in `result` (never raw media). `fn_map_dict()` / `fn_schema_migrations()` power the platform console ERD.\\n- **Dictionary discipline**: any new table or code value must be registered in `meta.map_dict` / `meta.code_dict` in the same migration (ADR-0007) \u2014 the physical schema is explicitly *not* self-documenting.\\n\\n## Adding or modifying a migration\\n\\nWhen adding a new migration:\\n\\n1. **Follow the naming and type conventions**: UUID PKs, `deleted_at`, `*_cd`/`*_on`/`*_at` suffixes, `attrs JSONB` where extension is expected.\\n2. **Make it re-runnable**: `IF NOT EXISTS` on DDL, `DO` blocks for conditional grants, `ON CONFLICT` for inserts.\\n3. **Register tables in `meta.map_dict`** and new code values in `meta.code_dict` \u2014 the dictionary update should be part of the same migration (`ON CONFLICT (phys_table) DO UPDATE` keeps it authoritative).\\n4. **Grant to `app_rt` explicitly** \u2014 `ALTER DEFAULT PRIVILEGES` covers tables created by the migration role, but explicit grants are the belt-and-braces pattern used throughout.\\n5. **Add RLS immediately for tenant-scoped or personal data**: `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` + `CREATE POLICY` (after `DROP POLICY IF EXISTS`). Prefer cheap equality on indexed columns before SECURITY DEFINER network checks.\\n6. **For append-only tables**: `REVOKE UPDATE, DELETE FROM app_rt` plus a `BEFORE UPDATE OR DELETE` trigger on `core.tg_no_touch()`, and grant only `SELECT, INSERT`.\\n7. **For \\\"latest row\\\" semantics**: add `seq_n BIGSERIAL` and order by `(..., created_at DESC, seq_n DESC)` \u2014 never `created_at` alone, because `NOW()` ties within a transaction.\",\"database-migrations-schema-shared-types\":\"# Database Migrations &amp; Schema \u2014 shared-types\\n\\n# shared-types \u2014 API Contract Vocabulary\\n\\n## Purpose\\n\\n`packages/shared-types/src/index.ts` is the single source of truth for the API contracts shared by the frontend and backend. It defines the **logical vocabulary** of the platform: every status, role, row shape, and request/response body that crosses the HTTP boundary.\\n\\nThe module deliberately contains **no physical database codes**. A status like `\\\"verified\\\"` or a party kind like `\\\"trade_company\\\"` is what the API speaks; the repository layer translates these to and from physical codes (`S01`\u2013`S04`, `K01`\u2013`K03`, etc.) per `wiki/architecture/schema-mapping.md`.\\n\\nThis is a compile-time-only module: it exports TypeScript types and a single runtime constant (`AU_REGIONS`). It has no dependencies, no internal calls, and no execution flows.\\n\\n## Architecture Role\\n\\n```mermaid\\ngraph LR\\n    FE[Frontend] --&gt;|imports &amp; renders| ST[shared-typeslogical contracts]\\n    API[API routes / services] --&gt;|produce &amp; validate against| ST\\n    ST --&gt;|logical vocabulary only| REPO[Repository layer]\\n    REPO --&gt;|translates to S01 / K02 / R01\u2026| DB[(PostgreSQL)]\\n```\\n\\nThe frontend never sees physical status codes; the repository layer never exposes them upward. `shared-types` is the boundary that keeps those two worlds from leaking into each other.\\n\\n## Design Principles\\n\\n### 1. Logical vocabulary, physical storage\\n\\nEvery enum in this module maps to a physical code column somewhere in the database. The mapping is the repository layer's job, not the API's. When you add a status, you add the logical value here, the physical code in the migration, and the translation in the repository.\\n\\n### 2. Derived state is never stored\\n\\nSeveral types are **computed on read** from more fundamental facts, and the comments call this out explicitly:\\n\\n| Type | Stored? | Derived from |\\n|---|---|---|\\n| `IdentityStatus` | Never | check outcome + consent + today |\\n| `ReadinessState` | Never | union of applicable requirements |\\n| `EngagementReadiness` | Never | union of requirements, not copied portable status |\\n| `EvidenceStatus` | Never | `t_artifact` + expiry |\\n| `Engagement.state` | Never | project lifecycle + placement liveness |\\n| `FundingLabel` / `FundingView` | Never | server-side derivation from case + order + allocation |\\n\\nThis is what lets a check be a **true historical fact** (`IdentityCheckState` is immutable and never decays) while simultaneously no longer being *relied upon* (`IdentityStatus` can be `\\\"expired\\\"` or `\\\"consent_lapsed\\\"` without the underlying fact changing).\\n\\n### 3. Snapshots over references\\n\\nInvitations carry immutable copies of what was asked, not live references:\\n\\n- `RequestRow.verificationRequirementKeys` \u2014 what the sender required of this recipient, frozen at send time.\\n- `VerificationRequirement.key` \u2014 a stable snapshot key (`artifact:` or a system check name).\\n- `EngagementRequirement.key` \u2014 stable across sources so a union can dedupe (`doc:A101`, `course:`).\\n\\nThis means a recipient's obligations don't change if the sender later edits their template.\\n\\n### 4. View separation for money\\n\\nPayments types are split by audience, and the split is enforced by shape:\\n\\n- **Beneficiary** sees `FundingView` \u2014 a label, a next action, a funder name. **No amount, no payer identity.**\\n- **Payer** sees `OrderView` \u2014 full money fields (integer AUD cents), items, allocation status.\\n- **Operator** sees `AdminOrderView` / `AdminRefundView` \u2014 adds payer identity and provider references (`sessionRef`, `intentRef`, `providerRef`) that neither payer nor beneficiary has any use for.\\n\\nThe comment on `FundingView` states the rule: *\\\"Carries no amount and no payer person.\\\"*\\n\\n### 5. Server-authored authority\\n\\nTypes like `WalletPass` are explicitly server-authored. The browser can render them but never construct them. `qrPayload` exists because the gate needs *one contract* for QR encoding; `passVer` is bumped when the pass face goes stale.\\n\\n## Type Catalogue by Domain\\n\\n### API Envelope\\n\\nEvery API response is one of:\\n\\n- `ApiSuccess` \u2014 `success: true` with `data` and optional pagination `meta`.\\n- `ApiError` \u2014 `success: false` with a stable `code`, `message`, and optional `details`.\\n- `ApiEnvelope` \u2014 the union; route handlers return this.\\n\\n### Logical Enums and Physical Mappings\\n\\n| Type | Logical values | Physical mapping |\\n|---|---|---|\\n| `VerifiedStatus` | `pending` / `verified` / `action_required` / `suspended` | `t_party.status_cd` S01\u2013S04 |\\n| `AppRole` | `platform_admin` / `tier1_admin` / `tier1_user` / `trade_company_admin` / `trade_individual` | `t_grant.role_cd` R01\u2013R05 |\\n| `PartyKind` | `principal_contractor` / `trade_company` / `individual` | `t_party.kind_cd` K01\u2013K03 |\\n| `OrgClass` | `company` / `sole_trader` / `trust` / `partnership` | E01\u2013E04 |\\n| `RequestKind` | `direct_company` / `direct_individual` / `sub_request` / `connection` / `top_up` | Q01\u2013Q05 |\\n| `RequestStatus` | `pending` / `accepted` / `completed` / `expired` / `declined` | U01\u2013U05 |\\n| `ReadinessState` | `pending` / `ready` / `blocked` / `action_required` | `t_link.state_cd` J01\u2013J04 |\\n| `NodeKind` | `project` / `site` | N01/N02 |\\n| `RequirementBlock` | `hard` / `soft` / `advisory` | `t_req_item.block_cd` B01\u2013B03 |\\n\\nThe `RequestKind` values carry real semantic weight:\\n\\n- `connection` \u2014 the invited party already exists on the platform; they share their portable profile instead of running the wizard (ADR-0012).\\n- `top_up` \u2014 the party is already engaged; only the requirements missing for one placement are asked for (ADR-0018).\\n- The rest run the standard onboarding wizard.\\n\\n### Identity Verification (ADR-0014 / ADR-0015 / ADR-0026)\\n\\nThe identity model separates **historical fact** from **current reliance**:\\n\\n- `IdentityCheckState` \u2014 the immutable outcome of one check: `verified` / `unverified` / `check_failed`. Per the source comment, `check_failed` is *never* a negative finding about the person.\\n- `IdentityStatus` \u2014 derived currency: `not_checked` \u2192 `in_progress` \u2192 `current` / `expiring_soon` / `expired` / `superseded` / `did_not_pass` / `could_not_complete`, plus `consent_lapsed` \u2014 distinct from `expired` because the document is still valid but the authority to rely on it has lapsed (ADR-0015 \u00a73).\\n- `IdentityStepKey` / `IdentityStepState` \u2014 the three steps (`document` \u2192 `liveness` \u2192 `face_match`) and their outcomes.\\n- `IdentityDiagnostics` \u2014 non-identifying telemetry (ADR-0026): per-step outcomes, capture quality scores, match/liveness confidence, warnings, environment (`live`/`sandbox`), and document type/country. Explicitly **no** document numbers, names, DOB, MRZ data, images, or media URLs.\\n- `IdentitySummary` \u2014 what the UI actually renders: status, `checkedAt`, `documentExpiresOn`, consent, `canStartCheck` with a `blockedReason`, diagnostics, and `sessionExpiresAt` so the UI can say \\\"finish by Sunday\\\".\\n\\n### Engagement Requirements &amp; Readiness (ADR-0005 layers 4\u20135, ADR-0018)\\n\\n- `RequirementSource` \u2014 where a requirement came from: `portable` travels with the trade; `organisation` / `project` / `site` / `placement` belong to one principal and apply only inside that engagement.\\n- `EngagementRequirement` \u2014 one thing a trade must satisfy, with a stable `key` for dedupe, a `block` severity, an optional `appliesTag` for trade-type scoping, and a `met`/`status` pair.\\n- `EngagementReadiness` \u2014 computed from the **union** of every applicable requirement. Carries `engagedTags`, the full requirement list, and `gaps` (the subset not yet satisfied \u2014 the top-up ask), with hard/soft gap counts.\\n\\n### Directory, Profiles &amp; Network Lineage\\n\\n- `DirectoryLookupResult` \u2014 the pre-invite directory check. Deliberately thin: existence, portable status, match anchor, and confidence only. *Never* the underlying profile \u2014 the principal has no relationship with this party yet.\\n- `DirectoryRow` \u2014 a searchable directory entry: public ref, name, kind, classifications, document/trade counts, and `actionReason` (the one non-verified reason worth surfacing on a card).\\n- `CompanyProfile` / `IndividualProfile` \u2014 full profile views. Note `coreChecks` comes from `RequirementChecklist[\\\"coreChecks\\\"]` (not upload rows), and `EvidenceRow` carries `pcReview` \u2014 a per-principal-contractor acknowledgement that never alters portable `status`.\\n- `NetworkLineage` \u2014 the Tier 1 \u2192 companies \u2192 sub-companies \u2192 individuals tree, with `LineageCompany`, `LineageIndividual`, `LineageInvite`, and totals.\\n- `PcArtifactReview` \u2014 a PC's accept/decline of a platform-verified artifact. The comment is explicit: it does **not** alter the portable status.\\n\\n### Requests &amp; Onboarding\\n\\n- `RequestRow` \u2014 the full lifecycle of an onboarding invite: kind, status, target, sender/root org, immutable `verificationRequirementKeys`, optional `nodeId`/`nodeName` binding, and sender-only `token` / `inviteUrl`.\\n- `SeatInviteRow` / `SeatInviteKind` \u2014 PC and employee seat invites. Employee seats carry a snapshot of employer-selected checks (`verificationRequirementKeys`, absent for pre-migration-034 seats).\\n- `CreateRequestBody` \u2014 direct invite creation, with optional `phone` for transactional SMS and optional per-invite `verificationRequirementKeys` (server snapshots the org default when omitted).\\n- `CreateProjectInviteBody` / `ProjectInviteTradeBundle` \u2014 bulk PC invites from a project/site: company-level checks plus trade-type-scoped bundles applied per `tagCode`.\\n- `OnboardingContext` \u2014 what the wizard needs to render, including `existingProfile`. The `credentialPending` flag decides whether to send the person to Entra **sign-up** or **sign-in** \u2014 offering sign-in for an account that was never created fails with \\\"we couldn't find an account\\\".\\n- `AcceptInviteBody` \u2014 wizard completion payload; requires suburb *or* postcode for individuals.\\n- `AbnVerifyResult` \u2014 ABR lookup with a useful nuance: `valid` can be true for cancelled/historical ABNs, but compliance only passes when `active` is true.\\n- `RequirementChecklist` \u2014 core checks + evidence + `readyToSubmit`, plus `tradeTypesSelected` so the wizard blocks submission when the invitation asked for trade types and none are saved.\\n\\n### Dashboard &amp; Reporting\\n\\n- `DashboardSummary` \u2014 the principal dashboard aggregate: network splits (`StatusSplit`), portfolio totals, expiring evidence, attention buckets, recent activity, trade-type counts, and top companies by assignment volume.\\n- `EvidenceAttentionItem` \u2014 one row in the \\\"needs attention\\\" panel, bucketed `expiring` / `missing` / `failed`.\\n- `AuditEventItem` / `AuditEventPage` \u2014 full audit events with keyset pagination: pass `nextBefore` (ISO timestamp of the last item) to fetch the next page.\\n\\n### Projects, Sites &amp; Engagements\\n\\n- `ProjectRow` / `ProjectDetail` / `SiteSummary` / `SiteDetail` \u2014 project/site hierarchy with readiness counts. `SiteSummary` carries persisted geocoding (`lat`/`lng`/`geoLabel` from `t_node.attrs.geo`).\\n- `SiteAssignment` \u2014 one trade placed at a site, with readiness.\\n- `Engagement` / `MyEngagements` \u2014 a trade's view of its placements. There is **no completion state stored**; `state` is derived from the project lifecycle and whether the placement is still live. `suppliedName`/`suppliedPartyId` identify the employee when the placement isn't the profile itself.\\n\\n### Trade Self-Service\\n\\n- `MyProfile` \u2014 the trade's own profile: party info, `RequirementChecklist`, networks, `tradeTypes` (with per-classification compliance splits), `workforce` (company only), and `availability` (company only, AU-region opt-in).\\n- `TeamMember` \u2014 employee roster rows with document counts, active site engagements, and the withdrawal flags. `verificationFunded` / `verificationClaimed` / `verificationFundedBy` tell an employer whether a seat is already bought \u2014 but the comment notes checkout **recomputes** this and is the only authority.\\n- `TradeAvailability` / `UpdateAvailabilityBody` \u2014 open-to-new-business setting by AU region.\\n- `AU_REGIONS` \u2014 the only runtime export: `[\\\"NSW\\\", \\\"VIC\\\", \\\"QLD\\\", \\\"WA\\\", \\\"SA\\\", \\\"TAS\\\", \\\"ACT\\\", \\\"NT\\\"]` as a const tuple, with the `AuRegion` derived type.\\n\\n### Document Extraction (ADR-0019)\\n\\n- `ExtractionPreview` \u2014 response from `POST /artifacts/extract`; nothing is persisted by that call. Proposes, never verifies.\\n- `ExtractionVerdict` \u2014 `clean` / `check` / `hold`, derived from the strongest finding.\\n- `ExtractionReadValue` \u2014 one read field with 0\u20131 confidence.\\n- `ExtractionSummary` \u2014 the same shape attached to a document in the platform review queue.\\n\\n### Wallet Passes\\n\\n- `WalletPass` \u2014 the in-app credential. Server-authored from party data. `validity` is portable Verified status **only**; site readiness is never claimed on the face \u2014 the gate resolves it live against the site. `qrPayload` is pipe-delimited and server-encoded so the gate has one contract. `passVer` bumps when the face must be treated as stale.\\n- `WalletGateResolve` \u2014 live gate lookup: the QR is a pointer, not a readiness stamp.\\n- `WalletPassIssueBody` / `WalletPassIssueResult` \u2014 issuance endpoints returning a `.pkpass` download path and/or a Google Wallet save URL.\\n\\n### Paid Verification (migrations 023\u2013025)\\n\\nThe payments domain is the most carefully segmented part of the module:\\n\\n- **Lifecycle**: `VerificationCaseStatus` (`open` \u2192 `funded` \u2192 `in_verification` \u2192 `completed` / `closed`), `OrderStatus` (12 states), `AllocationStatus`, `AttemptStatus`, `ProviderEventStatus`, `RefundStatus`, `DisputeStatus`.\\n- **Eligibility**: `EligibilityState` \u2014 the server-computed position on whether paid verification may be sold. `eligible_for_payment` is explicitly *not* `verified` and never implies it. Each position carries machine-readable `EligibilityReason`s.\\n- **Beneficiary view**: `FundingView` \u2014 label, next action, funder name, order number for quoting to support. No money, no payer.\\n- **Payer view**: `OrderView` / `OrderItemView` / `RefundView` \u2014 full amounts in integer AUD cents. `RefundView.amount` is null until approved because the payer never chooses it: an approved refund is a whole number of verifications, derived from the order.\\n- **Operator view**: `AdminOrderView` / `AdminRefundView` / `PaymentInboxProblem` / `PaymentSweepResult` \u2014 adds payer identity, provider refs, reconcile attempts, and `nextTryAt` (the rising-attempts count is the signal that a refund needs a person).\\n- **Config/status**: `PaymentsStatusView` and `PaymentsConfigView` \u2014 administrator-readable payment health. Never expose keys or provider account references; `detail` names env vars and source files because that's what the person fixing it needs.\\n\\n### Support Desk\\n\\n- `SupportTicket` \u2014 in-app tickets with SLA fields (`slaResponseDueAt`, `slaResolveDueAt`) and derived `responseSla` / `resolveSla` states (`ok` / `due_soon` / `breached` / `met`).\\n- `SupportMonitor` \u2014 console aggregates: open, in progress, waiting, breached, due soon, resolved today.\\n\\n### Schema ERD (Platform Admin)\\n\\n- `SchemaErdModel` \u2014 a live `information_schema` snapshot for the ERD studio: tables (with physical/logical names and domain lanes), columns, foreign-key edges, and migrations included. `SchemaDomain` groups tables into `identity` / `network` / `work` / `evidence` / `training` / `ops` / `commercial` / `system`.\\n\\n## Working With This Module\\n\\n**Adding a new endpoint.** Define the request and response types here first. Both the route handler (which produces them) and the frontend (which consumes them) compile against the same shapes, so a contract change breaks the build on both sides at once.\\n\\n**Adding a status or enum value.** Add the logical value here, the physical code in the migration, and the translation in the repository layer. The UI and API never see the physical code. Keep the comment that names the physical mapping (`t_party.status_cd S01\u2013S04`, etc.) up to date \u2014 it is the pointer to `schema-mapping.md`.\\n\\n**Never store derived types.** If a value is computed on read (readiness, identity currency, funding label, engagement state), it does not belong in a migration. Add it to the derived-type family here and compute it in the service layer.\\n\\n**Respect the view boundaries.** Money shapes are split by audience. If you need a new field on a payments type, decide which view it belongs to: the beneficiary (`FundingView`), the payer (`OrderView`), or the operator (`AdminOrderView`). Do not add payer money fields to a beneficiary-facing shape.\\n\\n**Keep the envelope.** All route responses should be assignable to `ApiEnvelope`. Success carries data; errors carry a stable machine-readable `code`, not prose.\\n\\n## References\\n\\n- `wiki/architecture/schema-mapping.md` \u2014 physical \u2194 logical code mappings\\n- ADR-0005 \u2014 engagement requirement layers 4\u20135\\n- ADR-0012 \u2014 connection requests and the portability path\\n- ADR-0014 / ADR-0015 \u2014 identity verification outcome vs. reliance\\n- ADR-0018 \u2014 top-up requests and trade-type scoped requirements\\n- ADR-0019 \u2014 document extraction (proposes, never verifies)\\n- ADR-0026 \u2014 non-identifying identity diagnostics\",\"database-migrations-schema-src\":\"# Database Migrations &amp; Schema \u2014 src\\n\\n# Database Migrations &amp; Schema \u2014 src\\n\\n## Overview\\n\\n`src/shared/schema/tables.ts` defines the single source of truth for physical database table names used by the backend. It exports `T`, a frozen, const-asserted mapping from **logical table names** used in business code to **obfuscated physical names** in the PostgreSQL `core` schema.\\n\\nThe module has no runtime logic, no imports, and no exports other than `T`. It exists purely as an indirection layer between application code and the database schema.\\n\\n## Purpose\\n\\nThe backend does not hardcode physical table names anywhere in business logic. Instead, all queries reference keys on `T`. This provides:\\n\\n- **Obfuscation** \u2014 physical names like `core.t_party` carry no business meaning; the logical meaning is documented in `wiki/architecture/schema-mapping.md`.\\n- **Single point of change** \u2014 if a physical table is renamed, only this file changes; all call sites keep working.\\n- **Compile-time safety** \u2014 `as const` makes the keys and values literal types, so a typo like `T.parties` is a TypeScript error, and the resolved name is a literal string type that can be checked.\\n- **Migration traceability** \u2014 comments in this file link table groups to the migrations that introduced them (e.g., `023\u2013025` for paid verification, `033` for artifact acknowledgment).\\n\\n## Key Export: `T`\\n\\n`T` is a plain object declared with `as const`. Each key is a stable logical name; each value is a string of the form `core.`.\\n\\n```ts\\nimport { T } from \\\"../shared/schema/tables\\\";\\n\\n// Logical key resolves to the physical name at compile time\\nconst tableName: \\\"core.t_party\\\" = T.party;\\n\\n// Used directly in queries\\nconst rows = await db.raw(`SELECT * FROM \\\"${T.party}\\\" WHERE id = ?`, [id]);\\n```\\n\\nBecause the values are literal string types, the database layer can validate that a `T` value is being passed where a table name is expected.\\n\\n## Naming Conventions\\n\\n| Element | Convention | Example |\\n|---|---|---|\\n| PostgreSQL schema | Always `core` | `core.t_party` |\\n| Physical table prefix | `t_` | `core.t_party_ident` |\\n| Logical key | camelCase, matches domain concept | `T.partyIdent` |\\n| Suffixes | `_tag`, `_item`, `_note`, `_delivery` describe relationship or role | `T.partyTag`, `T.srNote` |\\n\\n## Table Groups\\n\\nThe entries on `T` fall into several domain groups. Group boundaries are not enforced by the module \u2014 they are conventions that help when reviewing migrations and queries.\\n\\n### Identity &amp; Parties\\n\\n`party`, `partyIdent`, `actor`, `partyTag`, `consent`, `emailChange`, `passDl`, `passDevice`, `seat`\\n\\nCore party records, identity documents, device/pass credentials, and consent state.\\n\\n### Graph &amp; Relations\\n\\n`node`, `link`, `linkTag`, `rel`, `grant`\\n\\nGeneric graph storage plus the grant table for relational permissions.\\n\\n### Requests, Artifacts &amp; Verification\\n\\n`reqSet`, `reqItem`, `request`, `verifyTemplate`, `artifactClass`, `artifact`, `artifactAck`, `check`, `tag`, `tagClass`\\n\\nRequest sets/items, platform-verified artifacts, per-PC acknowledgment of artifacts (migration `033`), and the tag model.\\n\\n### Learning &amp; Assessment\\n\\n`course`, `task`, `result`, `case`, `caseFile`\\n\\nCourse and task data, result records, and case files.\\n\\n### Communications\\n\\n`msg`, `emailDelivery`, `sms`, `sr`, `srNote`\\n\\nOutbound messaging, email/SMS delivery records, and support requests.\\n\\n### Ledger, Events &amp; Idempotency\\n\\n`ledger`, `event`, `meter`, `idem`\\n\\nFinancial ledger entries, event log, metering counters, and idempotency keys.\\n\\n### Payments &amp; Billing (Migrations 023\u2013025)\\n\\n`cycle`, `order`, `orderItem`, `payTry`, `payEvt`, `finTxn`, `refund`, `dispute`\\n\\nBilling cycles, orders, payment attempts/events, financial transactions, refunds, and disputes. This group was added across migrations `023`\u2013`025`; the comments on `cycle` and `order` mark that origin.\\n\\n## Relationship to Migrations\\n\\nThis module is the **read-side view** of the migration history. Every migration that creates a physical table must add a corresponding key to `T` before business code can use it.\\n\\nThe typical flow:\\n\\n1. A migration SQL file creates `core.t_artifact_ack` (migration `033`).\\n2. `T.artifactAck` is added to `tables.ts` with a comment referencing the migration.\\n3. Business code references `T.artifactAck` from that point onward.\\n\\nBecause migrations run outside the application, there is no import or call relationship between this module and the migration files. The connection is purely documentary, via the comments.\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    A[\\\"Business codequeries by logical key\\\"] --&gt; B[\\\"T.artifactAck\\\"]\\n    B --&gt; C[\\\"tables.ts registrylogical \u2192 physical\\\"]\\n    C --&gt; D[\\\"core.t_artifact_ackPostgreSQL core schema\\\"]\\n    E[\\\"Migration 033creates table\\\"] -.-&gt; D\\n    E -.-&gt;|\\\"manual sync\\\"| C\\n```\\n\\nThe registry is the only place where the logical-to-physical mapping lives. Business code never sees the physical name except through `T`.\\n\\n## Contribution Notes\\n\\n- **Never** introduce a raw SQL string containing a `core.t_*` table name outside this file.\\n- When adding a new table via migration, add the `T` entry in the same change set, with a comment noting the migration number if the new table belongs to a migration group.\\n- When renaming a physical table, update only the value here; do not rename keys unless the logical domain concept itself changes.\\n- Keep the object flat \u2014 no nesting, no computed keys. The literal type safety of `as const` depends on the object shape being statically known.\",\"database-migrations-schema\":\"# Database Migrations &amp; Schema\\n\\n# Database Migrations &amp; Schema\\n\\nThe Database Migrations &amp; Schema module defines the platform's data vocabulary at three levels: what the API calls things, what application code names them, and what PostgreSQL actually stores. Its three sub-modules form the indirection layers that let HTTP contracts, business logic, and the physical database evolve independently.\\n\\n## Sub-modules\\n\\n- [src \u2014 table name indirection](src.md) \u2014 exports `T`, a frozen const-asserted mapping from logical table names to obfuscated physical names in the `core` schema. No runtime logic; it exists so no query in business code ever hardcodes a physical table name.\\n- [migrations](migrations.md) \u2014 the complete PostgreSQL schema for the `assureworx` database, spanning 23 migrations from the original prototype through the enterprise core rewrite, security hardening, and the engagement-requirements/verification-case models. Built on the generic party model, it uses obfuscated tables (`core.t_party`, `core.t_node`, `core.t_link`) and a `meta` schema as the in-DB dictionary of table, column, and code-value meaning.\\n- [shared-types](shared-types.md) \u2014 the compile-time API contract vocabulary: every status, role, row shape, and request/response body that crosses the HTTP boundary. It deliberately contains no physical database codes.\\n\\n## How the layers connect\\n\\nEach sub-module occupies one step on the path from API to disk:\\n\\n```mermaid\\nflowchart LR\\n    API[shared-typeslogical vocabulary\\\"verified\\\", \\\"trade_company\\\"] --&gt;|HTTP request/response| REPO[repository layer]\\n    REPO --&gt;|table names via T| MAP[src / Tlogical name \u2192 physical table]\\n    subgraph MIG[\\\"migrations \u2014 PostgreSQL schema\\\"]\\n        MAP --&gt; DB[(\\\"core schemat_party, t_node, t_link\\\")]\\n        DB --- META[meta schemadictionaryS01\u2013S04, K01\u2013K03]\\n    end\\n    REPO -.-&gt;|logical \u2194 physical codes| META\\n```\\n\\n- **shared-types** defines what the platform *speaks* \u2014 logical values like `\\\"verified\\\"` or `\\\"trade_company\\\"`, with no knowledge of the database.\\n- **src (`T`)** defines what code *names* \u2014 it maps logical table names to obfuscated physical tables in the `core` schema.\\n- **migrations** defines what PostgreSQL *stores* \u2014 the physical tables plus the physical codes whose meaning the `meta` schema catalogues.\\n\\nThe repository layer (outside this module) performs both translations at runtime: it consults `T` for table names and converts shared-types logical values to and from the migrations' physical codes.\\n\\n## Cross-module workflow\\n\\nA schema change moves through the layers in order:\\n\\n1. **migrations** alters the physical schema \u2014 a new status, for example, becomes a new code recorded in the `meta` dictionary.\\n2. **src** adds any new physical table to `T`, giving the repository a stable logical name to reference.\\n3. **shared-types** exposes the new capability to frontend and backend as part of the API vocabulary.\\n\\nBecause every boundary in this module is an indirection layer, tables can be renamed, obfuscated, or reorganised without changing a single API contract \u2014 and the API vocabulary can evolve without forcing database churn.\",\"development-tooling-scripts-backend-scripts\":\"# Development Tooling &amp; Scripts \u2014 backend-scripts\\n\\n# Development Tooling &amp; Scripts \u2014 backend-scripts\\n\\n## Overview\\n\\nThe `backend/scripts` directory contains the development tooling for the `@assureworx/backend` workspace. The scripts fall into two families:\\n\\n- **Probes** \u2014 verification entry points that exercise a subsystem the way a developer would, and print a human-readable pass/fail report. Some run entirely offline (`probe-extraction.ts`), some spend real money (`probe-cloud-read.ts`), and some drive a live running server (`probe-webhook.mjs`).\\n- **Dev utilities** \u2014 scripts that manipulate local data or external configuration directly, bypassing application-layer authorization. These are explicitly dev-only and carry sharp edges: `purge-party.mjs` hard-deletes database rows, `mint-invite.mjs` writes invitation rows that would normally require an authenticated API call.\\n\\nAll scripts that touch the database operate under the RLS bypass (`app.bypass`) that the runtime API would never set. They are tools for developers, not part of the application surface.\\n\\n## Script inventory\\n\\n| Script | Kind | Documented invocation | Purpose | Safety profile |\\n|---|---|---|---|---|\\n| `probe-extraction.ts` | Probe | `npm run probe:extraction --workspace @assureworx/backend` | Offline test of the document extraction pipeline using synthetic PDFs (ADR-0019) | Free; no DB, no network |\\n| `probe-cloud-read.ts` | Probe | `npm run probe:cloud-read --workspace @assureworx/backend` | Paid Azure AI Document Intelligence `prebuilt-read` probe (ADR-0020) | Real charge (~US$0.0045 for 3 cases); needs Azure + reachable DB |\\n| `probe-engagement-requirements.ts` | Probe | `npx tsx --env-file=.env scripts/probe-engagement-requirements.ts` | Tom Delaney readiness scenario (ADR-0018) | Writes scratch data, then rolls everything back |\\n| `probe-google-wallet.mts` | Probe | `npm run -w backend probe:google-wallet` | Generates a clickable Google Wallet save link to validate issuer/demo-account setup | Writes `.tmp` files only |\\n| `probe-schema-erd.ts` | Probe | no npm alias documented | Sanity-checks the schema ERD builder | Read-only |\\n| `probe-webhook.mjs` | Probe | no npm alias documented | Drives a live API's webhook route with real HMAC signatures | Mutates `core.t_check`, cleans up after itself |\\n| `verify-identity.ts` | Probe | `npm run -w backend verify:identity` | Identity smoke checks: spend cap, signatures, consent, lifecycle | Mutates test rows, cleans up; never contacts the provider |\\n| `ensure-google-wallet-class.mts` | Utility | no npm alias documented | Creates or updates the Google Wallet generic class with card row layout | Mutates the Wallet class in Google's API |\\n| `mint-invite.mjs` | Utility | `npm run -w backend invite -- someone@example.com \\\"Their Name\\\"` | Mints a pending onboarding invite and prints the links | Writes `core.t_request` directly; dev only |\\n| `purge-party.mjs` | Utility | `npm run -w backend purge -- someone@example.com [--all]` | Hard-deletes a person party and all dependent rows | Destructive; dev only; refuses organisations |\\n\\n## Common conventions\\n\\n### RLS bypass\\n\\nCore tables are protected by row-level security (migration 009). Every DB-touching script enables the bypass session GUC immediately after connecting:\\n\\n- Scripts using the shared pool call `withBypass(async (tx) =&gt; { ... })` from `src/shared/db/pool.js`, which wraps the callback in a transaction with `app.bypass` set.\\n- Scripts using a raw `pg.Client` call `SELECT set_config('app.bypass', 'on', false)` directly.\\n\\n### Connection defaults\\n\\n- `DATABASE_URL` defaults to `postgres://app_login:app_dev_password@localhost:5433/assureworx` \u2014 note the non-standard port `5433`.\\n- `purge-party.mjs` is the exception: it reads `ADMIN_DATABASE_URL` (default `postgres://postgres:devpassword@localhost:5433/assureworx`). It must run as the database owner because the runtime role `app_rt` deliberately has no `DELETE` on append-only tables (`t_result`, `t_ledger`, `t_consent`). The purge cannot use application credentials.\\n\\n### Cleanup discipline\\n\\n- `probe-engagement-requirements.ts` throws `PROBE_ROLLBACK` at the end of its `withBypass` transaction; the catch block confirms \\\"Rolled back cleanly.\\\"\\n- `verify-identity.ts` deletes the `t_consent`/`t_check`/`t_meter` rows it creates.\\n- `probe-webhook.mjs` deletes the `C02` check rows it inserts and closes its client.\\n- `probe-cloud-read.ts` deliberately passes no `partyId` to `extractDocument`, so the probe writes nothing and cannot pollute the audit log or trip the per-party daily cap.\\n\\n### Exit codes\\n\\nProbe scripts exit non-zero on failure. `verify-identity.ts` and `probe-webhook.mjs` aggregate individual assertions through a `check(name, cond, extra)` helper and exit `0` or `1` based on the failure count.\\n\\n## Probe scripts\\n\\n### `probe-extraction.ts`\\n\\n**Purpose.** Offline verification of the document extraction pipeline (ADR-0019). It builds synthetic PDFs mimicking the layouts Australian insurers, registries, and RTOs actually produce, runs them through the real `extractDocument` pipeline, and prints what came out. No database and no network \u2014 it runs anywhere.\\n\\n**How it works.**\\n\\n- Contains a minimal PDF writer (`makePdf` / `escapePdf`) that emits single-page PDFs with Helvetica text. No external PDF library is involved.\\n- Specimen documents are defined as line arrays: a QBE public liability certificate of currency, an icare NSW workers compensation certificate, an NSW Fair Trading electrical contractor licence, a Master Builders white card statement of attainment, a SafeWork NSW high-risk work licence, an ambiguous under-insured public liability certificate, and a blank \\\"scanned\\\" PDF with no text layer.\\n- Eight cases cover correct-type matches (`A004`, `A005`, `A101`, `A001`, `A105`), a wrong-type upload (workers comp as `A004`), an expired and under-insured COC, and a textless scan.\\n- Each case calls `extractDocument(bytes, \\\"application/pdf\\\", { classCode, partyName, partyAbn, today })` with `today` fixed at `2026-07-28`, then renders the result through `toPreview(result)`.\\n\\n**Output.** For each case: `status`, `verdict`, `confidence`, the tier ladder attempted (`result.attempted.join(\\\"&gt;\\\")`), duration, page count, the reason if one exists, the prefill JSON, every read field with confidence, and any findings.\\n\\nThe source comment carries the operative advice: *add a case here whenever a rule pack changes, and check both what was read and what was not \u2014 a pack that guesses is worse than one that stays quiet.*\\n\\n### `probe-cloud-read.ts`\\n\\n**Purpose.** Tier 3 probe for Azure AI Document Intelligence `prebuilt-read` (ADR-0020). This is the paid counterpart to `probe-extraction.ts`: it rasterises synthetic certificates to PNG \u2014 so there is no text layer that the free tiers could read \u2014 pushes them through the real tier ladder, and prints what Azure returned and what the Australian rule packs made of it.\\n\\n**Requirements.** `DOC_INTEL_ENDPOINT` set, `az login` with Cognitive Services User on the account, and a reachable `DATABASE_URL` because the monthly page meter lives in `core.t_meter`.\\n\\n**How it works.**\\n\\n1. `cloudReadStatus()` prints the endpoint, model, circuit-breaker state (`breakerClosed`), monthly page usage against the cap, and per-document limits from `config.docIntel` (`maxPagesPerDocument`, `dailyPagesPerParty`, `budgetMs`).\\n2. `makePageImage(lines)` renders SVG text lines through `sharp` to a PNG buffer \u2014 an honest simulation of a tradesperson photographing a certificate.\\n3. Three cases run: a photographed QBE public liability COC (`A004`), a photographed NSW electrical licence (`A101`), and an Allianz public liability COC under the $10M minimum (`A004`). Each is passed to `extractDocument(bytes, \\\"image/png\\\", { classCode, partyName, partyAbn, today })` with `today` fixed at `2026-07-29`. No `partyId` is passed.\\n4. Results are printed per case (status, source, confidence, attempted tiers, page count, duration), followed by the `toPreview(result)` read fields, prefill, and findings.\\n5. `cloudReadStatus()` is re-read at the end so the meter movement is visible, then `pool.end()` closes the pool.\\n\\n**Cost warning.** One page per case at US$1.50 per 1,000 pages \u2014 three cases is under a cent \u2014 but it is a real charge. The header comment says it plainly: *do not loop it.*\\n\\n### `probe-engagement-requirements.ts`\\n\\n**Purpose.** Verifies the Tom Delaney scenario (ADR-0018): a trade who is Assureworx Verified on their own portable profile is placed on a site that demands a classification they do not hold evidence for. The placement must go blocked with exactly one gap, and must clear the moment that gap is filled \u2014 without touching the portable profile.\\n\\n**How it works.** Everything runs inside `withBypass`:\\n\\n1. Finds a seeded principal contractor and a qualifying trade: status `S02`, related to the principal via `L02`/`L03` with status `RS1`, holding no `A101` artifact and no `G01` tag.\\n2. Inserts scratch project (`N01`) and site (`N02`) nodes, then a `J02` placement link for the trade on the site.\\n3. Resolves readiness with `resolveEngagementReadinessForLink(tx, link.id)` before any rules exist \u2014 the placement is free.\\n4. Adds a requirement set with one `X01` item: class `A101`, applying only when the engagement carries tag `G01`, with block code `B01`.\\n5. Re-resolves for the untagged trade \u2014 unaffected, because a trade not engaged for electrical work should not be blocked by an electrical requirement.\\n6. Tags the link `G01` and resolves again \u2014 now blocked, with exactly one gap. The gap object is printed as \\\"the 8th box (delta a top-up request would ask for).\\\"\\n7. Calls `evaluateLinks(tx, [link.id], null)` and reads back the persisted `state_cd`, `gap_hard_n`, `gap_soft_n`, and `evaluated_at` from `t_link`.\\n8. Confirms the portable profile is still `S02` (\\\"still Verified \u2014 correct\\\"), then inserts a verified `A101` artifact (`F02`) and resolves a final time \u2014 the placement clears.\\n9. Throws `PROBE_ROLLBACK`; the surrounding catch prints \\\"Rolled back cleanly.\\\"\\n\\nOne quirk worth knowing: the import line `import { pool, withBypass } from \\\"../src/shared/services/../db/pool.js\\\"` resolves to `../src/shared/db/pool.js`. It works, but it is a path oddity.\\n\\n### `probe-webhook.mjs`\\n\\n**Purpose.** Drives the running API's webhook route with real HMAC-SHA256 signatures. Its job is to prove the raw-body mounting survives Express's JSON parser and the CSRF guard \u2014 a kind of failure that only shows up when a real signed request arrives.\\n\\n**Requirements.** A live API at `PROBE_BASE` (default `http://127.0.0.1:4099`), `DIDIT_WEBHOOK_SECRET` matching the server under test (default `test-webhook-secret`), and a reachable `DATABASE_URL`.\\n\\n**How it works.**\\n\\n1. `sort()` canonicalises payloads by recursively sorting object keys; `sign()` HMAC-SHA256-signs the canonical JSON. This mirrors the server-side verification, which is key-order independent.\\n2. The script picks the first seeded person party (`kind_cd = 'K03'`), deletes any existing `C02` checks for it, and builds an `Approved` webhook payload whose `decision.id_verifications` contains fields named `MUST_NOT_PERSIST` \u2014 a tripwire for field-leak bugs.\\n3. Assertions, in order:\\n   - `/api/v1/identity/health` reports `wired`.\\n   - Unsigned delivery \u2192 `401`.\\n   - Stale timestamp (10 seconds old) \u2192 `401`.\\n   - Signed delivery \u2192 `200`, and the check persists as `H02` with `doc_expires_on` = `2032-09-14` and an empty `result` JSONB.\\n   - No `MUST_NOT_PERSIST` value leaks into `core.t_check` or `core.t_event`.\\n   - Replaying the same delivery is idempotent: still `200`, no duplicate row.\\n   - An unknown `vendor_data` (not a UUID) is acknowledged with `200` but writes nothing.\\n4. Cleans up the `C02` rows and exits `0`/`1` by failure count.\\n\\n### `verify-identity.ts`\\n\\n**Purpose.** The most comprehensive probe in the directory. It exercises the parts of identity verification where a mistake is expensive or invisible: the monthly spend cap (including under concurrency), webhook signature verification, the outcome-vs-currency derivation, consent ordering, and webhook idempotency. It needs local Postgres with migrations applied, but the `DIDIT_*` values are dummies \u2014 nothing here contacts the provider.\\n\\n**How it works.** Five sections run in sequence against the first seeded person party (`kind_cd = 'K03'`), skipping the consent/check section if no such party exists.\\n\\n1. **`deriveIdentityStatus`** \u2014 eight assertions covering the full derivation table: no check \u2192 `not_checked`; `H01` \u2192 `in_progress`; `H02` with far expiry \u2192 `current`; `H02` with 10-day expiry \u2192 `expiring_soon`; `H02` with past expiry \u2192 `expired`; `reason_cd = 'superseded'` outranks a valid expiry; `H03` \u2192 `did_not_pass`; an unknown status code \u2192 `could_not_complete` (never a pass).\\n2. **Webhook signature** \u2014 constructs a `DiditVerifier` with dummy values and asserts: a valid signature is accepted and the document expiry is extracted while every other document field is dropped; `vendor_data` comes back as `partyId`; key-order independence holds (the signature is over canonical JSON, so a reordered body must still verify); bad signature, stale timestamp, missing signature, and tampered body are all rejected; an in-flight status yields no result.\\n3. **Spend cap** \u2014 inserts a harness meter with cap 3, makes five `reserveCall` attempts, and asserts the first three win, the counter stops at the cap, `releaseCall` frees exactly one slot, and reservation becomes possible again.\\n4. **Concurrency** \u2014 25 parallel `reserveCall` calls against a cap of 10; exactly 10 win and the counter never exceeds the cap.\\n5. **Consent + check lifecycle** \u2014 the full real-world path: no consent initially \u2192 `grantConsent` produces a live consent expiring ~730 days out \u2192 `withdrawConsent` supersedes it \u2192 both events survive in the append-only trail. Then `recordStarted` creates a resumable pending check (the resume URL is readable while in flight), `applyResult` settles it (resume URL dropped, `H02` stored, expiry unshifted, `result` JSONB empty), a replayed webhook is a no-op, a settled check cannot be downgraded, `identitySummary` reports `current` and blocks a redundant check with `already_current`, `markSuperseded` preserves the outcome while setting `reason_cd`, and `destroyIdentityChecks` removes the check rows but keeps the consent trail.\\n\\nThe lifecycle under test:\\n\\n```mermaid\\nsequenceDiagram\\n    participant S as verify-identity.ts\\n    participant M as meter.ts\\n    participant C as consent.ts\\n    participant D as DiditVerifier\\n    participant DB as Postgres (core schema)\\n    S-&gt;&gt;M: reserveCall(tx, provider, cap)\\n    M-&gt;&gt;DB: t_meter row-lock + increment\\n    S-&gt;&gt;C: grantConsent(tx, { partyId, actorId, source })\\n    C-&gt;&gt;DB: append t_consent (granted)\\n    S-&gt;&gt;C: withdrawConsent(tx, { partyId, actorId, source })\\n    C-&gt;&gt;DB: append t_consent (withdrawn)\\n    S-&gt;&gt;D: parseWebhook({ rawBody, signature, timestamp })\\n    D--&gt;&gt;S: { result, partyId }\\n    S-&gt;&gt;DB: recordStarted \u2192 t_check (in flight)\\n    S-&gt;&gt;DB: applyResult \u2192 t_check H02, result JSONB {}\\n    S-&gt;&gt;DB: markSuperseded \u2192 reason_cd = superseded\\n    S-&gt;&gt;DB: destroyIdentityChecks \u2192 DELETE C02 rows\\n```\\n\\n### `probe-google-wallet.mts`\\n\\n**Purpose.** There is no offline Google Wallet simulator \u2014 Google still validates the save JWT. This probe writes a branded save link so you can click it from desktop Chrome and determine whether the issuer/demo-account setup is the problem, not `/verify`.\\n\\n**How it works.**\\n\\n1. `loadWalletConfig()` from `src/modules/wallet/config.js`; exits if Google Wallet is not configured.\\n2. Builds a `genericObject` with a fixed probe ID (`issuerId.probe_`), brand colour, card title/header/subheader, a QR barcode carrying `awx.verify.v1|AWX-PROBE|53004085616|AU|1`, a one-year validity interval, and five `textModulesData` entries (`status`, `licence`, `abn`, `expires`, `gate`).\\n3. Attaches logo/hero images from `loadWalletConfig()` if `logoUri`/`heroUri` are set.\\n4. Signs the save-to-wallet JWT with `aud: \\\"google\\\"`, `typ: \\\"savetowallet\\\"`, the configured origins, and the class/object payload.\\n5. Writes `.tmp/google-wallet-probe.html` (a clickable \\\"Add probe pass to Google Wallet\\\" link plus a JSON summary) and `.tmp/google-wallet-probe.url.txt`. The summary includes the troubleshooting tip: the Google account must be added as a demo tester on the issuer, the service account must be a user on the Google Pay &amp; Wallet Console issuer, and logo/hero URIs must be public HTTPS (not localhost).\\n\\n### `probe-schema-erd.ts`\\n\\n**Purpose.** A quick sanity check for the schema ERD builder. Runs `buildSchemaErd(tx)` inside `withBypass` and prints JSON with table count, edge count, domains, the latest migration, the logical name for `t_pass_dl`, and whether `t_req_set` exists. Useful after schema migrations to confirm the ERD service still resolves.\\n\\n## Utility scripts\\n\\n### `ensure-google-wallet-class.mts`\\n\\n**Purpose.** Creates or refreshes the Google Wallet generic class via REST. The card row layout lives on the class definition, not in the save JWT \u2014 keeping the per-object save token lean.\\n\\n**How it works.**\\n\\n1. `loadWalletConfig()`; exits unless `google.configured` and a private key are present.\\n2. Reads the service account key from `backend/certs/google-wallet-sa.json`.\\n3. Builds a one-hour RS256 JWT assertion (`iss` = client email, `scope` = `wallet_object.issuer`, `aud` = the OAuth2 token endpoint) and exchanges it for an access token.\\n4. Computes the class ID as `${issuerId}.${classSuffix}` and builds the class body with `classTemplateInfo.cardTemplateOverride.cardRowTemplateInfos` \u2014 two two-item rows mapping `object.textModulesData['status']` / `['expires']` and `object.textModulesData['licence']` / `['abn']`.\\n5. GETs the class; a `200` triggers a `PUT` update, anything else falls through to a `POST` create. Logs the status and body prefixes at each step and exits non-zero on failure.\\n\\nRun this after changing the card layout or when a class was created manually without the template override.\\n\\n### `mint-invite.mjs`\\n\\n**Purpose.** Dev-only. Mints a pending onboarding invitation and prints the links, so a real end-to-end verification can be walked without a mail provider configured. It writes a request row directly with the bypass, skipping the authorization that `POST /api/v1/requests` enforces.\\n\\n**Usage.** `npm run -w backend invite -- someone@example.com \\\"Their Name\\\"`\\n\\n**How it works.**\\n\\n1. Validates the email argument and resolves the app URL from `PUBLIC_APP_URL` (default `http://localhost:3000`).\\n2. Connects with bypass and selects the first seeded principal contractor org (`kind_cd = 'K01'`) together with its live actor.\\n3. Supersedes any earlier pending invite for this address (`status_cd 'U01' \u2192 'U05'`), so only one live token exists for the person \u2014 two live tokens for one address is a confusing thing to debug.\\n4. Generates a 32-byte hex token and inserts a `core.t_request` row: `kind_cd 'Q02'` (direct individual / sole-trader invite \u2014 `Q01` would additionally ask for a company name), `origin_party_id` and `root_party_id` set to the sender, `created_by` set to the sender's actor, the target email, a 14-day expiry, and the name in `note_txt`.\\n5. Prints the verify journey link (`/verify/i/{token}`) and the desktop wizard link (`/onboarding/invite/{token}`).\\n\\n### `purge-party.mjs`\\n\\n**Purpose.** Dev-only hard delete of a party and everything hanging off it, by sign-in email. The application never hard-deletes a party \u2014 it sets `deleted_at`, preserving audit history and network edges. This script exists to clear test identities so a journey can be walked from an empty slate, and it will happily destroy data the product would have kept.\\n\\n**Usage.** `npm run -w backend purge -- someone@example.com [--all]`\\n\\n**Constraints.**\\n\\n- **Refuses organisations** (`K01`/`K02`). Purging one would strip every trade connected to it.\\n- **Refuses ambiguity**: if the email maps to more than one party, it lists them and requires `--all` to proceed.\\n- **Runs as the database owner**, not `app_rt` \u2014 the runtime role deliberately has no `DELETE` on append-only tables like `t_result`, `t_ledger`, and `t_consent`, which is exactly why the purge cannot use application credentials.\\n- **Matches on subject too**: `subject_txt LIKE '%|'` catches actors that never finished Entra sign-up and carry `pending|` with `email_txt` unset. A leftover placeholder here is invisible but not harmless \u2014 it blocks re-registration with `PROFILE_EXISTS`.\\n\\n**Deletion order.** Everything runs in one transaction. The order follows foreign keys inward: rows that point at the party's actors first, then rows that point at the party, then the actors, then the party. Anything missed surfaces as an FK error rather than silent corruption.\\n\\n1. **Null actor-keyed FK columns** on rows that belong to somebody else and merely record who touched it: `t_artifact.checked_by`, `t_case.created_by`, `t_course.created_by`, `t_party.created_by`, `t_request.created_by`, `t_seat.created_by`, `t_grant.granted_by`, `t_check.actor_id`, `t_consent.actor_id`, `t_sr.assignee_actor_id`.\\n2. **Delete actor-keyed rows** whose column is `NOT NULL` and cannot outlive the actor: `t_sr_note.actor_id`, `t_email_change.actor_id`, `t_grant.actor_id`, `t_sr.requester_actor_id`.\\n3. **Delete party-keyed rows** across the dependent tables: `t_result`, `t_task`, `t_check`, `t_consent`, `t_artifact`, `t_party_ident`, `t_party_tag`, `t_link` (both `party_id` and `owner_party_id`), `t_msg`, `t_ledger`, `t_grant`, `t_rel` (both sides), `t_case_file`, `t_case` (both columns), `t_sr` (both columns), `t_seat`, `t_node`, `t_tag`, and `t_course`.\\n4. **Invitations sent to this person become unaddressed** (`target_party_id` \u2192 `NULL`) rather than deleted \u2014 the sending org's record that it asked is theirs, not ours to erase. Requests originated by the person are deleted.\\n5. **Delete actors, then the party row**, and commit. The script prints per-table deletion counts.\\n\\n## Relationship to the codebase\\n\\n| Script | Modules exercised | Key entry points |\\n|---|---|---|\\n| `probe-extraction.ts`, `probe-cloud-read.ts` | `src/shared/services/extraction/` | `extractDocument`, `toPreview`, `cloudReadStatus` |\\n| `probe-engagement-requirements.ts` | `src/shared/services/requirements.ts` | `resolveEngagementReadinessForLink`, `evaluateLinks` |\\n| `probe-webhook.mjs`, `verify-identity.ts` | `src/modules/identity/`, `src/shared/services/identity-check.ts` | `DiditVerifier.parseWebhook`, `reserveCall`/`releaseCall`/`readMeter`/`currentPeriod`, `grantConsent`/`withdrawConsent`/`currentConsent`, `recordStarted`/`applyResult`/`markSuperseded`/`destroyIdentityChecks`/`identitySummary`, `deriveIdentityStatus`/`latestIdentityCheck` |\\n| `probe-google-wallet.mts`, `ensure-google-wallet-class.mts` | `src/modules/wallet/config.ts` | `loadWalletConfig` |\\n| `probe-schema-erd.ts` | `src/shared/services/schema-erd.ts` | `buildSchemaErd` |\\n| All DB-writing scripts | `src/shared/db/pool.ts` | `pool`, `withBypass` |\\n\\nTwo shared helpers appear throughout: `src/shared/schema/tables.ts` (the `T` table-name constant map, used by `probe-engagement-requirements.ts`) and `src/shared/config.ts` (the `config.docIntel` limits printed by `probe-cloud-read.ts`). The wallet scripts share the same service-account key file at `backend/certs/google-wallet-sa.json` and the same OAuth token exchange against Google's endpoints.\\n\\nThe scripts are not part of the application runtime. They are the developer's verification harness for the subsystems where a regression is expensive to spot: extraction tier behaviour, engagement readiness derivation, webhook signature handling, spend-cap accounting, consent ordering, and the Google Wallet class layout. Run them after schema migrations, rule-pack changes, or identity-module refactors \u2014 and read their output the way the source comments instruct, checking not just what was accepted but what was correctly rejected or dropped.\",\"development-tooling-scripts-backend-src\":\"# Development Tooling &amp; Scripts \u2014 backend-src\\n\\n# Development Tooling &amp; Scripts \u2014 backend-src\\n\\n## Purpose\\n\\nThis module defines the canonical structure for all backend feature modules. It exists as a template (`_module.template.md`) that developers copy when creating a new bounded context under `backend/src/modules/`. The goal is to enforce a consistent layering across every feature so that each module remains readable, testable, and \u2014 critically \u2014 extractable into its own service if the bounded context outgrows the monolith.\\n\\nEvery folder under `modules/` is treated as a **bounded context** on the Domain-Driven Design model. Today, all contexts are deployed as a single Express process; the strict file boundaries make it possible to split any one of them out later without rewriting business logic.\\n\\n## Module Layout\\n\\nEach feature module follows the same seven-file pattern:\\n\\n```\\nmodules//\\n  routes.ts        # Express router only \u2014 no business logic\\n  controller.ts    # HTTP in/out \u2192 calls service\\n  validation.ts    # Zod schemas\\n  service.ts       # business logic + audit events\\n  repository.ts    # SQL only \u2014 enforce tenant scope here\\n  types.ts         # module types\\n  index.ts         # export router + public types\\n```\\n\\nNew modules are created by copying this template and filling in each file. The file names are fixed \u2014 they are the contract.\\n\\n## Layer Responsibilities\\n\\nEach file has a single, strict responsibility. Crossing these boundaries is the primary code-review failure mode.\\n\\n### `routes.ts` \u2014 routing only\\n\\n- Defines the Express router and HTTP method mappings.\\n- Contains **no business logic** \u2014 no validation, no data access, no computation.\\n- Delegates every handler to the controller.\\n\\n### `controller.ts` \u2014 HTTP boundary\\n\\n- Translates between HTTP and the service layer: parses request bodies/params, sets status codes, shapes response bodies.\\n- Calls into the service layer; never touches SQL.\\n- Never contains decision logic \u2014 only protocol translation.\\n\\n### `validation.ts` \u2014 request schemas\\n\\n- Zod schemas for request payloads, query params, and path params.\\n- Imported by the controller or middleware; kept separate so schemas are unit-testable and reusable independent of HTTP handling.\\n\\n### `service.ts` \u2014 business logic\\n\\n- Contains the actual business rules of the bounded context.\\n- Emits **audit events** \u2014 this is the only layer allowed to do so.\\n- Orchestrates repository calls; does not write SQL directly.\\n\\n### `repository.ts` \u2014 data access\\n\\n- **SQL only.** No business logic.\\n- The single place where **tenant scope must be enforced** \u2014 every query filters by the tenant context. This is checked in review: any SQL outside a repository file is a defect.\\n\\n### `types.ts` \u2014 shared types\\n\\n- Module-level TypeScript types: domain entities, DTOs, and any types shared between the layers above.\\n\\n### `index.ts` \u2014 public surface\\n\\n- Re-exports the router and **public types only**.\\n- Hides internal implementation details; nothing else in the codebase should import from `routes.ts`, `controller.ts`, etc. directly.\\n\\n## Request Flow\\n\\nA typical request traverses the layers in a single direction:\\n\\n```mermaid\\ngraph LR\\n    A[Express app] --&gt; B[routes.ts]\\n    B --&gt; C[controller.ts]\\n    C --&gt; D[validation.ts]\\n    C --&gt; E[service.ts]\\n    E --&gt; F[repository.ts]\\n    F --&gt; G[(SQL)]\\n    E --&gt; H[(audit events)]\\n```\\n\\n- `routes.ts` receives the HTTP request and hands it to the controller.\\n- The controller validates input via `validation.ts`, then calls the service.\\n- The service applies business logic and calls the repository for persistence.\\n- The repository executes SQL within tenant scope.\\n- The service emits audit events alongside business operations.\\n- The response bubbles back up through the same layers.\\n\\nThere are no cross-module calls \u2014 modules are self-contained bounded contexts and do not import each other's internals.\\n\\n## Integration with the Application\\n\\nEach module's `index.ts` exports its router. The root application (`src/app.ts`) mounts that router at:\\n\\n```\\n/api/v1/\\n```\\n\\nThe `` path is chosen per feature (e.g., `/api/v1/orders`, `/api/v1/users`). A module does not self-register; registration is centralized in `src/app.ts` so the full API surface is visible from one place.\\n\\n## Design Principles\\n\\n- **Bounded contexts, extractable by design.** Because each module has no inbound or outbound calls to other modules and its layers are decoupled, it can be lifted into a standalone Express service by copying the folder, adding a bootstrap entry point, and mounting its router.\\n- **One direction of dependency.** Controller \u2192 Service \u2192 Repository. Service never imports controller; repository never imports service. This keeps the layers individually testable.\\n- **SQL is quarantined.** All data access lives in `repository.ts`, and tenant scoping is enforced solely there. This is the module's security invariant.\\n- **Audit events are centralized.** Only the service layer emits audit events, ensuring consistent, complete audit trails across every operation.\\n\\n## Contributing\\n\\nWhen adding a feature to the backend:\\n\\n1. Copy this template to `modules//` with the fixed file names.\\n2. Implement each layer in order: types \u2192 repository \u2192 service \u2192 validation \u2192 controller \u2192 routes \u2192 index.\\n3. Mount the exported router in `src/app.ts` under `/api/v1/`.\\n4. Ensure no SQL exists outside `repository.ts` and no business logic exists outside `service.ts`.\\n\\nThe template itself is documentation \u2014 it carries no runtime code and has no call graph or execution flow. Its value is as a checklist and a contract for every future module.\",\"development-tooling-scripts-frontend-public\":\"# Development Tooling &amp; Scripts \u2014 frontend-public\\n\\n# Development Tooling &amp; Scripts \u2014 frontend-public\\n\\n## Overview\\n\\n`frontend/public/strip_text.py` is a standalone development utility that pre-processes status badge images used by the frontend. It removes empty space around the visual content of each badge, applies a fixed padding, and then re-saves each badge as a centered `_mark.png` file on a transparent canvas of uniform dimensions.\\n\\nThe script is designed for the specific set of badge images in `frontend/public/`:\\n\\n- `verified.png`\\n- `pending.png`\\n- `action_required.png`\\n- `status_header.png`\\n\\nThese source images are expected to contain both a logo/badge area at the top and a text label below it. The script identifies the gap between these two components and crops the image below that gap, ensuring the resulting mark contains only the icon/badge portion rather than the text.\\n\\n## Inputs\\n\\nThe script operates on files in the same directory as the script itself (`frontend/public/`). Each input file must be a PNG with an alpha channel, since the script reads the fourth channel (`alpha`) to determine non-empty content.\\n\\nInput filenames are hard-coded in the `NAMES` list:\\n\\n```python\\nNAMES = [\\n    \\\"verified\\\",\\n    \\\"pending\\\",\\n    \\\"action_required\\\",\\n    \\\"status_header\\\",\\n]\\n```\\n\\nFor each name, the script opens `.png`.\\n\\n## Processing Pipeline\\n\\n### 1. Load image and compute alpha row sums\\n\\nFor each input image, the script:\\n\\n1. Opens the PNG with Pillow.\\n2. Converts it to a NumPy array.\\n3. Extracts the alpha channel as `float32`.\\n4. Computes the sum of alpha values for each row (`row_sum`).\\n\\nRows with `row_sum &gt; 0` represent rows that contain some non-transparent content.\\n\\n### 2. Determine top and bottom crop boundaries\\n\\n- `top` is the first row with any non-transparent content.\\n- The script then scans downward from `top` to find the first \\\"gap\\\" row that satisfies the `DIP_THRESHOLD` condition.\\n- To avoid triggering on anti-aliased edges near the top, the scan only starts looking for a gap after a row with `row_sum &gt; 5000` has been seen. This ensures the initial logo/badge content has actually started.\\n- `DIP_THRESHOLD` is set to `2000`. If a row's alpha sum falls below this threshold after content has started, that row is treated as the gap between the badge and the text.\\n- The bottom boundary is set to that gap row (`dip_row`). If no gap is found, the bottom defaults to the last content row.\\n\\n### 3. Determine left and right crop boundaries\\n\\nUsing only the rows between `top` and `bottom`, the script computes which columns contain non-transparent pixels. The leftmost and rightmost such columns become the horizontal crop boundaries.\\n\\n### 4. Apply padding\\n\\nA constant `PAD` value (`10` pixels) is added to each side of the crop rectangle, clamped to the image bounds:\\n\\n```python\\ny0 = max(0, top - PAD)\\ny1 = min(arr.shape[0], bottom + PAD)\\nx0 = max(0, left - PAD)\\nx1 = min(arr.shape[1], right + PAD)\\n```\\n\\nThe cropped region is stored in the `crops` dictionary keyed by the badge name.\\n\\n### 5. Center on a uniform canvas\\n\\nAfter all four images are cropped, the script finds the maximum crop height and width across all badges. For each crop, it creates a transparent canvas of those maximum dimensions and places the crop in the center. This ensures all output marks share identical dimensions while preserving the original aspect ratio of each badge.\\n\\n### 6. Save output\\n\\nEach centered canvas is saved as `_mark.png` in the same `frontend/public/` directory, using RGBA mode.\\n\\nDuring execution the script prints diagnostics:\\n\\n```text\\nverified crop size (\u2026, \u2026)\\nsaved /path/to/frontend/public/verified_mark.png (\u2026, \u2026)\\n```\\n\\n## Configuration Constants\\n\\n| Constant         | Value | Purpose                                                              |\\n|------------------|-------|----------------------------------------------------------------------|\\n| `DIP_THRESHOLD`  | `2000`| Row alpha-sum threshold that identifies a gap between badge and text.|\\n| `PAD`            | `10`  | Padding in pixels added around the content crop.                     |\\n| `5000` (inline)  | \u2014     | Minimum row alpha-sum to consider the badge content as \\\"started\\\".    |\\n\\nThese values are tuned for the specific badge assets in the repository. If the badge designs change, the thresholds may need adjustment.\\n\\n## Outputs\\n\\nFor each input name, the script generates one file:\\n\\n| Input            | Output                     |\\n|------------------|----------------------------|\\n| `verified.png`   | `verified_mark.png`        |\\n| `pending.png`    | `pending_mark.png`         |\\n| `action_required.png` | `action_required_mark.png` |\\n| `status_header.png`   | `status_header_mark.png`   |\\n\\nAll output files are RGBA PNGs with identical dimensions, containing the badge artwork centered on a transparent background.\\n\\n## Dependencies\\n\\n- Python 3\\n- Pillow (`PIL`)\\n- NumPy\\n\\nThis script has no internal module dependencies and is not imported elsewhere. It is intended to be run directly:\\n\\n```bash\\npython frontend/public/strip_text.py\\n```\\n\\n## Integration with the Codebase\\n\\nThe script is a one-time or on-demand development tool. There are no incoming or outgoing calls; it does not register itself as part of the application runtime. Its outputs (`*_mark.png`) are consumed by the frontend as static assets, likely for UI elements that display a status badge icon without accompanying text.\\n\\n## Process Flow\\n\\n```mermaid\\nflowchart TD\\n    A[Read PNG with alpha] --&gt; B[Compute alpha row sums]\\n    B --&gt; C[Find top content row]\\n    C --&gt; D[Scan for gap below badge]\\n    D --&gt; E[Set bottom boundary]\\n    E --&gt; F[Compute horizontal bounds]\\n    F --&gt; G[Apply padding]\\n    G --&gt; H[Center crop on uniform canvas]\\n    H --&gt; I[Save _mark.png]\\n```\\n\\n## Notes for Contributors\\n\\n- If a badge design is changed so that the text/badge gap falls outside the current `DIP_THRESHOLD`, adjust the constant or the row-sum trigger value (`5000`).\\n- The script assumes all input files exist and have an alpha channel. Missing files or RGB-only PNGs will raise an exception.\\n- The centering pass normalizes dimensions across all badge marks; if a new badge is added to `NAMES`, it will affect the output canvas size for every mark.\",\"development-tooling-scripts-scripts-backup\":\"# Development Tooling &amp; Scripts \u2014 scripts-backup\\n\\n# scripts-backup \u2014 Backup &amp; Sync Tooling\\n\\n## Overview\\n\\n`scripts/backup/` contains five Windows batch scripts for creating repository backups and synchronizing selected parts of the repository to other drives or into the wiki knowledge base. These are interactive, developer-run tools \u2014 they are not invoked by the application, by CI, or by any other code in the repository. Their only external dependencies are `robocopy` (built into Windows) and PowerShell's `Get-Date`.\\n\\nAll scripts share the same design principles:\\n\\n- They resolve the repository root relative to their own location using `%~dp0..\\\\..`.\\n- They use `robocopy` with a consistent flag set.\\n- They translate robocopy exit codes into a simple pass/fail result (`GEQ 8` = failure).\\n- They end with `pause` so output stays visible when launched from Explorer.\\n\\n## Script Inventory\\n\\n| Script | Purpose | Default destination | Copy behavior |\\n|---|---|---|---|\\n| `backup-to-d-drive.bat` | Full repository backup | `D:\\\\assureworks\\\\\\\\v` | Additive (`/E`), versioned |\\n| `backup-to-e-drive.bat` | Full repository backup | `E:\\\\assureworks\\\\\\\\v` | Additive (`/E`), versioned |\\n| `backup-to-folder.bat` | Full repository backup | `D:\\\\Projects\\\\BackupFolder\\\\\\\\v` | Additive (`/E`), versioned |\\n| `sync-docs-to-e-drive.bat` | Selective docs / knowledge-base sync | `D:\\\\toShare` | Additive (`/E`) or mirror (`/MIR`) |\\n| `sync-frontend-backend-to-wiki-raw.bat` | Copy `FRONTEND` + `BACKEND` into `wiki\\\\raw` | `\\\\wiki\\\\raw\\\\FRONTEND`, `\\\\wiki\\\\raw\\\\BACKEND` | Full replace (delete, then `/E`) |\\n\\n## Common Mechanics\\n\\n### Resolving the Repository Root\\n\\nEvery script starts with the same source-resolution pattern:\\n\\n```bat\\npushd \\\"%~dp0..\\\\..\\\"\\nset \\\"SOURCE=%CD%\\\"\\npopd\\n```\\n\\n`%~dp0` expands to the directory containing the batch file. `..\\\\..` moves two levels up \u2014 from `scripts\\\\backup\\\\` to the repository root \u2014 so `SOURCE` becomes the absolute path of the repo root. The scripts therefore depend on remaining at exactly `scripts\\\\backup\\\\` inside the repository. Moving a script will silently change what gets copied.\\n\\n### Versioned Backup Layout\\n\\nThe three `backup-to-*.bat` scripts generate a fresh destination on every run:\\n\\n1. Compute a date string with PowerShell: `powershell -NoProfile -Command \\\"Get-Date -Format yyyy-MM-dd\\\"` \u2192 stored in `BACKUP_DATE`.\\n2. Create `%BACKUP_ROOT%\\\\` if it does not exist.\\n3. Search for the first free version directory (`v1`, `v2`, \u2026) using the `:find_dest` label.\\n4. Run `robocopy` into that directory.\\n\\n```mermaid\\nflowchart TD\\n    A[Invoke backup-to-*.bat] --&gt; B[Resolve SOURCE = repo root]\\n    B --&gt; C[BACKUP_DATE = yyyy-MM-dd via PowerShell]\\n    C --&gt; D[Create BACKUP_ROOT\\\\DATE if missing]\\n    D --&gt; E[V = 1]\\n    E --&gt; F{DEST vN exists?}\\n    F --&gt;|Yes| G[V += 1]\\n    G --&gt; F\\n    F --&gt;|No| H[robocopy SOURCE DEST /E /XD node_modules]\\n    H --&gt; I{RC &gt;= 8?}\\n    I --&gt;|No| J[Print complete, exit 0]\\n    I --&gt;|Yes| K[Print error, exit 1]\\n```\\n\\nBecause `:find_dest` always picks the first free `vN`, repeated runs on the same day produce `v1`, `v2`, `v3`, and so on. No backup is ever overwritten or pruned automatically.\\n\\n### Robocopy Flags and Exit Codes\\n\\nAll scripts use a common robocopy flag set:\\n\\n```\\n/E /XD node_modules /MT:8 /R:2 /W:2 /NP /NDL /NJH /NJS\\n```\\n\\n| Flag | Meaning |\\n|---|---|\\n| `/E` | Copy all subdirectories, including empty ones |\\n| `/XD node_modules` | Exclude any directory named `node_modules` |\\n| `/MT:8` | Multithreaded copy with 8 threads |\\n| `/R:2` | Retry twice on failure |\\n| `/W:2` | Wait 2 seconds between retries |\\n| `/NP` | No progress percentage display |\\n| `/NDL` | No directory listing |\\n| `/NJH` / `/NJS` | No job header / no job summary |\\n\\nRobocopy returns a bitmask exit code. Codes `0`\u2013`7` indicate success or benign warnings (e.g. `1` = files copied, `2` = extra files detected); anything `&gt;= 8` is a real failure. Every script checks this with:\\n\\n```bat\\nif !ERRORLEVEL! GEQ 8 (\\n    echo Robocopy reported an error ^(exit code !ERRORLEVEL!^).\\n    pause\\n    exit /b 1\\n)\\n```\\n\\n## Backup Scripts\\n\\n`backup-to-d-drive.bat`, `backup-to-e-drive.bat`, and `backup-to-folder.bat` are functionally identical. The only difference is the `BACKUP_ROOT` variable:\\n\\n| Script | `BACKUP_ROOT` |\\n|---|---|\\n| `backup-to-d-drive.bat` | `D:\\\\assureworks` |\\n| `backup-to-e-drive.bat` | `E:\\\\assureworks` |\\n| `backup-to-folder.bat` | `D:\\\\Projects\\\\BackupFolder` |\\n\\nEach backs up the entire repository \u2014 including `.git`, build output, and the `scripts` folder itself \u2014 excluding only directories named `node_modules`. If the destination drive is not present or robocopy fails, the script prints the robocopy exit code, pauses, and exits with status `1`.\\n\\n## Documentation Sync \u2014 `sync-docs-to-e-drive.bat`\\n\\nThis script is the most configurable of the five. Despite the filename mentioning \\\"E: drive\\\", the default destination is `D:\\\\toShare`; an explicit destination can be passed as the first argument.\\n\\n### Argument Parsing\\n\\nThe script accepts an optional destination path and an optional `/MIR` flag, in either position:\\n\\n```bat\\nsync-docs-to-e-drive.bat\\nsync-docs-to-e-drive.bat \\\"E:\\\\some\\\\other\\\\folder\\\"\\nsync-docs-to-e-drive.bat \\\"E:\\\\some\\\\other\\\\folder\\\" /MIR\\nsync-docs-to-e-drive.bat /MIR\\n```\\n\\nThe parsing logic:\\n\\n- If argument 1 is present and is not `/MIR`, it becomes `DEST`.\\n- If argument 1 *is* `/MIR`, `DEST` stays at the default.\\n- `MIRROR` is set to `/MIR` if either argument 1 or argument 2 equals `/MIR` (case-insensitive).\\n- `COPY_MODE` is `/E` (additive) by default, or `/MIR` when mirroring. `/MIR` deletes files at the destination that no longer exist at the source \u2014 use with care.\\n\\n```mermaid\\nflowchart TD\\n    A[Invoke sync-docs-to-e-drive.bat] --&gt; B{Arg 1 present and not /MIR?}\\n    B --&gt;|Yes| D[DEST = arg 1]\\n    B --&gt;|No| C[DEST = D:\\\\toShare default]\\n    D --&gt; E{Arg 1 or arg 2 is /MIR?}\\n    C --&gt; E\\n    E --&gt;|Yes| F[COPY_MODE = /MIR / mirror mode]\\n    E --&gt;|No| G[COPY_MODE = /E / additive mode]\\n```\\n\\nIf the destination does not exist, the script creates it and exits with status `1` if creation fails.\\n\\n### Sync Scope\\n\\nThe script syncs only documentation and knowledge-base content, never application source:\\n\\n- **Directories synced:** `wiki`, `isms`, `Raw`, `deliverables`\\n- **Root markdown files copied** (via `copy /Y`, only if present): `AGENTS.md`, `ARCHITECTURE.md`, `DESIGN.md`, `PRODUCT.md`\\n\\nFor the four directory syncs, the `:sync_dir` subroutine is called with a relative path and a display label:\\n\\n```bat\\ncall :sync_dir \\\"wiki\\\" \\\"wiki\\\"\\ncall :sync_dir \\\"isms\\\" \\\"isms\\\"\\ncall :sync_dir \\\"Raw\\\" \\\"Raw\\\"\\ncall :sync_dir \\\"deliverables\\\" \\\"deliverables\\\"\\n```\\n\\nIf a source subdirectory is missing, `:sync_dir` prints `SKIP missing: ` and continues (or returns via `goto :eof`).\\n\\n### The `:sync_dir` Subroutine\\n\\nFor each directory, robocopy runs with the common flags plus additional exclusions:\\n\\n```bat\\nrobocopy \\\"!SOURCE!\\\\!REL!\\\" \\\"!DEST!\\\\!REL!\\\" !COPY_MODE! !ROBO_COMMON! ^\\n  /XD node_modules .git .tmp .next dist build coverage .data ^\\n  /XF *.log .env .env.* *.pem *.p12 *.cer\\n```\\n\\n- `/XD` excludes dependency and build directories: `node_modules`, `.git`, `.tmp`, `.next`, `dist`, `build`, `coverage`, `.data`.\\n- `/XF` excludes log files, environment files, and private-key / certificate files.\\n- `ROBO_COMMON` (defined at the top of the script) adds `/XF Thumbs.db .DS_Store desktop.ini` to filter OS junk.\\n\\nThe subroutine tracks the highest exit code across all four syncs in `RC_MAX`. After all syncs complete, if `RC_MAX &gt;= 8`, the script reports failure and exits `1`; otherwise it reports success.\\n\\n## Source-to-Wiki Sync \u2014 `sync-frontend-backend-to-wiki-raw.bat`\\n\\nThis script pushes the application source trees into the wiki's raw area so the knowledge base includes current frontend and backend code:\\n\\n- `DEST_ROOT` resolves to `\\\\wiki\\\\raw` (via `for %%I in (\\\"%~dp0..\\\\..\\\\wiki\\\\raw\\\")`).\\n- It expects top-level `FRONTEND` and `BACKEND` directories in the repository root. If either is missing, it prints an error, pauses, and exits `1`.\\n- Unlike the backup scripts, it is **destructive by design**: any existing `wiki\\\\raw\\\\FRONTEND` and `wiki\\\\raw\\\\BACKEND` directories are removed with `rmdir /s /q` before copying, so the wiki copy is a clean snapshot.\\n- Each copy uses the common robocopy flags (`/E /XD node_modules /MT:8 /R:2 /W:2 /NP /NDL /NJH /NJS`), with the `node_modules` exclusion only \u2014 no `.git` or build-output exclusions.\\n\\nThe two robocopy calls are sequential. A failure in either (exit code `&gt;= 8`) aborts the script with status `1`; the second copy is not attempted if the first fails.\\n\\n## Repository Integration\\n\\nThese scripts connect to the rest of the codebase purely through filesystem paths. There are no code-level dependencies, no internal call-graph edges, and no imports from the application.\\n\\nExpected repository layout:\\n\\n```\\n/\\n\u251c\u2500\u2500 AGENTS.md          # copied by sync-docs-to-e-drive.bat\\n\u251c\u2500\u2500 ARCHITECTURE.md    # copied by sync-docs-to-e-drive.bat\\n\u251c\u2500\u2500 DESIGN.md          # copied by sync-docs-to-e-drive.bat\\n\u251c\u2500\u2500 PRODUCT.md         # copied by sync-docs-to-e-drive.bat\\n\u251c\u2500\u2500 FRONTEND/          # synced by sync-frontend-backend-to-wiki-raw.bat\\n\u251c\u2500\u2500 BACKEND/           # synced by sync-frontend-backend-to-wiki-raw.bat\\n\u251c\u2500\u2500 wiki/\\n\u2502   \u2514\u2500\u2500 raw/           # destination for sync-frontend-backend-to-wiki-raw.bat\\n\u251c\u2500\u2500 isms/\\n\u251c\u2500\u2500 Raw/\\n\u251c\u2500\u2500 deliverables/\\n\u2514\u2500\u2500 scripts/\\n    \u2514\u2500\u2500 backup/        # this module\\n```\\n\\nThe two sync scripts serve complementary purposes:\\n\\n- `sync-docs-to-e-drive.bat` deliberately **excludes** application source (`frontend`, `backend`, `packages`, `db`, `scripts`, `public`) and shares only documentation and deliverable content.\\n- `sync-frontend-backend-to-wiki-raw.bat` deliberately **includes** the frontend and backend source and places it under `wiki\\\\raw`, making it part of the knowledge base.\\n\\n## Known Quirks and Limitations\\n\\n- **Naming inconsistency:** `sync-docs-to-e-drive.bat` is named for syncing to the E: drive, but its default `DEST` is `D:\\\\toShare`, and its usage comment references an E: folder. The banner echo hardcodes `Sync docs to D:\\\\toShare` even when a different destination is passed. Pass an explicit destination as argument 1 to avoid ambiguity.\\n- **Unattended use is not supported:** every script ends with `pause`, and several print interactive progress banners. They are designed for a human at a console, not for CI or scheduled-task automation.\\n- **Backups are never pruned:** the versioned backup scripts accumulate `v1`, `v2`, \u2026 directories indefinitely. The date directory is created before the copy runs, so even a failed backup leaves an empty dated directory behind.\\n- **Backup scope is broad:** the backup scripts include `.git`, build artifacts, and the `scripts` folder \u2014 only `node_modules` is excluded. Use `sync-docs-to-e-drive.bat` when a narrower, source-safe share is needed.\\n- **Destructive sync:** `sync-frontend-backend-to-wiki-raw.bat` deletes existing `wiki\\\\raw\\\\FRONTEND` and `wiki\\\\raw\\\\BACKEND` before copying. It does not need `wiki\\\\raw` to pre-exist \u2014 it creates it if missing \u2014 but any manual edits inside those two destination folders will be lost.\",\"development-tooling-scripts-scripts-db-lifecycle\":\"# Development Tooling &amp; Scripts \u2014 scripts-db-lifecycle\\n\\n# Development Tooling &amp; Scripts \u2014 `scripts/db-lifecycle`\\n\\n## Purpose\\n\\nThe `scripts/db-lifecycle` module provides backup, wipe, and restore tooling for the two Assureworx Postgres environments that matter during development:\\n\\n- **Local Docker Postgres** \u2014 runs on `127.0.0.1:5433`, database `assureworx`\\n- **Azure DEV Postgres** \u2014 a shared, production-like environment (Postgres 18) whose admin credentials live in Azure Key Vault\\n\\nThe scripts are designed for a single founder/developer workflow: snapshot a database, nuke the local one back to a clean schema, or load an Azure DEV dump onto a laptop. Every backup captures schema, data, indexes, and **RLS policies**; dumps are always taken as an admin role (`postgres` locally, the Key Vault `DATABASE-URL` for Azure DEV) so that row-level security never hides or filters the dumped rows.\\n\\nBackups land in repo-root `backups/`, which is gitignored because dumps can contain customer PII. There is intentionally **no** `wipe-dev.ps1` \u2014 the scripts can only destroy the local database.\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    subgraph Sources\\n        DEV[(Azure DEVPostgres 18)]\\n        LOC[(Local DockerPostgres 16/18)]\\n    end\\n\\n    subgraph Scripts\\n        BD[backup-dev.ps1]\\n        BL[backup-local.ps1]\\n        W[wipe-local.ps1]\\n        R[restore-to-local.ps1]\\n    end\\n\\n    subgraph Storage\\n        BDIR[(backups/azure-dev/&lt;stamp&gt;)]\\n        LDIR[(backups/local/&lt;stamp&gt;)]\\n    end\\n\\n    BD --&gt;|Key Vault DATABASE-URL| DEV\\n    BL --&gt; LOC\\n    BD --&gt; BDIR\\n    BL --&gt; LDIR\\n\\n    W --&gt; LOC\\n    R --&gt; BDIR\\n    R --&gt; LOC\\n    R -. safety backup .-&gt; LDIR\\n```\\n\\nAll PowerShell scripts dot-source `_common.ps1`, which contains the shared connection-resolution, Postgres-tool discovery, and safety helpers. The two Node.js scripts (`inventory.mjs`, `ensure-founder.mjs`) are invoked by the PowerShell scripts and share `pgSslOptions` from `scripts/pg-ssl.mjs`.\\n\\n## Database model &amp; access roles\\n\\n| Piece | What it is |\\n| --- | --- |\\n| `core` schema | Application tables |\\n| `meta` schema | Dictionary tables + `schema_migration` |\\n| `app_rt` role | Login group role; the API connects as `app_login` which is a member of `app_rt` |\\n| `app_login` role | Runtime login for the API, password sourced from local secrets or `backend/.env` |\\n| Admin access | Scripts connect as `postgres` (local) or the Key Vault `DATABASE-URL` (Azure DEV) |\\n| RLS | Widely enabled, often with `FORCE ROW LEVEL SECURITY` \u2014 always dump as admin |\\n| Founder | `tech@assureworx.com.au`, platform admin (`R01`). Local wipes force `subject_txt = dev\\\\|\u2026` so passwordless login works without Entra |\\n\\nTwo facts drive most of the design:\\n\\n1. **RLS is aggressive.** Dumps and restores must run as an admin role (`postgres` or the Key Vault admin URL) or data would be silently filtered by policies. `ensure-founder.mjs` additionally sets the `app.bypass` GUC (`set_config('app.bypass', 'on', true)`) in its session so it can write actor/grant rows through the RLS policies.\\n2. **Azure DEV is Postgres 18; local was historically Postgres 16.** Custom-format (`pg_dump -Fc`) dumps are not downward-compatible, so the scripts version-check the client and fall back to `docker run postgres:18` when needed (see [Postgres version compatibility](#postgres-version-compatibility)).\\n\\n## Shared helpers \u2014 `_common.ps1`\\n\\nEvery `.ps1` script begins with:\\n\\n```powershell\\n. \\\"$PSScriptRoot\\\\_common.ps1\\\"\\n```\\n\\nThis sets `$ErrorActionPreference = \\\"Stop\\\"` and defines the following helpers.\\n\\n### Connection resolution\\n\\n- **`Get-LocalAdminDatabaseUrl`** \u2014 returns a `postgres://postgres:@127.0.0.1:5433/assureworx` admin URL. Resolution order: `$env:ADMIN_DATABASE_URL` \u2192 password from `backend/.data/local-infra-secrets.env` \u2192 hardcoded `devpassword` fallback.\\n- **`Get-LocalAppDatabaseUrl`** \u2014 reads `DATABASE_URL` from `backend/.env` (the runtime app connection).\\n- **`Get-LocalAppLoginPassword`** \u2014 the `app_login` password, from `APP_LOGIN_PASSWORD` in `local-infra-secrets.env`, else parsed out of the app `DATABASE_URL`, else `app_dev_password`.\\n- **`Get-AzureDevDatabaseUrl`** \u2014 returns `$env:DATABASE_URL` if it is set and points at a non-local host; otherwise performs `az login` (tenant `e0045fa5-8177-4976-a60a-892074203e28`, subscription `17065bb7-1e80-4ef0-a043-4302a7f37af8`) and reads the `DATABASE-URL` secret from Key Vault `kv-aw-dev-aue`.\\n- **`ConvertTo-PgConnArgs`** \u2014 passes the connection string through as a single URI argument and appends `sslmode=require` when the host is not localhost/127.0.0.1.\\n- **`Get-DbHostLabel`** \u2014 extracts the hostname for display and for the localhost safety checks.\\n\\n### Postgres tool discovery &amp; version compatibility\\n\\n- **`Get-PgTool `** \u2014 locates `psql` / `pg_dump` / `pg_restore` on `PATH`, then falls back to `C:\\\\Program Files\\\\PostgreSQL\\\\{18,17,16}\\\\bin\\\\.exe`. Throws if not found.\\n- **`Get-ServerMajor `** \u2014 queries `SHOW server_version_num;` against the target server; returns `$null` when no `psql` client exists.\\n- **`Invoke-PgDumpCompatible -ConnectionString  -DumpArgs `** \u2014 runs `pg_dump` with the host client when its major version is \u2265 the server's major version. Otherwise falls back to `docker run postgres:18 pg_dump`, rewriting `-f ` into a mounted `/out/...` path and rewriting `localhost`/`127.0.0.1` to `host.docker.internal` in the connection string.\\n- **`Invoke-PgRestoreCompatible -ConnectionString  -DumpFile `** \u2014 same pattern for `pg_restore`, always passing `--no-owner --no-acl --exit-on-error`.\\n\\n### Database lifecycle operations\\n\\n- **`Drop-AndCreateLocalDb `** \u2014 connects to the maintenance `postgres` database, terminates all connections to `assureworx`, then `DROP DATABASE IF EXISTS assureworx` / `CREATE DATABASE assureworx OWNER postgres`.\\n- **`Reset-LocalPostgres18`** \u2014 nuclear local reset: pulls `postgres:18`, stops/removes the `assureworx-pg` container, removes the old volume (so PG16 data files cannot poison a PG18 boot), creates volume `assureworx-pg-data`, and starts a fresh `postgres:18` container on `127.0.0.1:5433:5432` with `POSTGRES_DB=assureworx`. Requires `backend/.data/local-infra-secrets.env` for the superuser password.\\n- **`Invoke-Migrate `** \u2014 runs `npm run migrate` from the repo root with `DATABASE_URL` set to the admin URL.\\n- **`Ensure-LocalAppLogin `** \u2014 idempotently creates `app_rt` (NOLOGIN) and `app_login` (LOGIN, password from `Get-LocalAppLoginPassword`), grants `app_rt` to `app_login`, and re-syncs the password to match `backend/.env`.\\n- **`Ensure-FounderLocal `** \u2014 runs `ensure-founder.mjs tech@assureworx.com.au --dev-auth`.\\n\\n### Backup artifacts\\n\\n- **`New-BackupDir `** \u2014 creates `backups///` and returns the path.\\n- **`Invoke-SchemaInventory  `** \u2014 sets `DATABASE_URL` and runs `inventory.mjs` against the target.\\n- **`Write-Manifest  `** \u2014 writes `MANIFEST.txt` with creation time, machine, repo path, caller-supplied fields, and a contents legend.\\n\\n## Backup scripts\\n\\n### `backup-local.ps1`\\n\\nSnapshots the local Docker Postgres. Takes no parameters. Steps:\\n\\n1. Resolves the local admin URL and creates `backups/local//`.\\n2. Runs `pg_dump -Fc -b -v -f /assureworx.dump` (custom format: schema + data + RLS policies) via `Invoke-PgDumpCompatible`.\\n3. Runs schema-only plain SQL for the `core` and `meta` schemas into `schema.sql` (human-readable DDL + RLS).\\n4. If the `assureworx-pg` Docker container is running, captures `pg_dumpall --roles-only` into `roles.sql` (local only \u2014 this preserves `app_rt` / `app_login`).\\n5. Writes `inventory.json` / `inventory.md` via `Invoke-SchemaInventory`, then a `MANIFEST.txt`.\\n\\n### `backup-dev.ps1`\\n\\nSnapshots Azure DEV. Takes no parameters. Steps:\\n\\n1. Resolves the Azure DEV admin URL from Key Vault (may trigger `az login`).\\n2. Creates `backups/azure-dev//` and writes the same artifacts as the local backup, **minus `roles.sql`** (cluster roles are managed by Azure).\\n3. Uses `Invoke-PgDumpCompatible`, so a host `pg_dump` older than server 18 automatically falls back to `docker run postgres:18`.\\n\\nBoth scripts print a PII warning for Azure DEV backups and list the resulting files with sizes.\\n\\n## Wipe \u2014 `wipe-local.ps1`\\n\\nDestroys the local `assureworx` database and rebuilds it as empty schema + founder account.\\n\\n```powershell\\n.\\\\scripts\\\\db-lifecycle\\\\wipe-local.ps1 -ConfirmWipe\\n.\\\\scripts\\\\db-lifecycle\\\\wipe-local.ps1 -ConfirmWipe -SeedCampbell\\n.\\\\scripts\\\\db-lifecycle\\\\wipe-local.ps1 -ConfirmWipe -RecreatePg18\\n```\\n\\nFlags:\\n\\n| Flag | Effect |\\n| --- | --- |\\n| `-ConfirmWipe` | Required; without it the script prints a warning and exits with code 2 |\\n| `-SkipBackup` | Skip the automatic safety backup of the current local DB |\\n| `-SeedCampbell` | After wiping/migrating, runs `npm run seed -w backend` to create the Campbell demo network, then re-asserts the founder account (the seed uses `founder@`, but `tech@assureworx.com.au` must remain the passwordless goat) |\\n| `-RecreatePg18` | Recreate the local Docker container + volume on `postgres:18` (equivalent to `Reset-LocalPostgres18`) instead of just dropping the database |\\n\\nCore flow:\\n\\n1. Refuses to run unless the target host is `localhost` or `127.0.0.1`.\\n2. Safety-backups local (unless `-SkipBackup`).\\n3. Either `Drop-AndCreateLocalDb` or `Reset-LocalPostgres18`.\\n4. `Invoke-Migrate` (applies all migrations to the empty DB).\\n5. `Ensure-LocalAppLogin` then `Ensure-FounderLocal`.\\n6. Optionally seeds the Campbell demo network and re-runs `Ensure-FounderLocal`.\\n\\nAfter a wipe, the founder signs in at `http://localhost:3000` using **Development sign-in** (passwordless) as `tech@assureworx.com.au` \u2014 this requires `ALLOW_DEV_AUTH=1` on the API. The wipe always re-creates the founder, so company onboarding is always possible afterwards.\\n\\n## Restore \u2014 `restore-to-local.ps1`\\n\\nLoads a previous dump onto the local database.\\n\\n```powershell\\n.\\\\scripts\\\\db-lifecycle\\\\backup-dev.ps1\\n.\\\\scripts\\\\db-lifecycle\\\\restore-to-local.ps1 -LatestDev -ConfirmRestore\\n\\n.\\\\scripts\\\\db-lifecycle\\\\restore-to-local.ps1 `\\n  -DumpPath .\\\\backups\\\\azure-dev\\\\20260808-012300Z -ConfirmRestore\\n```\\n\\nFlags:\\n\\n| Flag | Effect |\\n| --- | --- |\\n| `-LatestDev` | Use the most recent `backups/azure-dev//assureworx.dump` |\\n| `-LatestLocal` | Use the most recent `backups/local//assureworx.dump` |\\n| `-DumpPath ` | Point at a `.dump` file or a backup folder containing `assureworx.dump` |\\n| `-ConfirmRestore` | Required; without it the script prints usage and exits with code 2 |\\n| `-SkipBackup` | Skip the pre-restore safety backup |\\n| `-RecreatePg18` | Force `Reset-LocalPostgres18` before restoring |\\n\\nFlow:\\n\\n1. Refuses to run unless the target host is localhost/127.0.0.1.\\n2. Safety-backups current local (unless `-SkipBackup`).\\n3. If the dump came from Azure DEV (`-LatestDev` or the path contains `azure-dev`) or `-RecreatePg18` is set, runs `Reset-LocalPostgres18`; otherwise just `Drop-AndCreateLocalDb`.\\n4. `pg_restore` with `--no-owner --no-acl --exit-on-error` (local roles and ownership stay as-is).\\n5. `Ensure-LocalAppLogin` (re-binds `app_login` password to `backend/.env`) then `Ensure-FounderLocal`.\\n6. Writes a post-restore inventory snapshot into `backups/local/post-restore-/`.\\n\\n## `inventory.mjs`\\n\\nDumps a live Postgres catalogue for the `core` and `meta` (plus `public`) schemas into a directory:\\n\\n```powershell\\n$env:DATABASE_URL = \\nnode .\\\\scripts\\\\db-lifecycle\\\\inventory.mjs \\n```\\n\\nProduces `inventory.json` and `inventory.md` containing:\\n\\n- Schemas present (`core`, `meta`, `public`)\\n- Roles matching `postgres`, `app_rt`, `app_login`, `awdbadmin`, or `app_%`\\n- Migrations from `meta.schema_migration`\\n- Tables/relations with `relkind`, RLS flag, `FORCE RLS` flag, and comments\\n- Columns (name, type, nullability, default, max length)\\n- RLS policies (permissive, roles, command, qual, with_check)\\n- Indexes (definition, uniqueness, primary)\\n- Row counts per regular table (`COUNT(*)` per table)\\n\\nIt connects via `pg` with `pgSslOptions(connectionString)` from `scripts/pg-ssl.mjs`, so SSL is configured consistently with the rest of the codebase. The script is invoked by the backup and restore PowerShell scripts; it is also runnable standalone.\\n\\n## `ensure-founder.mjs`\\n\\nIdempotently ensures the founder actor exists with platform-admin (`R01`) privileges:\\n\\n```powershell\\n$env:DATABASE_URL = \\nnode .\\\\scripts\\\\db-lifecycle\\\\ensure-founder.mjs tech@assureworx.com.au --dev-auth\\n```\\n\\nBehavior:\\n\\n- **With `--dev-auth`**: sets `subject_txt = dev|` so the local API passwordless path (`ALLOW_DEV_AUTH=1`) accepts the login without Entra. If the actor already exists with a different subject, the subject is rewritten.\\n- **Without `--dev-auth`**: a brand-new actor gets `pending|` so the first Entra sign-in links the subject. Existing Entra-linked subjects are left untouched.\\n\\nWhat it creates/updates, inside a single transaction with `app.bypass = 'on'`:\\n\\n- `core.t_party` rows: an organization party (`K01`/`E01`, \\\"Assureworx Platform\\\") and a person party (`K03`/`M01`, \\\"Assureworx Tech\\\")\\n- `core.t_party_ident` (`D03` = email identifier)\\n- `core.t_actor` with the chosen subject\\n- `core.t_grant` with `role_cd = 'R01'` on the org (if no R01 grant exists)\\n- `core.t_ledger` with `PL2` plan, `LS1` status, 10-year expiry\\n\\nThe PowerShell `Ensure-FounderLocal` wrapper always passes `--dev-auth` and the hardcoded founder email. After a wipe or restore the founder can sign in at `/admin` locally.\\n\\n## Safety &amp; guardrails\\n\\n- **Localhost-only destructive operations.** Both `wipe-local.ps1` and `restore-to-local.ps1` extract the target host and throw if it is not `localhost` or `127.0.0.1`. There is deliberately no Azure wipe script.\\n- **Automatic safety backups.** Wipe and restore back up the current local database first unless `-SkipBackup` is passed.\\n- **Explicit confirmation flags.** `-ConfirmWipe` / `-ConfirmRestore` are mandatory; without them the scripts print instructions and exit with code 2.\\n- **PII handling.** `backups/` is gitignored. Azure DEV dumps are production-like and treated as confidential; the backup script prints a warning.\\n- **Admin-only dumps.** Because RLS is widely `FORCE`d, all dumps and restores run as the admin role (`postgres` locally, Key Vault `DATABASE-URL` for Azure DEV).\\n- **No owner/ACL changes on restore.** `pg_restore` always runs with `--no-owner --no-acl`; local roles (`app_rt`, `app_login`) are re-asserted afterwards by `Ensure-LocalAppLogin`.\\n\\n## Postgres version compatibility\\n\\nAzure DEV runs Postgres 18, while local Docker has historically been 16. Because custom-format dumps are not downward-compatible:\\n\\n- `backup-dev.ps1` automatically uses `docker run postgres:18 pg_dump` when the host client is older than the server (`Invoke-PgDumpCompatible` checks client vs. server major versions).\\n- `restore-to-local.ps1 -LatestDev` recreates the local Docker container as `postgres:18` on a fresh empty volume before restoring (`Reset-LocalPostgres18`).\\n- `wipe-local.ps1 -RecreatePg18` does the same for a clean local slate.\\n\\nThe version check and Docker fallback live in `_common.ps1` so every dump/restore path gets the same behavior.\\n\\n## Integration with the wider codebase\\n\\n| Dependency | How it is used |\\n| --- | --- |\\n| `scripts/pg-ssl.mjs` | `inventory.mjs` and `ensure-founder.mjs` import `pgSslOptions` for consistent TLS configuration |\\n| `backend/.data/local-infra-secrets.env` | Source of the local Postgres superuser password and `APP_LOGIN_PASSWORD`; created by `scripts/secure-local-infra.ps1` |\\n| `backend/.env` | Source of the runtime `DATABASE_URL`; `Ensure-LocalAppLogin` re-syncs the `app_login` password to match |\\n| `package.json` scripts | `npm run migrate` (schema migrations), `npm run seed -w backend` (Campbell demo network) |\\n| Azure Key Vault `kv-aw-dev-aue` | Holds the Azure DEV `DATABASE-URL` secret; accessed via `az keyvault` |\\n| Local API (`ALLOW_DEV_AUTH=1`) | Accepts `dev\\\\|` subjects for passwordless Development sign-in \u2014 the founder escape hatch after a wipe |\\n| `core.t_actor` / `core.t_grant` / `core.t_party` | The tables `ensure-founder.mjs` writes; the application's identity model |\\n\\nThe `.bat` wrappers (`backup-local.bat`, `backup-dev.bat`, `wipe-local.bat`, `restore-to-local.bat`) simply invoke the corresponding `.ps1` with `-ExecutionPolicy Bypass`, so the same commands work from `cmd` or CI shells.\",\"development-tooling-scripts-scripts\":\"# Development Tooling &amp; Scripts \u2014 scripts\\n\\n# Development Tooling &amp; Scripts \u2014 `scripts/`\\n\\n## Overview\\n\\nThe `scripts/` directory is the operational control plane for the Assureworx platform. It contains every non-application script needed to build, deploy, configure, and maintain the system: Azure Container Apps deployments for DEV and TEST, Entra External ID (CIAM) tenant configuration, PostgreSQL migrations and data bootstrap, ISMS document generation, and a handful of Windows-local developer utilities.\\n\\nThe scripts are intentionally environment-aware. Most Azure-facing scripts encode the DEV/TEST resource names, subscription IDs, and tenant IDs as constants at the top of the file, and several switch Azure CLI context between two directories mid-run (the Azure sponsorship subscription and the CIAM tenant). This makes them safe to run repeatedly \u2014 nearly all are idempotent.\\n\\n## Script Inventory\\n\\n| Script | Category | Purpose |\\n|---|---|---|\\n| `deploy-dev.bat` / `deploy-dev.ps1` | Deployment | Build and deploy API + Web to DEV Container Apps |\\n| `deploy-test.ps1` | Deployment | Build and deploy API + Web to TEST Container Apps |\\n| `bind-dev-domains.ps1` | Deployment | Bind `dev.assureworx.com.au` / `api.dev.assureworx.com.au` and cut over env |\\n| `bind-test-domains.ps1` | Deployment | Bind `beta.assureworx.com.au` / `api.beta.assureworx.com.au` and cut over env |\\n| `entra-app-reg.ps1` | CIAM | Create the DEV Entra app registration + service principal + secret |\\n| `entra-app-reg-test.ps1` | CIAM | Create the TEST Entra app registration + service principal + secret |\\n| `enable-ciam-native-auth.ps1` | CIAM | Enable Native Auth on the DEV app + Graph delegated scopes + admin consent |\\n| `enable-ciam-otp-email.ps1` | CIAM | Register the `OnOtpSend` custom extension pointing at the API's ACS email handler |\\n| `enable-ciam-mfa-dev.ps1` | CIAM | Create a Conditional Access policy requiring MFA for the DEV web app |\\n| `apply-ciam-branding.ps1` | CIAM | Apply Assureworx branding + logos to the CIAM hosted login |\\n| `apply-004.mjs` | Database | Apply `db/migrations/004_account_invites.sql` in a transaction |\\n| `migrate.mjs` | Database | General migration runner (uses `pgSslOptions`) |\\n| `bootstrap-platform-admin.mjs` | Database | Idempotently create one platform-admin actor linked by email |\\n| `gen-data-catalogue.mjs` | Database | Generate the data classification catalogue for the `public.*` and `core.*` schemas |\\n| `reset-test-identity.mjs` | Database | Reset TEST identity data (`wipeDatabase`, `wipeEntra`, `purgeParty`) |\\n| `wipe-test-app-data.mjs` | Database | Wipe TEST application data (uses `pgSslOptions`) |\\n| `pg-ssl.mjs` | Shared | Derive `pg.Client` SSL options from a `DATABASE_URL` |\\n| `db-lifecycle/*.mjs` | Database | Lifecycle helpers (`ensure-founder.mjs`, `inventory.mjs`) |\\n| `build-isms-starter-docs.py` | ISMS | Generate controlled Word documents from `isms/**/*.md` sources |\\n| `isms-progress.mjs` | ISMS | Progress reporting for the ISMS plan |\\n| `slice-tradie-icons.py` | Image tooling | Slice/pad a sprite sheet into individual tradie icons |\\n| `free-ports.ps1` | Local utility | Kill processes listening on given TCP ports |\\n| `force-shutdown-10min.bat` / `cancel-shutdown.bat` | Local utility | Schedule/cancel a forced Windows shutdown |\\n\\n## Azure Deployment\\n\\n### DEV \u2014 `deploy-dev.ps1`\\n\\nThe DEV deploy path is the most complete deployment flow in the repo. The `.bat` wrapper (`deploy-dev.bat`) normalizes arguments and pauses on failure; the `.ps1` does the real work.\\n\\n```\\nscripts\\\\deploy-dev.bat                 # All, local Docker\\nscripts\\\\deploy-dev.bat Api             # API only\\nscripts\\\\deploy-dev.bat All cloud       # ACR cloud build instead of local Docker\\n```\\n\\n**Parameters**\\n\\n| Parameter | Meaning |\\n|---|---|\\n| `-Target` | `All`, `Api`, or `Web` (default `All`) |\\n| `-CloudBuild` | Build in ACR via `az acr build` instead of local Docker |\\n| `-SkipSmoke` | Skip post-deploy HTTP checks |\\n| `-EnableDevAuth` | Explicitly enable passwordless dev auth (founder-demo exception) |\\n| `-AllowDirty` | Deploy from a dirty working tree; marks the build non-promotable |\\n\\n**Flow**\\n\\n1. **Validate Git state.** The script resolves `HEAD`, fails on a dirty tree unless `-AllowDirty`, and stamps the deployment with provenance.\\n2. **Image tag.** DEV uses `sha-&lt;12-char-commit&gt;`, or `sha-&lt;12-char-commit&gt;-dirty-` for dirty builds. The image is always resolved to a digest via `Get-ImageDigest` before deployment, so Container App revisions are immutable.\\n3. **Build.** `Build-ImageLocal` runs `docker build` + `docker push` (suppressing push output so it doesn't pollute the return value); `Build-ImageCloud` runs `az acr build` and waits via `Wait-AcrRun`. Both pass `DEPLOY_COMMIT`, `DEPLOY_BUILT_AT`, and `DEPLOY_DIRTY` as build args.\\n4. **Deploy.** `Deploy-App` updates the Container App image with a new revision suffix `dHHmmss`, and sets `DEPLOY_*` env vars on the app.\\n5. **API posture.** The API is configured **Entra-only by default** (`ASSUREWORX_ENV=dev`, `HOST=0.0.0.0`; `ALLOW_DEV_AUTH` and `DEV_AUTH_SECRET` explicitly removed). With `-EnableDevAuth`, it creates/reads `DEV-AUTH-SECRET` in Key Vault and wires it as a `secretref`. The script then syncs Redis URL, ACS email (via `wire-acs-managed-identity.ps1`), Apple Wallet secrets, and App Insights connection string from Key Vault onto the API.\\n6. **Web wiring.** `API_ORIGIN` is always set to the DEV API URL. `DEV_AUTH_SECRET` is only wired when `-EnableDevAuth` is passed.\\n7. **Site gate.** `Ensure-DevSiteGateSecrets` creates `DEV-SITE-BASIC-USER` / `DEV-SITE-BASIC-PASSWORD` in Key Vault if missing (auto-generated 24-char password), and `Sync-DevSiteGateToWeb` applies them plus `DEV_SITE_GATE_ENABLED` (defaults to `true` when unset) onto the web app.\\n8. **Smoke test.** After a 20s settle, `Invoke-Smoke` checks:\\n   - `GET {ApiUrl}/health` \u2014 verifies `success` and that `data.version.commit` matches the deployed commit\\n   - `GET {ApiUrl}/api/v1/auth/entra/status` \u2014 verifies Entra/Native Auth enabled, and that `allowDevAuth` is false unless `-EnableDevAuth`\\n   - The web gate: unauthenticated `/auth/login` must return `401`; Basic-Auth request must return `200` (using `curl.exe` to avoid `Invoke-WebRequest` caching credentials)\\n\\n```mermaid\\nflowchart TD\\n    PS[\\\"deploy-dev.ps1\\\"] --&gt; LOGIN[\\\"Ensure-LoggedIn\\\"]\\n    LOGIN --&gt; MODE{\\\"CloudBuild?\\\"}\\n    MODE --&gt;|\\\"no\\\"| LOCAL[\\\"Build-ImageLocal\\\"]\\n    MODE --&gt;|\\\"yes\\\"| CLOUD[\\\"Build-ImageCloud\\\"]\\n    LOCAL --&gt; DIGEST[\\\"Get-ImageDigest\\\"]\\n    CLOUD --&gt; DIGEST\\n    DIGEST --&gt; DEPLOY[\\\"Deploy-App\\\"]\\n    DEPLOY --&gt; GATE[\\\"Sync-DevSiteGateToWeb\\\"]\\n    GATE --&gt; SMOKE[\\\"Invoke-Smoke\\\"]\\n```\\n\\n### TEST \u2014 `deploy-test.ps1`\\n\\n`deploy-test.ps1` mirrors the DEV flow with a few deliberate differences:\\n\\n- Fixed image tag `test` instead of commit-based tags.\\n- Container App revision suffix `tHHmmss`.\\n- Sets `ASSUREWORX_ENV=test`, `NODE_ENV=production`, and all Azure Storage container env vars (`AZURE_STORAGE_ACCOUNT_NAME=stawtestaue`, document/profile/logo/temp containers, notifications queue).\\n- Restricts auth to a single allowlisted user: `AUTH_JIT_PROVISION=0` and `AUTH_EMAIL_ALLOWLIST=tech@assureworx.com.au`.\\n- Explicitly removes `ALLOW_DEV_AUTH` if present.\\n- Syncs 11 secrets from Key Vault (`Sync-SecretToApi`): `DATABASE-URL`, `SESSION-SECRET`, `REDIS-URL`, `APPLICATIONINSIGHTS-CONNECTION-STRING`, `ACS-CONNECTION-STRING`, `ACS-EMAIL-SENDER`, and the five `ENTRA-*` secrets.\\n- Updates ingress target port (API `4000`, Web `3000`) after deploy.\\n- No site gate, no deploy provenance env vars, no wallet sync.\\n\\n### Custom domain binding\\n\\n`bind-dev-domains.ps1` and `bind-test-domains.ps1` follow the same shape:\\n\\n1. **Assert DNS** (`Assert-Dns`) \u2014 resolves CNAMEs for the custom hostnames pointing at the Container App FQDNs, plus the `asuid.` TXT records containing the Azure app-service verification ID. DNS must be in place at Crazy Domains first; failure throws with a pointer to the runbook.\\n2. **Bind hostnames** \u2014 `az containerapp hostname bind --validation-method CNAME` for web and API apps.\\n3. **Certificates** \u2014 unless `-SkipCert`, lists bound hostnames as evidence (the bind step itself requests managed certs). `-SkipCert` can be used when certificates are still provisioning.\\n4. **Env cutover** \u2014 unless `-SkipEnvCutover`, updates the Key Vault secret `ENTRA-REDIRECT-URI`, pushes it to the API Container App as the `entra-redirect-uri` secret, and sets `PUBLIC_APP_URL`, `CORS_ORIGIN`, and `ENTRA_REDIRECT_URI=secretref:...` on the API. The TEST script additionally sets the auth allowlist on the API and `API_ORIGIN` on the Web app.\\n\\nThe DEV script prints a reminder to add `https://dev.assureworx.com.au/auth/callback` to the CIAM app registration.\\n\\n## Entra External ID (CIAM) Configuration\\n\\nThe Entra scripts operate in a two-directory model:\\n\\n- **Directory A** \u2014 Azure sponsorship tenant (`e0045fa5-8177-4976-a60a-892074203e28`, subscription `17065bb7-1e80-4ef0-a043-4302a7f37af8`) hosting ACR, Container Apps, and Key Vault.\\n- **Directory B** \u2014 CIAM tenant (`0286fa4b-f51a-4a2c-9dfc-f7f6ca4c4513` for DEV, `37fe079d-2892-4140-9de6-a744aba5beab` for TEST) hosting app registrations, branding, and Conditional Access.\\n\\nScripts that need both (e.g., `entra-app-reg.ps1 -PushToKeyVault`, `enable-ciam-otp-email.ps1`) switch with `az account set` mid-run.\\n\\nThe recommended bootstrap sequence for a new CIAM tenant:\\n\\n```mermaid\\nflowchart LR\\n    START[\\\"New CIAM tenant\\\"] --&gt; REG[\\\"entra-app-reg.ps1\\\"]\\n    REG --&gt; NATIVE[\\\"enable-ciam-native-auth.ps1\\\"]\\n    REG --&gt; MFA[\\\"enable-ciam-mfa-dev.ps1\\\"]\\n    REG --&gt; BRAND[\\\"apply-ciam-branding.ps1\\\"]\\n    NATIVE --&gt; OTP[\\\"enable-ciam-otp-email.ps1\\\"]\\n```\\n\\n### App registration \u2014 `entra-app-reg.ps1` / `entra-app-reg-test.ps1`\\n\\nCreates the `Assureworx Web DEV` / `Assureworx Web TEST` app registration in the CIAM tenant with:\\n\\n- `--sign-in-audience AzureADMyOrg` (required for the user-flow app picker)\\n- Web redirect URIs: `http://localhost:3000/auth/callback` plus the environment's FQDN (`ca-aw-web-...azurecontainerapps.io`, and `beta.assureworx.com.au` for TEST)\\n- ID token issuance enabled\\n- A service principal (`az ad sp create`) so the app appears in user flows\\n- A client secret via `az ad app credential reset`\\n\\nWith `-PushToKeyVault`, it switches to the sponsorship subscription and writes `ENTRA-TENANT-ID`, `ENTRA-CLIENT-ID`, `ENTRA-CLIENT-SECRET`, `ENTRA-AUTHORITY`, and `ENTRA-REDIRECT-URI` into `kv-aw-dev-aue` / `kv-aw-test-aue`. The authority is `https://assureworxdev1.ciamlogin.com/` (or `assureworxtest1.ciamlogin.com` for TEST).\\n\\n### Native authentication \u2014 `enable-ciam-native-auth.ps1`\\n\\nPatches the DEV app registration to `isFallbackPublicClient=true` and `nativeAuthenticationApisEnabled=all` so the Express BFF can proxy signup/signin/SSPR. It then:\\n\\n1. Finds the Microsoft Graph service principal (`00000003-0000-0000-c000-000000000000`).\\n2. Adds delegated scopes `openid`, `profile`, `email`, `offline_access`, and `User.Read` to the app's `requiredResourceAccess`.\\n3. Creates or updates an `AllPrincipals` OAuth2 permission grant for the app's service principal.\\n\\nWithout that admin-consent grant, Native Auth password sign-in fails with `AADSTS65001` / `consent_required` \u2014 which historically surfaced to users as *\\\"Email or password is incorrect.\\\"* Email OTP for SSPR remains a portal step (Entra ID \u2192 Authentication methods \u2192 Email OTP \u2192 Enable for All users).\\n\\n### OTP email extension \u2014 `enable-ciam-otp-email.ps1`\\n\\nThis script routes CIAM's OTP verification emails through Assureworx-branded ACS mail instead of Microsoft's generic template. It:\\n\\n1. Creates or reuses a separate extension app registration (`Assureworx OTP Email Extension DEV`) and sets its identifier URI to `api:///`.\\n2. Creates an `OnOtpSendCustomExtension` (Graph beta endpoint) with:\\n   - `targetUrl` = `POST {ApiBaseUrl}/api/v1/auth/entra/otp-email`\\n   - `azureAdTokenAuthentication` with `resourceId` = extension app ID\\n   - A 2000ms client timeout\\n3. Creates an `onEmailOtpSendListener` (priority `500`) scoped to the Assureworx Web DEV app.\\n4. Wires `ENTRA_OTP_EXTENSION_AUDIENCE=` onto the DEV API Container App so the API's JWT validation accepts Entra's callback token.\\n\\nEntra authenticates to the callback with an OAuth bearer token whose audience is the extension app ID \u2014 there is **no shared callback API key**. If Graph rejects the extension creation (common for limited CIAM admin roles), the script prints portal steps, still wires the audience env var, and exits `0`.\\n\\n### MFA policy \u2014 `enable-ciam-mfa-dev.ps1`\\n\\nCreates or updates an enabled Conditional Access policy requiring MFA for the Assureworx Web DEV enterprise application, targeting all users with `grantControls.builtInControls = [\\\"mfa\\\"]`. It documents the separation of concerns:\\n\\n- **Email OTP** (via the `OnOtpSend` extension) covers signup verification and password reset.\\n- **Sign-in MFA** is enforced by Conditional Access as a second factor for returning password sign-ins.\\n\\nIf Graph rejects the policy creation, it prints the manual Entra admin center steps and exits `0`. Validation guidance is printed at the end: sign in with a password, expect an MFA challenge, and export a sign-in log as evidence.\\n\\n### Tenant branding \u2014 `apply-ciam-branding.ps1`\\n\\nUses a Graph token for the CIAM tenant (obtained via `az account get-access-token --tenant  --resource https://graph.microsoft.com`) so it does **not** require switching `az account`. It:\\n\\n1. Creates the `en-US` branding localization if absent.\\n2. Patches colors (`#171A20` background/header), sign-in page text (\\\"Assureworx | Verify once. Trust everywhere.\\\"), username hint, and \\\"Reset password\\\" link text.\\n3. Uploads logos through `Invoke-WebRequest` PUT calls to `/branding/localizations/en-US/{slot}`. `Resize-UnderLimit` downscales images iteratively until they fit Entra's size/byte constraints:\\n\\n| Slot | Max dimensions | Max bytes |\\n|---|---|---|\\n| `bannerLogo` | 280\u00d760 | 45,000 |\\n| `headerLogo` | 200\u00d740 | 9,000 |\\n| `squareLogo` / `squareLogoDark` | 240\u00d7240 | 45,000 |\\n| `favicon` | 32\u00d732 | 4,500 |\\n\\nDefault sources are `frontend/public/logo-dark.png`, `logo.png`, and `fav.png`. `-SkipLogos` skips the image pipeline and only patches text/colors.\\n\\n## Database Tooling\\n\\nAll `.mjs` database scripts share a common pattern:\\n\\n```js\\nimport pg from \\\"pg\\\";\\nimport { pgSslOptions } from \\\"./pg-ssl.mjs\\\";\\n\\nconst client = new pg.Client({ connectionString, ...pgSslOptions(connectionString) });\\n```\\n\\n`pg-ssl.mjs` is the shared helper that derives `pg.Client` SSL options from the connection string, so TLS behaviour is consistent across every script. Scripts require `DATABASE_URL`, run inside an explicit transaction (`BEGIN` \u2026 `COMMIT` / `ROLLBACK`), and call `client.end()` in a `finally` block.\\n\\n### `apply-004.mjs`\\n\\nA one-off migration runner that reads `db/migrations/004_account_invites.sql`, executes it inside a transaction, and records the version in `public.schema_migrations` with `ON CONFLICT DO NOTHING`. On failure it rolls back, logs the error message, and exits `1`. This is the template for applying numbered migration files after the canonical `migrate.mjs` runner.\\n\\n### `bootstrap-platform-admin.mjs`\\n\\nMinimal, idempotent bootstrap for a platform administrator \u2014 deliberately **not** the Campbell seed. Usage:\\n\\n```\\nDATABASE_URL=... node scripts/bootstrap-platform-admin.mjs tech@assureworx.com.au\\n```\\n\\nIt enables the RLS bypass (`SELECT set_config('app.bypass_rls', 'on', true)`) to match the `seed.ts` posture, then:\\n\\n1. Checks for an existing non-deleted actor with the email; exits `0` if present.\\n2. Creates an organisation party (`kind_cd='K01'`, `class_cd='E01'`) \u2014 \\\"Assureworx Platform\\\".\\n3. Creates a person party (`K03`/`M01`) with the admin's name and email.\\n4. Adds a `D03` (email) party identifier.\\n5. Creates the actor with a provisional subject `pending|` \u2014 the first Entra login with that email rewrites it to the real `oid`/`sub`.\\n6. Grants `R01` (platform admin) on the org and creates a 10-year `PL2`/`LS1` ledger entry.\\n\\n### `gen-data-catalogue.mjs`\\n\\nGenerates a data classification catalogue covering both the legacy `public.*` tables (from migration 001) and the current `core.*` schema. Each column is assigned one of five classifications \u2014 `C`, `CIC`, `PII`, `SPII`, or `GP` \u2014 with per-column notes. Legacy tables carry the note *\\\"LEGACY public.* from migration 001 \u2014 superseded by core.*; classify if still present on a DB.\\\"* The generator also classifies JSONB subfields (e.g., `attrs.geo.lat`) and documents that artifact blob bytes become `PII`/`SPII` when the file is ID-like. The `rows` array is serialized with `writeFileSync`; run it after schema changes to refresh the catalogue.\\n\\n### TEST reset tooling\\n\\n`reset-test-identity.mjs` and `wipe-test-app-data.mjs` tear down TEST environment state. From the call graph: `reset-test-identity.mjs` composes `wipeDatabase` (via `pgSslOptions`), `wipeEntra` (using `azBin`), and `purgeParty` (using `softDeleteSql`). These are safety-valve scripts for reproducing clean TEST identity flows.\\n\\n## ISMS Document Generation \u2014 `build-isms-starter-docs.py`\\n\\nThis script produces the controlled Word deliverables for the Assureworx ISMS from Markdown sources under `isms/`. It outputs to `deliverables/Assureworx_ISMS/`.\\n\\n### Outputs\\n\\n| Output | Sources | Preset |\\n|---|---|---|\\n| `00_Shareholder_Review/ISMS_GOV_001_002_Review_Pack_v0.1.docx` | `governance/shareholder-brief.md`, `governance/approval-record.md` | compact |\\n| `03_Plans/ISMS_PLAN_001_v0.2_For_Review.docx` | `getting-started.md` | compact |\\n| `01_Policies/ISMS_POL_001_v0.2_For_Review.docx` | `policies/information-security-policy.md` | business |\\n| `01_Policies/ISMS_POL_002_v0.2_For_Review.docx` | `policies/data-classification-and-handling-policy.md` | business |\\n| `02_People/ISMS_TPL_001_NDA_v0.2_Legal_Review.docx` | `templates/confidentiality-and-non-disclosure-agreement.md` | business |\\n| `02_People/ISMS_People_Pack_v0.2_For_Review.docx` | onboarding + offboarding + policy-acknowledgement templates | compact |\\n\\n### Pipeline\\n\\n`main()` calls `build_document()` for each output. `build_document` is the orchestrator:\\n\\n- `configure_styles()` \u2014 applies a preset (`business` for policies, `compact` for plans/packs) controlling body spacing, heading sizes/colors, list indents, and table shading. Adds a custom `\\\"Review Note\\\"` paragraph style.\\n- `set_page_and_furniture()` \u2014 A4 portrait, margins, header logo (from `Raw/logo.png`), and a footer containing `Assureworx | {document_id} | Version {version} | {status} | Page {PAGE field}`.\\n- `add_title_block()` \u2014 renders the title, subtitle, document metadata line, preparer line, and an optional orange legal-review note.\\n- `add_markdown()` \u2014 parses the Markdown source and emits Word content. Supported syntax:\\n\\n| Markdown | Word output |\\n|---|---|\\n| `# ` / `## ` / `### ` | Heading 1/2/3 (H1 skipped unless `include_source_titles`) |\\n| `- ` | List Bullet |\\n| `- [ ]` | Checkbox paragraph (`\u2610`) |\\n| `1. ` | Numbered paragraph with a **fresh numbering instance per item** |\\n| `\\\\| ... \\\\|` | Grid table with fixed column widths and repeated header row |\\n| `&gt; ` | Shaded \\\"Review Note\\\" callout |\\n| `` | Explicit page break |\\n| `**bold**`, `` `code` ``, `[link](url)` | Inline bold, Consolas code, external hyperlink |\\n| Front matter (`---` \u2026 `---`) | Stripped |\\n\\nNotable behaviours: a heading containing \\\"signatures\\\" forces a page break; a heading of exactly \\\"Related\\\" terminates the source; column widths for tables are chosen heuristically from the column count and header names (`column_widths()`); `new_decimal_numbering()` creates a new `w:num` instance per numbered paragraph so long lists keep their Markdown ordinals stable across page breaks and combined documents.\\n\\n## Local Developer Utilities\\n\\n- **`free-ports.ps1`** \u2014 accepts one or more ports (`free-ports.ps1 3000 4000`), finds owning PIDs via `Get-NetTCPConnection`, force-kills them, and retries up to three times. Exits `1` if a port is still occupied.\\n- **`force-shutdown-10min.bat`** / **`cancel-shutdown.bat`** \u2014 paired Windows utilities. The first schedules `shutdown /s /f /t 600`; the second cancels a pending shutdown with `shutdown /a`.\\n- **`slice-tradie-icons.py`** \u2014 sprite-sheet processing (`inspect_sheet`, `slice_pack`, `trim_and_pad`, `content_bbox`, `key_out_green`). Used to regenerate the tradie icon set from a source sheet.\\n- **`isms-progress.mjs`** \u2014 renders progress bars/status for the ISMS implementation plan (exports `bar()`).\\n\\n## Conventions\\n\\n- **Strict PowerShell error handling.** Every `.ps1` sets `$ErrorActionPreference = \\\"Stop\\\"`. Where a native command (`az`, `docker`) writes harmless warnings to stderr \u2014 which PowerShell would otherwise treat as a terminating `NativeCommandError` \u2014 scripts temporarily set `$ErrorActionPreference = \\\"Continue\\\"` around the call, capture `$LASTEXITCODE`, and restore the original value. They also redirect stdout to `$null` where it would pollute function return values (e.g., `docker push`).\\n- **Idempotency.** Key Vault secrets are created only when absent; Graph resources (app registrations, extensions, listeners, CA policies) are looked up by name/ID before creation; DB bootstraps check for existing rows.\\n- **Secret indirection.** Secrets live in Key Vault, are copied to Container App secrets with lowercase-kebab names, and are referenced from env vars as `secretref:name`. The deploy/bind scripts never hardcode credentials.\\n- **Deployment provenance.** DEV images and Container Apps carry `DEPLOY_COMMIT`, `DEPLOY_BUILT_AT`, `DEPLOY_DIRTY`, and (on the app) `DEPLOY_PROMOTABLE`; the API exposes these via `/health` and the smoke test verifies them.\\n- **Windows-first tooling.** The operational scripts assume a Windows developer machine (PowerShell, `.bat` wrappers, `System.Drawing`). The `.mjs` scripts are cross-platform Node.\\n\\n## Connections to the Rest of the Codebase\\n\\n| Codebase area | Related scripts | Contract |\\n|---|---|---|\\n| `backend/` | `deploy-dev.ps1`, `deploy-test.ps1`, `enable-ciam-otp-email.ps1`, `bind-*.ps1` | Dockerfile build args; `/health` version payload; `/api/v1/auth/entra/status`; `/api/v1/auth/entra/otp-email` webhook; env vars (`ASSUREWORX_ENV`, `ALLOW_DEV_AUTH`, `ENTRA_OTP_EXTENSION_AUDIENCE`, `AUTH_JIT_PROVISION`, `AUTH_EMAIL_ALLOWLIST`) |\\n| `frontend/` | `deploy-dev.ps1`, `deploy-test.ps1`, `bind-*.ps1`, `apply-ciam-branding.ps1` | `API_ORIGIN` env var; `/auth/login` + `/auth/callback`; `frontend/public/logo*.png` used as CIAM branding defaults |\\n| `db/migrations/` | `apply-004.mjs`, `migrate.mjs` | SQL migration files; `public.schema_migrations` version tracking |\\n| `core.*` schema | `bootstrap-platform-admin.mjs`, `gen-data-catalogue.mjs` | `t_party`, `t_party_ident`, `t_actor`, `t_grant`, `t_ledger`; RLS bypass via `app.bypass_rls` |\\n| `isms/` + `deliverables/` | `build-isms-starter-docs.py` | Markdown sources \u2192 controlled DOCX outputs |\\n| Azure resources | deploy/bind scripts | ACR (`crawdevaue`, `crawtestaue`), Container Apps (`ca-aw-*-dev-aue`, `ca-aw-*-test-aue`), Key Vault (`kv-aw-dev-aue`, `kv-aw-test-aue`) |\\n| Entra CIAM tenants | Entra scripts | DEV tenant `0286fa4b-...`, TEST tenant `37fe079d-...`; app registrations, service principals, CA policies, branding localizations |\\n\\nThe tightest coupling is between the deployment scripts and the API's runtime contract: the smoke test asserts the exact commit hash reported by `/health` matches the deployed image, the auth-status endpoint must report `nativeAuth=true` and `allowDevAuth=false` under the default production-like DEV posture, and the web app must honour the Basic-Auth site gate when `DEV_SITE_GATE_ENABLED=true`. Any change to those endpoints or env vars must be mirrored in these scripts.\",\"development-tooling-scripts\":\"# Development Tooling &amp; Scripts\\n\\n# Development Tooling &amp; Scripts\\n\\nThis module groups every non-application script and development tool in the Assureworx repository. It spans the full lifecycle of a feature: scaffolding the module layout, provisioning local infrastructure, manipulating databases, verifying backend subsystems, and deploying to Azure.\\n\\n## Sub-modules\\n\\n- [backend-scripts](backend-scripts.md) \u2014 TypeScript and JavaScript probes that exercise backend subsystems the way a developer would. Some run offline (`probe-extraction.ts`), some call paid cloud services (`probe-cloud-read.ts`), and some drive a live server (`probe-webhook.mjs`). Also contains sharp-edged dev utilities like `purge-party.mjs`.\\n- [backend-src](backend-src.md) \u2014 a scaffold template (`_module.template.md`) for creating new backend feature modules as bounded contexts. Defines the directory layout that keeps every module extractable from the monolith.\\n- [frontend-public](frontend-public.md) \u2014 a standalone image preprocessor, `strip_text.py`, that crops, pads, and centers status badge PNGs used by the frontend.\\n- [scripts](scripts.md) \u2014 the operational control plane: Azure Container Apps deployment for DEV/TEST, Entra External ID (CIAM) tenant configuration, PostgreSQL migrations, ISMS document generation, and local developer utilities like `provision-pc.mjs`.\\n- [scripts-backup](scripts-backup.md) \u2014 interactive Windows batch scripts for repository backups and syncing repo content into the wiki knowledge base.\\n- [scripts-db-lifecycle](scripts-db-lifecycle.md) \u2014 backup, wipe, and restore tooling for local Docker Postgres and Azure DEV Postgres. Dumps include schema, data, indexes, and RLS policies.\\n\\n## How the sub-modules fit together\\n\\nThe sub-modules are deliberately independent, but one explicit dependency exists: `scripts-db-lifecycle` imports the shared `pgSslOptions` helper from `scripts` (`pg-ssl.mjs`), as does `scripts/migrate.mjs`. The `scripts` module is therefore the shared hub for Postgres connectivity and operational scripts.\\n\\n`backend-scripts` is self-contained: its probes verify behavior end-to-end without importing from the other modules. However, database-centric probes are typically run after a `scripts-db-lifecycle` restore so they exercise a known dataset. `backend-src` is a development aid rather than an executable script; it constrains the shape of new backend features, which are later probed and deployed using the other sub-modules. `frontend-public` and `scripts-backup` are standalone and have no dependencies on the rest of the module.\\n\\n```mermaid\\ngraph TD\\n    backend[backend-scriptsProbes &amp; dev utilities] ---|manual workflow| db\\n    src[backend-srcBounded-context template]\\n    front[frontend-publicBadge image processor]\\n    backup[scripts-backupWindows backups &amp; wiki sync]\\n    ops[scriptsAzure, CIAM, migrations, ISMS]\\n    db[scripts-db-lifecyclePostgres dump/restore/wipe]\\n\\n    db --&gt;|imports pgSslOptions| ops\\n    backend --- db\\n```\\n\\n## Key workflows\\n\\n1. **Database lifecycle and verification** \u2014 Restore a snapshot with `scripts-db-lifecycle`, bring the schema forward with `scripts/migrate.mjs`, then run `backend-scripts` probes to confirm the restored state behaves correctly.\\n2. **Machine and environment provisioning** \u2014 `scripts/provision-pc.mjs` bootstraps a Windows dev machine, while the Azure-facing scripts in `scripts` deploy and configure DEV/TEST infrastructure. New backend features follow the `backend-src` template and are verified by `backend-scripts` probes before deployment.\\n3. **Documentation and backup** \u2014 `scripts/build-isms-starter-docs.py` generates ISMS documents, and `scripts-backup` syncs repository content into the wiki, keeping generated documentation backed up alongside source.\\n4. **Frontend asset maintenance** \u2014 `frontend-public/strip_text.py` is run manually whenever badge images change, producing the centered `_mark.png` files consumed by the frontend.\",\"directory-trades-network-backend-src\":\"# Directory, Trades &amp; Network \u2014 backend-src\\n\\n# Directory, Trades &amp; Network \u2014 Backend\\n\\n## Overview\\n\\nThis module implements the two sides of the Assureworx engagement lifecycle:\\n\\n- **Principal-side (Tier 1)** \u2014 `directoryRouter` and `nodesRouter` let a Tier 1 organisation see and manage its network of trade companies and individuals, organise them into projects and sites, and assign trades to those sites.\\n- **Trade-side** \u2014 `partiesRouter` is the self-service surface for trade accounts: profile and compliance dossier, team management, site placements, and account closure.\\n\\nAll three routers live under `backend/src/modules/` and share a small network helper in `backend/src/shared/services/network.ts`. The codebase refers to this whole area as \\\"Directory, Trades &amp; Network\\\".\\n\\n## Domain Model &amp; Key Codes\\n\\nThe module operates on a small set of core tables:\\n\\n| Entity | Table | Notes |\\n|---|---|---|\\n| Party | `t_party` | Companies (`K02`) and individuals (`K03`); the Tier 1 principal is also a party |\\n| Relationship | `t_rel` | Directed edges between parties (`L01` employment, `L02` company\u2192principal, `L03` person\u2192principal, `L04` subcontractor) |\\n| Node | `t_node` | Projects (`N01`) and sites (`N02`); sites have `parent_node_id` \u2192 project |\\n| Link | `t_link` | Assignment of a party to a node, owned by the principal; carries a readiness `state_cd` |\\n| Request | `t_request` | Invites and introducer chain; `U01` pending invite, `U03` accepted/introduced |\\n| Identity | `t_party_ident` | ABNs (`D01`) |\\n| Tags | `t_party_tag` | Trade classifications (`tag_cd` mapped to display names via `TAG_NAMES`) |\\n\\nImportant code values used throughout:\\n\\n| Code | Meaning |\\n|---|---|\\n| `L01` / `L02` / `L03` / `L04` | employed-by / company-in-network / person-in-network / subcontractor |\\n| `N01` / `N02` | project / site |\\n| `J01` / `J02` / `J03` | link readiness: pending / ready / blocked |\\n| `U01` / `U03` | pending invite / accepted request (introducer edge) |\\n| `S02` / `S03` / `S04` | party status: verified / action_required / suspended |\\n| `RS1` / `RS2` | relationship status: active / retired |\\n| `R04` | company admin grant |\\n\\nOne relationship is not stored in `t_rel`: **company\u2192company introductions**. `t_rel` expresses only the star of `L02` edges onto the Tier 1 org. The introducer tree is reconstructed from the `t_request` chain (`origin_party_id` \u2192 `target_party_id` for `U03` requests). The lineage endpoint depends on this.\\n\\n## Router Map\\n\\n```mermaid\\ngraph TD\\n    T1[\\\"Tier 1 Principalscope org (t_party)\\\"]\\n    C1[\\\"Trade Company AK02\\\"]\\n    C2[\\\"Trade Company BK02\\\"]\\n    P1[\\\"Direct PersonK03\\\"]\\n    E1[\\\"Employee / SubconK03\\\"]\\n    PR[\\\"ProjectN01\\\"]\\n    S1[\\\"SiteN02\\\"]\\n    LK[\\\"t_linkstate J01\u2013J03\\\"]\\n\\n    C1 -- \\\"L02\\\" --&gt; T1\\n    C2 -- \\\"L02\\\" --&gt; T1\\n    P1 -- \\\"L03\\\" --&gt; T1\\n    E1 -- \\\"L01 / L04\\\" --&gt; C1\\n    C1 -. \\\"introducedBy (t_request U03)\\\" .-&gt; C2\\n    T1 -- \\\"owns\\\" --&gt; PR\\n    PR -- \\\"parent_node\\\" --&gt; S1\\n    S1 -- \\\"node_id\\\" --&gt; LK\\n    C1 -- \\\"party_id\\\" --&gt; LK\\n```\\n\\n| Router | File | Audience |\\n|---|---|---|\\n| `directoryRouter` | `modules/directory/index.ts` | Tier 1 admins/users: network directory, lookup, lineage, profiles |\\n| `nodesRouter` | `modules/nodes/index.ts` | Tier 1 admins/users: projects, sites, geocoding, assignments |\\n| `partiesRouter` | `modules/parties/index.ts` | Trade accounts: profile, team, engagements, closure |\\n\\n## Shared Network Service (`shared/services/network.ts`)\\n\\nTwo exported helpers are used by both the routers and the reporting module:\\n\\n- **`getNetworkParties(db, scopeOrgId)`** \u2014 returns every party reachable from a Tier 1 org: companies via `L02`, individuals via `L03`, and individuals employed (`L01`) by those companies. The SQL is the same `direct` + `employed` CTE shape used by the directory list. Results are deduplicated by `party_id`; a person who is both directly networked and employed appears once.\\n- **`isInNetwork(db, scopeOrgId, partyId)`** \u2014 boolean check for direct `L02`/`L03` membership or employment under an `L02` company. Used by site assignment to prevent placing parties outside the principal's network.\\n\\nNote: `t_rel` is **not** RLS-protected. The scope filter is applied explicitly in SQL \u2014 `WHERE r.to_party_id = $1` \u2014 which is the isolation boundary for these queries.\\n\\n## Directory Router\\n\\n### Endpoints\\n\\n| Method &amp; Path | Roles | Description |\\n|---|---|---|\\n| `GET /health` | any authenticated | liveness probe |\\n| `GET /` | `tier1_admin`, `tier1_user` | paginated, filterable network directory |\\n| `GET /lookup?term=` | `tier1_admin`, `tier1_user` | pre-invite recognition (\\\"is this trade already on Assureworx?\\\") |\\n| `GET /lineage` | `tier1_admin`, `tier1_user` (`heavyReadLimiter`) | full nested network tree with pending invites |\\n| `GET /companies/:id` | `tier1_admin`, `tier1_user` | company profile with evidence, checklist, staff |\\n| `GET /individuals/:id` | `tier1_admin`, `tier1_user` | person profile with evidence and checklist |\\n\\nThe router mounts `requireAuth \u2192 resolveTenantContext \u2192 requireRole(\\\"tier1_admin\\\", \\\"tier1_user\\\")` for every route.\\n\\n### Directory list (`GET /`)\\n\\nThe list is one SQL statement: a `direct` CTE (`L02`/`L03` edges into the scope), an `employed` CTE (`L01` under those companies, excluding company admins via `core.fn_identity_admin_party_ids`), a `network` CTE that de-duplicates by `party_id` and computes `withdrew_information`, and a `filtered` CTE that applies status/search/classification filters and pagination (`LIMIT`/`OFFSET` with `COUNT(*) OVER()`).\\n\\nThe endpoint accepts `q` (min 3 chars), `status` (`pending`, `verified`, `action_required`, `suspended`), `classification` (display name or raw tag code), `page`, and `pageSize` (max 100).\\n\\nNotable behaviours:\\n\\n- **Withdrew masking** \u2014 parties with `holder_erasure` on the edge or party, or a pending closure request, are surfaced as `\\\"Withdrew their information\\\"`. Their name is masked, contact/ABN/photo are `null`, evidence is hidden, and status is forced to `suspended`.\\n- **Suspended filter** \u2014 filtering by `status=suspended` (`S04`) also matches `withdrew_information` rows.\\n- **Aggregation** \u2014 the response includes `documents` (from evidence summarisation), `trades` for companies (people counts), and `actionReason` from the primary evidence gap.\\n\\n### Pre-invite lookup (`GET /lookup`)\\n\\nDeliberately thin, per ADR-0012. It answers one question \u2014 *\\\"is this trade already on Assureworx?\\\"* \u2014 so a principal sends a connection request instead of forcing a verified tradie to re-onboard.\\n\\nThe term is classified by `classifyLookupTerm` (ABN, email, phone, public ref, name), then resolved by `resolveIdentity` under `withBypass` \u2014 the caller has no relationship with this party yet, so normal tenant RLS cannot apply. The response carries existence and portable status only: no ABN, no contact details, no evidence. It also reports `inNetwork` (existing `L02`/`L03` edge) and `requestPending` (an outstanding `U01` invite), so the UI can route the user to \\\"send request\\\" instead of \\\"invite\\\".\\n\\n### Network lineage (`GET /lineage`)\\n\\nThis is the most expensive directory endpoint and is protected by `heavyReadLimiter`.\\n\\nThe response is a `NetworkLineage` tree:\\n\\n- **Root** \u2014 the scope org itself.\\n- **Companies** \u2014 `L02` companies, nested by the introducer chain from `t_request` (`U03` rows where `origin_party_id` \u2192 `target_party_id`). A company is nested under its introducer if the introducer is also in the network. Top-level companies are those not introduced by another network company, plus a safety pass that surfaces anything unvisited (cycles or dangling introducers).\\n- **Individuals per company** \u2014 `L01` employees (excluding admins) and `L04` subcontractors; employees with `class_cd = 'M02'` are labelled `sole_trader` instead of `employee`.\\n- **Direct individuals** \u2014 `L03` persons not already shown under a company.\\n- **Pending invites** \u2014 `U01` requests grouped by origin company.\\n\\n`buildCompany` recurses through the introducer map; a `visited` set prevents cycles. Invite tokens are only exposed when the caller is the origin org's `tier1_admin` \u2014 never for `tier1_user`. The endpoint also tallies `companies`, `individuals`, `verified`, `attention`, and `pendingInvites` across the whole tree.\\n\\n### Company &amp; individual profiles\\n\\nBoth profile endpoints first verify the party is in the caller's network via `getNetworkParties`, then load evidence, PC reviews (`loadPcReviews`), and a compliance checklist (`buildChecklist`).\\n\\n- **`GET /companies/:id`** (`CompanyProfile`) \u2014 core checks, evidence with attached PC reviews, ABN, entity class, classifications, and the crew roster. The roster query excludes company admins who also carry an `L01` from company-invite onboarding \u2014 the `NOT EXISTS` against `fn_identity_admin_party_ids` is what keeps founders off their own staff list.\\n- **`GET /individuals/:id`** (`IndividualProfile`) \u2014 same shape minus roster, plus `companyName` resolved from the person's employer edge (`entry.employerPartyId`).\\n\\n## Nodes Router\\n\\n### Endpoints\\n\\n| Method &amp; Path | Roles | Description |\\n|---|---|---|\\n| `GET /health` | any authenticated | liveness probe |\\n| `GET /projects` | `tier1_admin`, `tier1_user` | list projects with site count, assigned trades, readiness |\\n| `POST /projects` | `tier1_admin` (`mutationLimiter`) | create project |\\n| `PATCH /projects/:id` | `tier1_admin` (`mutationLimiter`) | update project |\\n| `GET /projects/:id` | `tier1_admin`, `tier1_user` | project detail + site summaries |\\n| `POST /projects/:id/sites` | `tier1_admin` (`mutationLimiter`) | create site, enqueue geocode |\\n| `PATCH /sites/:id` | `tier1_admin` (`mutationLimiter`) | update site, clear + re-enqueue geocode |\\n| `GET /sites` | `tier1_admin`, `tier1_user` | all sites across projects (`LIMIT 500`) \u2014 portfolio map boards |\\n| `GET /sites/:id` | `tier1_admin`, `tier1_user` | site detail with assignments |\\n| `POST /geocode` | `tier1_admin`, `tier1_user` (`geocodeLimiter`) | batch geocode with sync cap |\\n| `POST /sites/:id/assign` | `tier1_admin` (`mutationLimiter`) | assign a network party to a site |\\n| `DELETE /links/:id` | `tier1_admin` | soft-delete an assignment |\\n\\n### Projects\\n\\n`projectRows(tx, scope, projectId?)` is the shared read path. It selects `N01` nodes owned by the scope, counts child sites via a subquery, and aggregates `t_link` rows into per-project `assignedTrades` and readiness counts. Links can attach at the site level (`sn.parent_node_id` is used to roll them up to the project) or directly at the project node.\\n\\nProject creation and update are plain inserts/updates on `t_node` with an `audit(...)` write. Date validation uses a `calendarDateSchema` that rejects non-calendar dates (e.g. `2025-02-31`), and a `superRefine` enforces `endsOn &gt;= startsOn`.\\n\\n### Sites\\n\\nSites are `N02` nodes with `parent_node_id` \u2192 project. `siteSummaries` returns per-site assignment counts (`assigned`, `ready` = `J02`, `blocked` = `J03`) plus geocoordinates from `attrs.geo`. `siteDetail` is the full version: project context, tags for each assigned party, and `SiteAssignment[]`.\\n\\nWhen a site is created or updated, the router builds a geocode query from address/locality/region/postcode and calls `enqueueSiteGeocode({ siteId, scopeOrgId, query })` \u2014 fire-and-forget, so the HTTP response is not blocked on external geocoding. Updating a site strips the stored `geo` from `attrs` so stale coordinates are not served.\\n\\n### Batch geocoding (`POST /geocode`)\\n\\nThe endpoint accepts up to 40 `{ id, query }` pairs. Its strategy keeps map first-paint bounded:\\n\\n1. Load persisted `attrs.geo` for UUID site IDs in one round-trip.\\n2. If a stored coordinate's normalized query matches the request (whitespace/lowercase normalised), return it immediately.\\n3. Otherwise the place is a miss. At most **3** misses are geocoded synchronously via `geocodePlace` (Nominatim); hits are persisted to `attrs.geo`.\\n4. Remaining misses are enqueued for the background job (only for UUID site IDs; arbitrary client IDs are returned with `lat/lng = null` but never stored).\\n\\n### Site assignment (`POST /sites/:id/assign`)\\n\\nThis is the most guarded write in the module. The flow:\\n\\n1. **Site ownership** \u2014 the site must be an `N02` owned by the scope.\\n2. **Network check** \u2014 `isInNetwork(tx, scopeOrgId, partyId)`, else `NOT_IN_NETWORK`.\\n3. **Duplicate check** \u2014 the party cannot already be assigned to this site.\\n4. **Suspended hard block** \u2014 `S04` parties cannot be placed on a site.\\n5. **Evidence hard block** \u2014 required evidence artifacts with `blockCd = 'B01'` that are missing, expired, or rejected refuse assignment (`HARD_BLOCK`). There is no override path yet (referenced in code as Q21).\\n6. **Insert** \u2014 a `t_link` row with initial `state_cd` from `READINESS_BY_STATUS[party.status]`.\\n7. **Engagement tags** \u2014 the `tagCodes` from the request are written to `t_link_tag`. These represent *what the principal engaged the trade for*, which is the audience test for classification-scoped requirements (ADR-0018): a carpenter placed on electrical works needs the electrical licence even if they never tagged themselves an electrician.\\n8. **Evaluate** \u2014 `evaluateLinks(tx, [linkId], actorId)` runs immediately after insertion. Engagement requirements are not a reason to refuse the placement; they are the reason to show a gap and raise a top-up.\\n\\n`DELETE /links/:id` is the inverse: soft-delete + audit, with no cascade \u2014 it simply retires the assignment.\\n\\n## Parties Router\\n\\n### Endpoints\\n\\n| Method &amp; Path | Roles | Description |\\n|---|---|---|\\n| `GET /health` | any authenticated | liveness probe |\\n| `GET /me/profile` | trade accounts | compliance dossier + workforce + availability |\\n| `PUT /me/photo` | any authenticated person | replace account avatar |\\n| `DELETE /me/photo` | any authenticated person | remove avatar |\\n| `PUT /me/org-photo` | `trade_company_admin` | company logo (separate from person avatar) |\\n| `GET /me/team` | `trade_company_admin` | crew with docs, active sites, verification funding |\\n| `GET /me/assignable-sites` | `trade_company_admin` | sites the org can place employees on |\\n| `POST /me/team/:partyId/assign` | `trade_company_admin` | place an employee on a site |\\n| `PUT /me/location` | any authenticated | home suburb / postcode / region |\\n| `PUT /me/availability` | `trade_company_admin` | open-to-new-business + AU regions |\\n| `DELETE /me/team/:partyId` | `trade_company_admin` | de-register employee, release site placements |\\n| `GET /me/engagements` | trade accounts | active / completed jobs |\\n| `GET /me/closure` | any authenticated person | account closure status (ADR-0027) |\\n| `POST /me/closure` | any authenticated person | request account closure (`{ confirm: true }`) |\\n\\nThe router mounts `requireAuth \u2192 resolveTenantContext` globally; role checks are per-route. `partiesRouter` is the only one of the three that is not restricted to Tier 1 roles \u2014 it serves `trade_company_admin` and `trade_individual` accounts.\\n\\n### Who is \\\"me\\\"?\\n\\nThree helper functions resolve the subject party:\\n\\n- `myTradeOrgId(req)` \u2014 the trade organisation (`ctx.orgId`); throws for non-trade roles.\\n- `myTradeDossierPartyId(req)` \u2014 for `trade_individual`, the person party; for company admins, the org.\\n- `myTradePartyId(req)` \u2014 deprecated alias for `myTradeOrgId`.\\n\\nThe distinction matters because an individual's compliance dossier is their own person record, while a company admin's dossier is the organisation record.\\n\\n### Profile and the members CTE\\n\\n`GET /me/profile` returns the party's public profile plus `tradeTypes`, `workforce`, and `availability`. Trade-type counts use a `MEMBERS_CTE` that is repeated across several queries:\\n\\n```sql\\nWITH members AS (\\n  SELECT $1::uuid AS party_id\\n  UNION\\n  SELECT r.from_party_id\\n    FROM t_rel r\\n   WHERE r.to_party_id = $1 AND r.kind_cd = 'L01'\\n     AND r.status_cd = 'RS1' AND r.deleted_at IS NULL\\n)\\n```\\n\\nA company's compliance picture is the sum of the people it can supply, so member-scoped queries include the org itself plus all active `L01` employees. `tradeTypeCounts` groups party tags by status across members; `workforceBreakdown` totals employee statuses.\\n\\n`/me/engagements` uses the same CTE so a company admin sees jobs for the whole crew, while an individual sees only their own.\\n\\nPhotos are data URIs written to `t_party.image_uri` and bounded by `boundImageDataUrl` (max 500 KB, max edge 1024 px). The person avatar and the company logo are distinct endpoints writing to different party rows.\\n\\n### Team\\n\\n`GET /me/team` lists employed crew via `L01`, with the same admin-exclusion pattern as the directory router but implemented against `t_actor`/`t_grant` (`NOT EXISTS` an `R04` grant on the org). It includes:\\n\\n- **Withdrawal handling** \u2014 `holder_erasure` on the relationship or party, or a pending closure request, shows the member as `\\\"Withdrew their information\\\"` with masked details and no documents or sites.\\n- **Document summaries** per active member from evidence.\\n- **Active sites** \u2014 links to sites whose project is `RS1` and not past `ends_on`.\\n- **Verification funding** \u2014 `verificationFunded`, `verificationClaimed`, and `verificationFundedBy` come from `workforceFundingFor(tx, activeIds)`.\\n\\nThe funding read runs under `withBypass` because `t_cycle` and `t_order_item` are written by the payments service rather than a scoped tenant (migration 023), so the employer's own tenant scope cannot see them. It is read-only and limited to staff the query already established are employed.\\n\\n### Assigning employees\\n\\n`GET /me/assignable-sites` delegates to `listAssignableSites(tx, orgId)`. `POST /me/team/:partyId/assign` delegates to `placeEmployeeOnSite(...)` under `withBypass` \u2014 the trade is placing a person, not a company, and the placement logic spans link + grant/actor concerns that the trade's own tenant scope does not own. The response is the created placement.\\n\\n### De-registering an employee\\n\\n`DELETE /me/team/:partyId` retires the `L01` employment edge (sets `deleted_at` + `status_cd = 'RS2'`) and soft-deletes site placements. Critically, it only releases links owned by principals this trade is networked to (`L02`), so it never touches placements owned by unrelated principals. The audit delta records `sitesReleased`.\\n\\n### Engagements and the derived lifecycle\\n\\n`GET /me/engagements` derives \\\"active\\\" vs \\\"completed\\\" \u2014 there is no lifecycle state on a link (ADR-0016). A placement is **active** when:\\n\\n- the link is not soft-deleted,\\n- the project status is `RS1`, and\\n- the project has no `ends_on`, or `ends_on` is today or later,\\n- the site status is `RS1`.\\n\\nAnything else reads as **completed**, with `endedOn` derived from the link's `deleted_at` or the project end date. The response is split into `active` and `completed` buckets (capped at 200 rows, newest first).\\n\\n### Account closure\\n\\n`GET /me/closure` and `POST /me/closure` implement ADR-0027. The POST requires `{ confirm: true }` and delegates to `requestAccountClosure(...)` from `modules/retention/closure.ts`; the GET reads status via `readClosureStatus`. Closure is a 90-day grace period followed by a retention sweeper \u2014 the parties router only exposes the request/status surface.\\n\\n## Cross-Cutting Patterns\\n\\n### Tenant scoping and RLS\\n\\nThree transaction helpers are used across the module:\\n\\n- `withTenant(actorId, scope, fn)` \u2014 runs under the caller's tenant RLS; used for most reads/writes.\\n- `withScope(scope, fn)` \u2014 scope-only variant; the nodes router's default.\\n- `withBypass(fn)` \u2014 bypasses RLS for cross-tenant service reads: identity resolution (`/lookup`), payments/funding data, and `placeEmployeeOnSite`.\\n\\nThe module deliberately mixes them. `t_rel` is not RLS-protected, `t_request` is, and payments tables are written outside any tenant scope \u2014 each access pattern picks the helper that matches the table's protection model.\\n\\n### Auditing\\n\\nEvery mutation in the module writes an audit trail via `audit(tx, { actorId, scopePartyId, verb, objType, objId, delta })`. Verbs are namespaced: `project.created`, `project.updated`, `site.created`, `site.updated`, `trade.assigned`, `trade.unassigned`, `party.photo_updated`, `party.availability_updated`, `employee.deregistered`.\\n\\n### Rate limiting\\n\\n- `heavyReadLimiter` \u2014 `/lineage`, the most expensive directory query.\\n- `mutationLimiter` \u2014 all project/site/assignment writes.\\n- `geocodeLimiter` \u2014 the batch geocode endpoint, which can trigger external Nominatim calls.\\n- `uploadLimiter` \u2014 photo uploads.\\n\\n### Evidence &amp; readiness\\n\\nDirectory and profile endpoints all converge on the same evidence services: `getEvidenceForParties` / `getEvidenceForParty`, `summariseDocuments`, `loadPcReviews` + `attachPcReviews`, and `buildChecklist`. The pattern is consistent: load evidence rows, attach principal-completed reviews, summarise, and let `primaryActionReason` explain any `action_required` status in the directory list.\\n\\nSite readiness (`J01`/`J02`/`J03`) on a link is initialised from `READINESS_BY_STATUS` and then maintained by `evaluateLinks` after assignment \u2014 the link state is a projection of the party's current compliance, not a hand-set field.\\n\\n## Related Services\\n\\n| Service | Used by | Purpose |\\n|---|---|---|\\n| `shared/services/identity.ts` | directory lookup | `classifyLookupTerm`, `resolveIdentity` |\\n| `shared/services/evidence.ts` | all routers | evidence rows, document summaries, PC reviews |\\n| `shared/services/checklist.ts` | directory, parties | compliance checklist construction |\\n| `shared/services/requirements.ts` | nodes | `evaluateLinks` after assignment |\\n| `shared/services/employee-placement.ts` | parties | `listAssignableSites`, `placeEmployeeOnSite` |\\n| `shared/jobs/handlers.ts` | nodes | `enqueueSiteGeocode` |\\n| `modules/payments/views.ts` | parties | `workforceFundingFor` |\\n| `modules/retention/closure.ts` | parties | closure request/status |\",\"directory-trades-network-frontend-src\":\"# Directory, Trades &amp; Network \u2014 frontend-src\\n\\n# Directory, Trades &amp; Network \u2014 Frontend\\n\\n## Overview\\n\\nThe Directory, Trades &amp; Network module is the frontend for browsing and inspecting the trade network visible to a principal portal account. It serves three related experiences:\\n\\n1. **Trade directory** \u2014 a paginated, filterable listing of all trade companies and individuals in the network, with three view modes (list, cards, and network lineage).\\n2. **Company profiles** \u2014 a full compliance profile for a trade company, covering core checks, evidence documents, and the individuals associated with the company.\\n3. **Individual profiles** \u2014 the equivalent compliance profile for a single worker or sole trader.\\n\\nAll three share a common data model (`DirectoryRow`, `CompanyProfile`, `IndividualProfile` from `@assureworx/shared-types`), a common visual language (`ProfileSheet`, `VerifiedSplit`, `StatusChip`, `PartyAvatar`), and a common trade-classification icon system (`TradeTypeIcon`, `trade-pack.ts`).\\n\\n## Module Layout\\n\\n```\\nfrontend/src/app/(app)/trades/\\n\u251c\u2500\u2500 page.tsx                              # Server page: directory listing\\n\u251c\u2500\u2500 TradesClient.tsx                      # Client: TradesDirectory (list/cards/lineage)\\n\u251c\u2500\u2500 [id]/\\n\u2502   \u251c\u2500\u2500 page.tsx                          # Server page: company profile\\n\u2502   \u2514\u2500\u2500 CompanyProfileClient.tsx          # Client: company profile sheet\\n\u2514\u2500\u2500 individuals/\\n    \u2514\u2500\u2500 [id]/\\n        \u251c\u2500\u2500 page.tsx                      # Server page: individual profile\\n        \u2514\u2500\u2500 IndividualProfileClient.tsx   # Client: individual profile sheet\\n\\nfrontend/src/components/domain/\\n\u251c\u2500\u2500 TradeTypeIcon.tsx                     # Classification glyph (pack art + fallback)\\n\u2514\u2500\u2500 TradeTypeList.tsx                     # Stacked or chipped trade-type lists\\n\\nfrontend/src/lib/\\n\u2514\u2500\u2500 trade-pack.ts                         # G-code \u2192 asset/name mapping utilities\\n```\\n\\n## Server Pages\\n\\nEach route follows the same pattern: server-side auth check, server-side data fetch, then hand-off to a client component with prefetched `initialData`. This gives an immediate first paint and lets the client component skip its first identical fetch.\\n\\n### `TradesPage` (`trades/page.tsx`)\\n\\n```ts\\nawait requirePortal(\\\"principal\\\");\\nconst { data, meta } = await serverApi.getWithMeta(\\\"/directory?pageSize=50\\\");\\nreturn ;\\n```\\n\\n- Wrapped in `traceAppPage` for performance tracing.\\n- Fetches the default directory (page size 50, no filters) so the listing renders without a client-side round-trip.\\n- Redirects to `/auth/login` on 401.\\n\\n### `CompanyProfilePage` (`trades/[id]/page.tsx`)\\n\\n```ts\\nawait requirePortal(\\\"principal\\\");\\nconst initialData = await loadAppPage(`/directory/companies/${id}`);\\nreturn ;\\n```\\n\\n### `IndividualProfilePage` (`trades/individuals/[id]/page.tsx`)\\n\\n```ts\\nawait requirePortal(\\\"principal\\\");\\nconst initialData = await loadAppPage(`/directory/individuals/${id}`);\\nreturn ;\\n```\\n\\nThe profile routes use `loadAppPage` rather than a raw `serverApi` call, which adds the standard app-page loading/tracing wrapper.\\n\\n## Trade Directory (`TradesDirectory`)\\n\\n`TradesClient` renders `TradesDirectory` inside a `Suspense` boundary. The directory component is the most stateful piece of this module.\\n\\n### Initial data &amp; revalidation\\n\\nThe component uses two refs to coordinate server-seeded data with client-side fetching:\\n\\n- `directoryReady` \u2014 set to `true` immediately when server data exists; once `true`, subsequent filter changes are debounced by 250 ms instead of firing immediately.\\n- `skipSeededDefaultFetch` \u2014 set to `true` when server data was seeded. If the first client query is exactly the default (`pageSize=50` with no filters), that fetch is skipped entirely to avoid a redundant identical GET. The ref is consumed once and reset to `false`.\\n\\n```ts\\nconst isDefaultSeedQuery = query === \\\"pageSize=50\\\";\\nif (skipSeededDefaultFetch.current &amp;&amp; isDefaultSeedQuery) {\\n  skipSeededDefaultFetch.current = false;\\n  directoryReady.current = true;\\n  setLoading(false);\\n  return;\\n}\\n```\\n\\nAll non-seeded fetches go through `api.getWithMeta(\\\"/directory?\\\" + query)` with an `AbortController`. Superseded requests are aborted in the effect cleanup, which frees the connection and keeps superseded searches off the API.\\n\\n### Filtering &amp; URL state\\n\\n- `status` and `classification` are initialised from URL search params (`?status=`, `?classification=`) and kept in sync when the params change.\\n- The query string is built via `useMemo` from `status` + `classification`, always with `pageSize=50`.\\n- The status filter select offers `All statuses`, plus the four `VerifiedStatus` values (`verified`, `action_required`, `pending`, `suspended`), labelled via `verifiedChip()`.\\n\\n### View modes\\n\\nThree views, stored in `view` state:\\n\\n- **`list`** \u2014 a sortable table with columns: Name, ABN, Status, Trade types, Evidence, Member since.\\n- **`cards`** \u2014 a responsive grid of clickable profile cards, each showing identity, status chip, action reason, evidence split, contact detail, and trade types.\\n- **`lineage`** \u2014 a network tree/map rendered by `LineageTree`, fetched lazily from `/directory/lineage` the first time the view is opened.\\n\\nView selection is:\\n\\n1. Taken from the URL `?view=` param when present (`list`, `cards`, `lineage`; legacy `summary` maps to `lineage`).\\n2. Otherwise read from `localStorage` under the key `awx.trades.view`.\\n3. Persisted back to `localStorage` when the user toggles via `setViewMode`.\\n\\n### Sorting\\n\\nClient-side only. The user can sort by `name`, `status`, or `memberSince`, ascending or descending. Status ordering is explicit:\\n\\n```ts\\nconst STATUS_ORDER: Record = {\\n  suspended: 0,\\n  action_required: 1,\\n  pending: 2,\\n  verified: 3,\\n};\\n```\\n\\nSorting is applied to the fetched rows in a `useMemo`; the sort direction toggles when re-clicking the same column header.\\n\\n### Withdrawn parties\\n\\nRows where `row.withdrewInformation` is truthy get special treatment in both list and card views:\\n\\n- The row is not clickable; navigation is disabled.\\n- The status chip is `withdrewChip()` instead of `verifiedChip(row.status)`.\\n- Personal details are masked: ABN becomes `\u2013`, photo is suppressed, email is hidden, and the sub-label reads \\\"Personal details removed at their request\\\".\\n\\n### Trade type cell\\n\\n`TradeTypeCell` renders up to two trade classifications inline with `TradeTypeIcon`, plus a `+N` suffix when more exist. It uses `row.classificationCodes` when present, falling back to `tradeCodeFromName(row.classifications[i])` for legacy data that only has display names.\\n\\n### Lineage view\\n\\nWhen the user switches to `lineage`, the component fetches `NetworkLineage` from `/directory/lineage` once (guarded by `view !== \\\"lineage\\\" || lineage`). While loading or before data arrives, a `SkeletonRows` placeholder is shown. If the lineage is empty, an `EmptyState` prompts the user to invite the first trade (only when `canSendRequests(me)` is true). Otherwise `LineageTree` renders with a Tree/Map layout toggle.\\n\\n## Profile Pages\\n\\nBoth `CompanyProfileClient` and `IndividualProfileClient` follow the same architecture:\\n\\n1. **Initial data** \u2014 passed from the server page.\\n2. **Live refresh** \u2014 `useApi(path, { initialData })` fetches the current profile and exposes `data`, `error`, and `refresh`.\\n3. **Tab sheet** \u2014 `ProfileSheet` with `ProfileSheetTabs`; available tabs depend on whether the profile has core checks:\\n   - Company: `Checks` (if `coreChecks` has any non-`not_required` entry), `Evidence`, `People`.\\n   - Individual: `Checks` (same condition), `Evidence`.\\n4. **Fallback** \u2014 if the active tab is no longer valid (e.g., checks disappear after a refresh), the component falls back to the first valid tab.\\n\\n### Status reason derivation\\n\\nBoth clients define a local `statusReasonFromProfile(status, coreChecks, evidence)` helper. Given a non-`verified` status, it walks the data in priority order:\\n\\n1. First failing or pending core check \u2192 `\\\"{label} failed\\\"` or `\\\"{label} needs attention\\\"`.\\n2. First evidence row with `expired`, `rejected`, `missing`, or `expiring_soon` status \u2192 a human-readable reason using the document name (`\\\"{documentName} Expired\\\"`, `\\\"{documentName} Missing\\\"`, etc.), including the rejection reason when present.\\n3. Fallback to the bare status label (`\\\"Awaiting info\\\"`, `\\\"Action required\\\"`).\\n\\nThis reason appears in `ProfileHeader` as `statusReason`.\\n\\n### Evidence board &amp; PC review\\n\\nBoth clients render an `EvidenceBoard` with:\\n\\n- A caption identifying the profile (`Documents for {name}`).\\n- An `EvidenceMeter` in the tab panel actions showing document counts.\\n- `onPcReview` \u2014 the handler for principal-company (PC) review of an artifact.\\n\\nThe review flow posts to `/artifacts/{artifactId}/pc-ack` with `{ decision, reason }`, where `decision` is `\\\"accept\\\" | \\\"decline\\\" | \\\"clear\\\"`. While a review is in flight, the component tracks `busyId` (the artifact being reviewed) and passes it to the board as `pcReviewBusyId`. After the POST succeeds, `refresh()` re-fetches the whole profile. Errors are rethrown so `EvidenceBoard` can surface them; the `busyId` is cleared in a `finally` block.\\n\\n### Company-specific: People tab\\n\\n`CompanyProfileClient` computes `peopleCounts` from `profile.individuals`:\\n\\n- `total` \u2014 all individuals.\\n- `current` \u2014 count of `verified` individuals.\\n- `pending` \u2014 count of `pending`.\\n- `attention` \u2014 count of `action_required` or `suspended`.\\n\\nThe People tab renders a table of individuals (Name, Role, Status). Each row links to `/trades/individuals/{partyId}` via `router.push`, and the embedded `PartyIdentity` link stops propagation so the row click and the name link don't double-fire.\\n\\n### Company-specific: entity labels\\n\\n`ENTITY_LABELS` maps `entityClass` values to display labels:\\n\\n```ts\\n{ company: \\\"Company\\\", sole_trader: \\\"Sole trader\\\", trust: \\\"Trust\\\", partnership: \\\"Partnership\\\" }\\n```\\n\\nUnmapped classes fall back to `\\\"Trade company\\\"`.\\n\\n## Trade Classification Utilities\\n\\n### `trade-pack.ts`\\n\\nCentral mapping between platform trade classification codes (G01\u2013G17) and sliced tradiepack asset slugs.\\n\\n| Export | Signature | Purpose |\\n|---|---|---|\\n| `TRADE_PACK` | `Record` | G-code \u2192 asset slug (`G01` \u2192 `electrician`, `G04` \u2192 `carpenter`, etc.). Some codes intentionally reuse the closest pack art where no exact match exists (e.g., `G03` gasfitter \u2192 `plumber`). |\\n| `NAME_TO_CODE` | `Record` | Legacy display name \u2192 G-code fallback for APIs that only returned labels. |\\n| `GENERIC_TRADE_PACK_SRC` | `string` | `/tradiepack/builder.png`, the default silhouette for parties with no classification or unmapped codes. |\\n| `tradeCodeFromName(name)` | `(string \\\\| null \\\\| undefined) =&gt; string \\\\| null` | Resolves a G-code from a display name. |\\n| `tradePackSrc(code)` | `(string \\\\| null \\\\| undefined) =&gt; string \\\\| null` | Returns the public asset URL for a G-code, or `null` when unmapped. |\\n| `primaryTradeCode(types)` | `(ReadonlyArray&lt;{ code: string; count: number }&gt;) =&gt; string \\\\| null` | Returns the highest-count classification code, with code order as a tiebreaker. |\\n\\n### `TradeTypeIcon`\\n\\nRenders a classification glyph with a three-level fallback:\\n\\n1. **Mapped pack art** \u2014 `tradePackSrc(code)` returns a URL \u2192 render `` with the requested `size` and `className`.\\n2. **Generic pack art** \u2014 when `preferPackGeneric` is `true` (the default) or `code` is absent \u2192 render `GENERIC_TRADE_PACK_SRC` (`builder.png`).\\n3. **MetaIcon fallback** \u2014 when `preferPackGeneric` is `false` and the code is unmapped \u2192 render `MetaIcon` with `tradeIcon(code ?? \\\"\\\")`.\\n\\nThe `alt` prop defaults to an empty string, making the image decorative when the surrounding link/button already carries the accessible label. The `` is marked `draggable={false}`.\\n\\n### `TradeTypeList`\\n\\nRenders `TradeTypeCount[]` as either:\\n\\n- **`stack`** (default) \u2014 vertical list with icon, name, and optional count column.\\n- **`chips`** \u2014 compact inline chips for dense profile boards.\\n\\n`showCounts` defaults to `true`; pass `false` to suppress the count column (typically when every entry represents a single person). Unlike `TradeTypeIcon`'s default, `TradeTypeList` passes `preferPackGeneric={false}`, so unmapped codes fall back to greyscale MetaIcons rather than the generic builder.\\n\\n## Data Flow\\n\\n```mermaid\\nflowchart TD\\n    subgraph Server[\\\"Server pages (RSC)\\\"]\\n        TP[TradesPage] --&gt;|\\\"GET /directory?pageSize=50\\\"| API[Platform API]\\n        CPP[CompanyProfilePage] --&gt;|\\\"GET /directory/companies/:id\\\"| API\\n        IPP[IndividualProfilePage] --&gt;|\\\"GET /directory/individuals/:id\\\"| API\\n    end\\n\\n    subgraph Client[\\\"Client components\\\"]\\n        TP --&gt; TC[TradesClient \u2192 TradesDirectory]\\n        CPP --&gt; CPC[CompanyProfileClient]\\n        IPP --&gt; IPC[IndividualProfileClient]\\n\\n        TC --&gt;|\\\"GET /directory?status&amp;classification&amp;pageSize=50\\\"| API\\n        TC --&gt;|\\\"GET /directory/lineage (lazy)\\\"| API\\n        CPC --&gt;|\\\"GET /directory/companies/:id (useApi)\\\"| API\\n        IPC --&gt;|\\\"GET /directory/individuals/:id (useApi)\\\"| API\\n        CPC --&gt;|\\\"POST /artifacts/:id/pc-ack\\\"| API\\n        IPC --&gt;|\\\"POST /artifacts/:id/pc-ack\\\"| API\\n    end\\n\\n    subgraph Shared[\\\"Shared UI &amp; utilities\\\"]\\n        TC --&gt; TradeTypeIcon\\n        CPC --&gt; EvidenceBoard\\n        IPC --&gt; EvidenceBoard\\n        TradeTypeList --&gt; TradeTypeIcon\\n        TradeTypeIcon --&gt;|tradePackSrc| tradePack[lib/trade-pack.ts]\\n        TC --&gt;|tradeCodeFromName| tradePack\\n    end\\n```\\n\\nKey flow notes:\\n\\n- **Server-seeded data** flows down as `initialRows` / `initialData`; client components treat it as the first render source and only re-fetch when needed.\\n- **Directory re-fetch** is debounced (250 ms) after the first paint and aborts superseded requests via `AbortController`.\\n- **Profile re-fetch** happens on demand via `refresh()` after a PC review mutation, or implicitly via `useApi`'s own lifecycle.\\n- **Lineage** is fetched lazily and cached in component state for the lifetime of the directory view.\\n\\n## Integration Points\\n\\n### Shared components consumed\\n\\n| Component | Used by | Role |\\n|---|---|---|\\n| `LineageTree` | `TradesDirectory` | Network lineage rendering (tree/map layouts) |\\n| `VerifiedSplit` | `TradesDirectory` | Compact document/trade evidence counts |\\n| `ProfileSheet` family | Both profile clients | Tabbed profile layout |\\n| `ProfileHeader` | Both profile clients | Identity, status, status reason, contact details |\\n| `CoreChecks` | Both profile clients | Compliance check states |\\n| `EvidenceBoard` / `EvidenceMeter` / `evidenceCounts` | Both profile clients | Document evidence display and summarised counts |\\n| `PartyAvatar`, `PartyIdentity`, `StatusChip` | Directory + profile pages | Identity and status rendering |\\n| `Button`, `EmptyState`, `MetaIcon`, `PageHeader`, `Panel`, `SkeletonRows` | Various | UI primitives |\\n\\n### Shared libraries\\n\\n| Library | Usage |\\n|---|---|\\n| `api` / `ApiClientError` | All client-side HTTP (`get`, `getWithMeta`, `post`); `ApiClientError.code === \\\"REQUEST_CANCELLED\\\"` is handled specially in the directory fetch. |\\n| `serverApi` / `ServerApiError` | Server-side prefetch in `TradesPage`. |\\n| `useApi` | Profile data fetching with `initialData` hydration and `refresh()`. |\\n| `useSession` / `canSendRequests` | Gates the \\\"Invite a trade\\\" / \\\"Send an onboarding request\\\" actions. |\\n| `requirePortal` / `traceAppPage` / `loadAppPage` | Server-side session, tracing, and data-loading wrappers. |\\n| `formatAbn` / `formatShortDate` | ABN formatting and date display in the directory. |\\n\\n### Consumed elsewhere\\n\\nThe trade-classification utilities are not private to this module. Per the call graph, `TradeTypeIcon` is also used by `DashboardClient` and `WorkforceMetricRow`, and `TradeTypeList` / `primaryTradeCode` are used by `ProfileClient` (`(app)/profile/ProfileClient.tsx`). Any change to `trade-pack.ts` or `TradeTypeIcon`'s fallback behaviour will affect those screens as well.\\n\\n### Cross-cutting performance tracing\\n\\nProfile loading flows through `useApi` \u2192 `trackNetworkInstant` into the perf store (`lib/perf/store.ts`), which records network instant metrics and emits them via the store's `emit`/`uid`/`isRecording` machinery. This means profile page latency is automatically included in the app's performance telemetry without any module-specific instrumentation.\\n\\n## Behavioural Notes for Contributors\\n\\n- **Don't remove the `skipSeededDefaultFetch` guard** \u2014 it prevents a redundant identical GET on every directory visit where the server already seeded the default page. If you change the default page size or add default filters, update the guard's `query === \\\"pageSize=50\\\"` comparison accordingly.\\n- **Abort superseded fetches** \u2014 the `AbortController` in the directory fetch effect is intentional; new filter changes abort in-flight requests and the `REQUEST_CANCELLED` error is deliberately swallowed so the newer request owns the loading state.\\n- **Keep `statusReasonFromProfile` in sync** \u2014 the identical helper exists in both profile clients. If the reason priority changes (e.g., evidence should outrank core checks), update both copies.\\n- **URL params are the source of truth** for `status`, `classification`, and `view` on initial load; local state follows the params via the `useEffect` on `params`. If you add a new filter, wire it into the `useMemo` query and the params-sync effect together.\\n- **Withdrawn parties must never navigate** \u2014 both list rows and cards gate `onClick` and link rendering on `row.withdrewInformation`. Preserve this invariant when restyling the directory.\",\"directory-trades-network\":\"# Directory, Trades &amp; Network\\n\\n# Directory, Trades &amp; Network \u2014 Module Overview\\n\\nThe **Directory, Trades &amp; Network** module supports the two sides of the Assureworx engagement lifecycle: the principal-side view of the trade network, and the trade-side self-service surface.\\n\\nOn the **backend**, three routers implement this area:\\n\\n- `directoryRouter` \u2014 principal-side listing of trade companies and individuals\\n- `nodesRouter` \u2014 organising trades into projects and sites, and assigning trades to those sites\\n- `partiesRouter` \u2014 trade accounts' self-service profile, compliance dossier, team management, site placements, and account closure\\n\\nThese share a small network helper in `backend/src/shared/services/network.ts`, used for lookups such as `getNetworkParties` and `isInNetwork`.\\n\\nOn the **frontend**, the module provides:\\n\\n- **Trade directory** \u2014 `TradesDirectory` and `TradesClient` give a paginated, filterable listing with three view modes: list, cards, and network lineage\\n- **Company profiles** \u2014 `CompanyProfileClient` renders compliance checks, evidence documents, and associated individuals\\n- **Individual profiles** \u2014 `IndividualProfileClient` renders the equivalent for a single worker or sole trader\\n\\nBoth the backend and frontend share the same underlying concepts of `DirectoryRow`, `CompanyProfile`, and `IndividualProfile`.\\n\\n## Sub-modules\\n\\n- [Directory, Trades &amp; Network \u2014 Backend](backend-src.md)\\n- [Directory, Trades &amp; Network \u2014 Frontend](frontend-src.md)\\n\\n## How the sub-modules fit together\\n\\nThe backend defines the network model and API surface; the frontend consumes that surface to present the trade network to a principal portal account.\\n\\n```\\ngraph LR\\n    subgraph Backend\\n        A[directoryRouter] --&gt; D[network.ts]\\n        B[nodesRouter] --&gt; D\\n        C[partiesRouter] --&gt; D\\n    end\\n    subgraph Frontend\\n        E[TradesDirectory] --&gt; F[CompanyProfileClient]\\n        E --&gt; G[IndividualProfileClient]\\n        F --&gt; H[useApi]\\n        G --&gt; H\\n        H --&gt; A\\n        H --&gt; B\\n        H --&gt; C\\n    end\\n```\\n\\nKey relationships:\\n\\n- The **directory** backend route powers the frontend trade directory and profile pages via `loadPartyDetails`, `buildCompany`, and `toIndividual` helpers.\\n- The **nodes** backend route feeds project/site context into the network views, with helpers such as `siteSummaries`, `normalizeGeoQuery`, and `projectRows`.\\n- The **parties** backend route supports the trade-side self-service experience, using `myTradePartyId`, `myTradeDossierPartyId`, `availabilityFromAttrs`, and `tradeTypeCounts` to tailor responses to the caller's own trade account.\\n- On the frontend, `TradeTypeIcon` and `TradeTypeList` provide the shared visual vocabulary for trades, while `kindLabel`, `hrefFor`, `toggleSort`, and `setViewMode` help `TradesDirectory` and `TradesClient` handle presentation and navigation.\\n- Profile pages (`CompanyProfileClient`, `IndividualProfileClient`) use `useApi` to call the backend; those calls are instrumented through `trackNetworkInstant` in `lib/perf/store.ts`, recording network timing for the module's views.\\n\\n## Key workflows\\n\\n- **Browsing the network** \u2014 A principal opens the trade directory, filters and sorts the network, and switches between list, card, and lineage views. The backend's `directoryRouter` supplies the matching `DirectoryRow` data.\\n- **Inspecting a company or individual** \u2014 From the directory, a user opens a profile. The company profile surfaces compliance checks and evidence; the individual profile shows the worker's dossier. Both are driven by `CompanyProfileClient` / `IndividualProfileClient` and the backend's `directoryRouter`.\\n- **Managing the engagement on the trade side** \u2014 A trade account uses the self-service surfaces backed by `partiesRouter` to maintain its dossier, manage team members, adjust site placement, or close the account. The same routers also supply the data that principal-side profile pages ultimately render.\\n- **Recording network performance** \u2014 The profile client workflows route through `useApi` \u2192 `trackNetworkInstant` \u2192 `push` \u2192 `emit`/`isRecording`, giving visibility into the latency of network-lookup calls made across the directory.\",\"documents-evidence-extraction-backend-src\":\"# Documents, Evidence &amp; Extraction \u2014 backend-src\\n\\n# Documents, Evidence &amp; Extraction\\n\\n## Purpose\\n\\nThis module is the backend's source of truth for compliance documents and the evidence they represent. It answers three questions:\\n\\n1. **What evidence does a party have, and is it current?** \u2014 via `evidence.ts`, which derives the UI-facing evidence status from stored artifact rows.\\n2. **Where are document bytes stored, and how are they retrieved?** \u2014 via `artifact-storage.ts`, which abstracts filesystem and Azure Blob storage behind one interface.\\n3. **What does an uploaded document say, and can it be trusted?** \u2014 via the `extraction/` pipeline, which reads a document, extracts structured fields with Australian-specific rules, and decides whether the result is clean enough to auto-accept (`F02`) or must be held for Platform Admin review (`F01`).\\n\\nThe module lives under `backend/src/shared/services/` and is consumed by the parties, directory, nodes, onboarding, requests, notifications, reporting, checklist, and jobs subsystems.\\n\\n---\\n\\n## Module Layout\\n\\n| Path | Responsibility |\\n|---|---|\\n| `evidence.ts` | Evidence status derivation, party evidence aggregation, document summaries, PC review annotations |\\n| `artifact-storage.ts` | Blob/filesystem storage abstraction with Azure SAS URLs |\\n| `extraction/index.ts` | Extraction orchestrator, result storage shape, prefill helpers |\\n| `extraction/sources.ts` | The tier ladder (PDF text layer \u2192 local OCR \u2192 cloud OCR) |\\n| `extraction/preprocess.ts` | Page gating, PDF truncation, image downscaling before cloud send |\\n| `extraction/cloud-read.ts` | Azure AI Document Intelligence client with cost caps, circuit breaker, concurrency limit |\\n| `extraction/parse.ts` | Pure, synchronous primitive parsers (dates, money, ABN/ACN, references, names) |\\n| `extraction/rules.ts` | Per-document-class rule packs (insurance, licence, certification, ABN, generic) |\\n| `extraction/validate.ts` | Confidence scoring, finding verdicts, declared-vs-observed comparison |\\n| `extraction/decide-status.ts` | Artifact status decision (`F01` hold vs `F02` auto-accept), hard rejects |\\n| `extraction/present.ts` | Converts extraction results to API preview/summary shapes |\\n| `extraction/preview-cache.ts` | In-process cache so a preview extraction is not paid for twice |\\n\\n---\\n\\n## Evidence Model\\n\\n### Artifact classes and statuses\\n\\nEvidence is modelled as **artifact classes** \u2014 stable codes (`A001` white card, `A004` public liability, `A005` workers compensation, `A101`\u2013`A116` licences and certifications, `A003` ABN registration) \u2014 against which parties have at most one **live artifact** per class. The live artifact is the most recently uploaded, non-deleted row for that party and class (`getEvidenceForParties` uses `DISTINCT ON (party_id, class_cd) ... ORDER BY uploaded_at DESC`).\\n\\nRaw artifact status codes:\\n\\n| Code | Meaning |\\n|---|---|\\n| `F01` | Pending verification |\\n| `F02` | Verified (auto-accepted or reviewed) |\\n| `F03` | Rejected |\\n| `F04` | Verified (alternate path) |\\n\\n`F02` and `F04` are treated identically by `deriveEvidenceStatus`.\\n\\n### Deriving the UI status\\n\\n`deriveEvidenceStatus(artifact, alertDays)` maps a live artifact (or its absence) to a UI-facing status:\\n\\n```\\nmissing             no artifact\\nrejected            artifact.status_cd === \\\"F03\\\"\\npending_verification artifact.status_cd === \\\"F01\\\"\\nexpired             expiresOn &lt; today\\nexpiring_soon       expiresOn within alertDays\\nverified            otherwise\\n```\\n\\n`alertDays` comes from `t_artifact_class.alert_days` via `loadArtifactClasses`, which also carries `display_name`, `universal_flag`, `block_cd`, `method_cd`. Display names prefer localised constants from `ARTIFACT_CLASS_NAMES` when present.\\n\\n### Which classes are required\\n\\n`requiredUniversalClasses(meta)` implements the policy in its doc comment:\\n\\n- Salaried employees (`kindCd K03`, `classCd M01`) need only `A001` (White Card).\\n- Everyone else needs `A001` + `A004`.\\n- `A005` (workers compensation) is additionally required when `attrs.employing === \\\"true\\\"`.\\n- Classification-specific classes are added from the party's live tags via `t_tag_class`.\\n\\n`A002` (photo ID / identity) is **never** required, never stored as an artifact, and never extracted. Identity is verified by the Didit check (`C02`) per ADR-0014; the route layer rejects `A002` uploads. `getEvidenceForParties` explicitly deletes `A002` from the set of surfaced classes so legacy seed rows cannot make it appear uploadable.\\n\\n### Party evidence aggregation\\n\\n```ts\\ngetEvidenceForParties(db, partyIds): Promise&gt;\\ngetEvidenceForParty(db, partyId): Promise\\n```\\n\\n`PartyEvidence` contains:\\n\\n- `meta` \u2014 kind code, class code, attributes\\n- `requiredClasses` \u2014 the computed required set\\n- `rows` \u2014 one `EvidenceRow` per class in the union of required and uploaded classes, sorted by class code\\n\\nEach `EvidenceRow` carries the artifact id, document name, required flag, derived status, issued/expiry dates, authority, reference, verification timestamp, check method, and reject reason (for `F03` only). This shape is what the directory, profile, checklist, and readiness features all consume.\\n\\n### Document summaries and attention reasons\\n\\n`summariseDocuments(evidence)` computes counts for directory/team rows:\\n\\n- `total` \u2014 required rows only\\n- `current` \u2014 verified + expiring_soon\\n- `attention` \u2014 total \u2212 current\\n\\n`primaryActionReason(rows, partyStatus)` produces the one-line explanation for a profile header, preferring a concrete document problem over a generic status:\\n\\n```\\n\\\"Insurance Expired\\\"        \u2014 expired\\n\\\" Rejected\\\"      \u2014 rejected (or the stored reject reason)\\n\\\" Missing\\\"       \u2014 missing\\n\\\" Expiring\\\"      \u2014 expiring_soon\\n\\\"Awaiting info\\\"            \u2014 first pending_verification row, or party pending\\n\\\"Action required\\\"          \u2014 party action_required\\n```\\n\\n### People rollups and PC reviews\\n\\n`loadPeopleCounts(db, companyIds)` counts tradespeople per company from active `L01` employment relations (`RS1`, not soft-deleted, no `holder_erasure` attribute), splitting by party status `S02` (current) vs `S03`/`S04` (attention).\\n\\n`loadPcReviews(db, scopePartyId, artifactIds)` loads a Principal Contractor's acknowledgement decisions from `t_artifact_ack` (mapping `AK1` \u2192 accepted, `AK2` \u2192 declined). `attachPcReviews(rows, reviews)` merges them onto evidence rows as **metadata only** \u2014 a PC review never changes the artifact status.\\n\\n---\\n\\n## Artifact Storage\\n\\n`artifactStorage()` is a lazy singleton selecting between two backends:\\n\\n- **Filesystem** \u2014 `ARTIFACT_STORAGE_MODE=filesystem` (default when not deployed). Files are written under `ARTIFACT_STORAGE_DIR` (default `.data/artifacts`) with mode `0600` and `flag: \\\"wx\\\"` (fails if already exists). `safeLocalPath` prevents key traversal outside the configured root. `signedReadUrl` returns `null`.\\n- **Azure Blob** \u2014 `ARTIFACT_STORAGE_MODE=azure`. Requires `AZURE_STORAGE_ACCOUNT`; container defaults to `evidence`. Uses `DefaultAzureCredential` (managed identity in deployed environments, developer credentials locally). `put` uploads with content type, `get` streams the blob down, `remove` deletes including snapshots, and `signedReadUrl` issues a read-only user-delegation SAS (HTTPS only, default TTL 120s).\\n\\nMode selection deliberately keys off `config.isDeployed`, not `NODE_ENV`: a host that sets the deployed environment but leaves `NODE_ENV` unset would otherwise silently store licences and insurance certificates on the container's ephemeral disk.\\n\\n`MAX_ARTIFACT_BYTES` (10 MB) is the upload ceiling applied by callers. All artifact writes are immutable: existing keys are never overwritten (`wx` on filesystem, unique keys assumed on Blob).\\n\\n---\\n\\n## Extraction Pipeline\\n\\nThe extraction pipeline turns uploaded document bytes into structured, validated findings. Identity documents never enter it (`NEVER_EXTRACT = { \\\"A002\\\" }`); they are skipped with status `\\\"skipped\\\"` and an explanatory reason.\\n\\n```mermaid\\nflowchart LR\\n    U[\\\"Upload bytes\\\"] --&gt; P[\\\"preprocesspage gate + shrink\\\"]\\n    P --&gt; T1[\\\"Tier 1PDF text layerfree \u00b7 2.5s budget\\\"]\\n    T1 --&gt;|\\\"no usable text\\\"| T2[\\\"Tier 2Local OCRnot implemented\\\"]\\n    T2 --&gt;|\\\"unavailable\\\"| T3[\\\"Tier 3Document Intelligencemetered &amp; capped\\\"]\\n    T1 --&gt;|\\\"text \u2265 40 chars\\\"| R[\\\"applyRulesrule pack for class\\\"]\\n    T3 --&gt;|\\\"text\\\"| R\\n    R --&gt; V[\\\"validate + score\\\"]\\n    V --&gt; D{\\\"confidence \u2265 0.7no blockers?\\\"}\\n    D --&gt;|yes| F02[\\\"F02 auto-accepted\\\"]\\n    D --&gt;|no| F01[\\\"F01 held for review\\\"]\\n```\\n\\n### Entry point\\n\\n`extractDocument(bytes, contentType, ctx, opts)`:\\n\\n- Skips `A002`.\\n- Filters the source ladder by content type (`sourcesFor`), optionally dropping the cloud tier via `opts.allowCloud: false` (used on the upload request path for a cheap expiry gate; a full extract is enqueued afterwards).\\n- Runs each tier in order, cheapest first. A tier's text is used only if it clears `MIN_USEFUL_CHARS` (40 chars).\\n- `applyRules` runs the class's rule pack and produces an `ExtractionResult` with `suggested` fields (what to prefill), `observed` facts, `findings`, and a confidence score.\\n- A candidate is accepted and the ladder stops when confidence \u2265 `ESCALATE_BELOW` (0.7). Below that, the next tier gets a turn **only if** `canOcrImprove(read)` \u2014 if the current tier already returned every character exactly, paying for a dearer reader cannot help; the shortfall is in the rules.\\n- The best candidate wins; confidence is compared, not last-write.\\n- A rule pack throwing never fails the upload: it returns status `\\\"failed\\\"` and the document is still stored for manual review.\\n- The outer per-source budget is a 30-second `AbortSignal.timeout`, applied uniformly. Metered tiers enforce their own tighter budgets inside.\\n\\n`ExtractionContext` carries `classCode`, `partyId`, `actorId`, and `partyAbn` (used to prefer the uploading party's ABN when a certificate shows several).\\n\\n### Preprocessing\\n\\n`preprocess.ts` does cheap local work before any OCR spend:\\n\\n- `gateUploadPages` rejects documents over `MAX_UPLOAD_PAGES` (10) with code `TOO_MANY_PAGES`. Counting is fail-open: a PDF that cannot be opened returns `pageCount: null` and passes, because blocking a legitimate upload on a broken counter is worse than shipping a few extra pages.\\n- `prepareCloudPayload` shrinks what leaves the process for Document Intelligence:\\n  - **PDF** \u2014 keeps the first `maxPages` pages (`truncatePdf` via pdf-lib), matching the `pages=1-N` query parameter the cloud client sends. Encrypted/odd PDFs fall through and are sent as-is.\\n  - **Images** \u2014 downscales long edges to `CLOUD_IMAGE_MAX_EDGE` (2000 px) and re-encodes JPEG at quality 85. Large photos (&gt; 1.5 MB) are re-encoded even when already under the edge cap. Everything the cloud path sends as an image becomes JPEG, one page.\\n\\nThe original upload bytes are never modified; only the cloud-read path receives the prepared buffer.\\n\\n### Tier 1 \u2014 PDF text layer\\n\\n`pdfTextSource` reads the embedded text layer with pdf-lib/unpdf. It is exact and free, and handles almost every registry/insurer-generated certificate. Runs inside the request with a hard `PDF_TEXT_BUDGET_MS` (2.5 s) wall-clock ceiling. PDF.js's `hasEOL` flag is honoured when joining positioned runs into lines, because labels lose their values when a page collapses into one run of prose. Character confidence is 1 \u2014 the author's characters, not a reading of them.\\n\\n`textLayerIsSound(text)` detects the one case where a digital PDF's text layer is a lie: a broken `ToUnicode` map yields private-use or replacement characters. Plenty of text, none of it words \u2014 and rasterising the PDF genuinely beats reading it.\\n\\n### Tier 2 \u2014 local OCR\\n\\n`localOcrSource` is a reserved seat, deliberately not implemented (ADR-0019). WASM Tesseract runs for seconds and holds hundreds of megabytes per worker, so it cannot share the request cycle, and the codebase has no job runner to move it to. `available()` returns `false`; adding local OCR is a registration, not a refactor.\\n\\n### Tier 3 \u2014 Cloud read (Azure Document Intelligence)\\n\\n`cloudReadSource` is the metered rung of the ladder. The module's design goal is to make two failure modes impossible:\\n\\n1. **Spending money unintentionally.** The model id is a pinned constant, not configuration:\\n   - `MODEL_ID = \\\"prebuilt-read\\\"` \u2014 the cheapest reader. The comment is explicit: `prebuilt-layout` and the prebuilt extractors bill US$10/page, add-on features US$6, query fields US$200, all on the same verb. No `features`, `queryFields`, or `outputContentFormat` parameter is ever sent.\\n   - The request URL carries exactly two query parameters: `api-version=2024-11-30` and `pages=1-N`, where N is `config.docIntel.maxPagesPerDocument`. The page range pins the bill before money is spent.\\n   - **Meter reservation** happens before any bytes leave the process. `reserve()` checks, in order: the per-party daily cap (counted from the audit log over the last 24 hours, so it survives restarts and holds across replicas), then the monthly cap in `t_meter` (`DOC_INTEL_PROVIDER_CD = \\\"docintel\\\"`). Cheap in-memory checks run first; the monthly cap is reserved last so a party-level refusal never consumes global budget. `releaseAll()` refunds everything on failure; `settle(actualPages)` refunds the difference between reserved and actually analysed pages.\\n   - **Audit**: every billable outbound read is recorded via `audit()` with verb `CLOUD_READ_VERB = \\\"extraction.cloud_read\\\"`, including page count, model, region, and duration \u2014 so spend is attributable per party. Audit failures are logged and swallowed; cost attribution must never fail an upload.\\n2. **Making a tradesperson wait for an unwell service.** Extraction is advisory, so degradation is \\\"we could not read it\\\", never a failed upload:\\n   - **Wall-clock budget**: `config.docIntel.budgetMs` from request start; the combined `AbortSignal` (caller + budget) aborts the fetch.\\n   - **Concurrency semaphore** (`config.docIntel.maxConcurrent`): caps in-flight analyses because each holds a document buffer in memory. A caller that cannot get a slot inside its budget gives up rather than queueing.\\n   - **Circuit breaker**: 5 consecutive infrastructure failures (network, 401/403, 5xx, timeouts) trip the breaker for 60 s. Document-level rejections (corrupt PDF, unsupported page count) never count. `cloudReadStatus()` exposes breaker state and meter usage for the platform console and probe scripts.\\n\\nRetryable statuses (`408, 429, 500, 502, 503, 504`) get at most one retry inside the budget, honouring `Retry-After` (capped at 10 s). Polling starts at 400 ms and eases off to a 1.5 s ceiling.\\n\\nThe response is converted by `toSourceText` into the same `SourceText` shape tier 1 produces: lines joined with newlines rather than taking `analyzeResult.content` wholesale, because rule packs match a label against the value beside it and lose the pairing when a page collapses into one run of prose. Per-word confidence is averaged; when no confidence comes back, `characterConfidence` defaults to 0.8 (a reading of the characters, not the characters themselves). A mismatched returned model id is treated as an infrastructure failure \u2014 the bill is not the one authorised.\\n\\nThe user-facing error is always the same deliberately uninformative message: `\\\"This file could not be read automatically.\\\"` A monthly cap, a tripped breaker, and a throttled provider all land a tradesperson in the same place \u2014 key the fields in by hand \u2014 and the real reason goes to the log.\\n\\n### Parsers\\n\\n`parse.ts` is pure, synchronous, and fail-open: every parser returns `null`/`[]` rather than guessing, because a confident wrong suggestion shown to a tradesperson is worse than none.\\n\\n- **Text normalisation** \u2014 `normalise` collapses PDF text-layer whitespace and folds unicode punctuation (en/em dashes, curly quotes, NBSP). `searchable` lower-cases and flattens newlines for label searching while preserving offsets.\\n- **Dates** \u2014 `findDates` returns every date in the document in order with confidence scores. Australian documents are day-first, so `03/04/2026` is 3 April; ambiguity (both components \u2264 12) drops confidence to make the uncertainty visible. Two-digit years pivot at 70 (`CENTURY_PIVOT` \u2192 19xx). Impossible dates (31 February) are rejected, as is anything outside 1990\u2013now+50. `findLabelledDate` finds the closest label-then-date pair; `findLabelledDateRange` handles the `from ... to ... ` period-of-insurance pattern, described in the code as \\\"the most reliable single signal in the whole pipeline\\\".\\n- **Money** \u2014 `findMoney` parses `$1,000,000`, `A$1.5m`, `$200k` etc. `findLabelledMoney` returns the **largest** amount in the label window, not the nearest, because liability schedules commonly list an excess right beside the limit.\\n- **ABN/ACN** \u2014 `isValidAbn` (ATO modulus 89) and `isValidAcn` (ASIC modulus 10) reject phone-number and policy-number lookalikes. `findAcns` drops candidates that sit inside a valid ABN, since the trailing nine digits of an ABN are the ACN.\\n- **References** \u2014 `findLabelledReference` reads multi-token references (`EC 45871C`), requires a numeric ending, and excludes known stopwords. Tokens like `Class` and `Period` are kept out by a four-character ceiling on non-numeric tokens.\\n- **Names &amp; dictionaries** \u2014 `findLabelledName` keeps to a single line and refuses addresses/numbers. `findFromDictionary` matches whole words so \\\"Vero\\\" does not fire inside \\\"Veronica\\\". `nameTokens`/`nameSimilarity` reduce company names to significant words, ignoring `pty`, `ltd`, `trading as`, `atf`, etc., for loose comparison during validation.\\n\\n### Rule packs\\n\\n`rules.ts` defines one pack per document family. A pack knows which labels carry the expiry, what the reference is called, and who the issuer is likely to be. Packs fail open: an unrecognised layout returns nothing, never a guess.\\n\\n| Pack | Classes | Signals |\\n|---|---|---|\\n| `insurancePack` | `A004`, `A005`, `A110` | certificate of currency, policy schedule, period of insurance\u2026 |\\n| `licencePack` | `A101`\u2013`A109`, `A111`, `A115` | licence, HRWL, fair trading, QBCC\u2026 |\\n| `certificationPack` | `A001`, `A112`\u2013`A114`, `A116` | statement of attainment, white card, RTO, USI\u2026 |\\n| `abnPack` | `A003` | Australian Business Register, entity name\u2026 |\\n| `genericPack` | (fallback) | labels only, no domain assumptions |\\n\\nPacks read dates with `readDates(text, dates, prefix, rangeIsAuthoritative)`. For insurance, the period-of-insurance range is authoritative: a certificate printed two days after inception is normal, and its print date is not the date the product cares about. For licences and certifications, a labelled range beats a labelled single date beats the shape of the two dates on the page.\\n\\nDomain dictionaries are extensive and explicit in the source:\\n\\n- `LICENCE_AUTHORITIES` \u2014 25 state/territory regulators from NSW Fair Trading to NT WorkSafe, each with aliases.\\n- `INSURERS` \u2014 28 Australian underwriters (QBE, Allianz, CGU, Vero, Lloyd's\u2026).\\n- `HRWL_CLASSES` \u2014 the scheduled high-risk-work licence class codes.\\n- `WHITE_CARD_UNITS` \u2014 CPCCWHS1001 etc.\\n- `JURISDICTIONS` \u2014 state names for the jurisdiction field.\\n\\n`detectPack(text)` scores packs by signal hits independent of the uploader's chosen class; this drives the `CLASS_MISMATCH` finding. `expectedKind(classCode)` and `kindConflictsWithClass` let validation compare what the document names itself (\\\"High risk work licence\\\") against what the class expects (\\\"Contractor licence\\\"). Vague kinds like \\\"Certificate of currency\\\" never conflict because they are consistent with several classes.\\n\\n### Validation and status decision\\n\\n`validate.ts` (re-exported through `index.ts`) provides:\\n\\n- `scoreExtraction` \u2014 combines read confidence with rule confidence into the overall extraction confidence.\\n- `validate` \u2014 produces `Finding[]` (levels `blocker`/`warning`/`info`), cross-checking dates, amounts, ABNs and names. Hard class rules are wired through `classRule` and `kindConflictsWithClass`.\\n- `compareDeclared` \u2014 folds the uploader's typed values into the result so a reviewer sees disagreements between the form and the document. `withDeclared` merges these as additional findings, sorted blocker \u2192 warning \u2192 info.\\n- `verdictOf` \u2014 summarises findings as a `Verdict` (`\\\"clean\\\"`, `\\\"needs_review\\\"`, etc.).\\n- `isExpiredHighConfidenceReject` \u2014 handles the expired-but-high-confidence case separately from the hard reject list.\\n- `LOW_CONFIDENCE` \u2014 the threshold under which a suggested field is **not** offered as a prefill. `prefillableFields` uses it so a low-confidence expiry still reaches the reviewer as a finding but is not shown to the tradesperson, because a pre-filled field gets confirmed without being read.\\n\\n`decide-status.ts` makes the accept/hold call:\\n\\n- `hardRejectFromExtraction` \u2014 refuses the upload outright for blocker findings with codes `CLASS_MISMATCH` or `COVER_BELOW_MINIMUM`.\\n- `decideArtifactStatus` \u2014 `F02` (auto-accepted) only when the read `status === \\\"extracted\\\"`, confidence \u2265 `ESCALATE_BELOW`, and no blocker findings exist. Everything else is `F01` (visible to the PC, needs attention / Admin exception queue).\\n\\n---\\n\\n## Presentation and Caching\\n\\n`present.ts` converts results to API shapes with brand-compliant wording (\\\"says what was *read*, never what was *verified*\\\"):\\n\\n- `toPreview(result)` \u2192 `ExtractionPreview` for the extract endpoint, including `prefill`, `read` values, and findings.\\n- `toSummary(stored)` \u2192 `ExtractionSummary` for the review queue, rehydrated from the compact stored form.\\n\\nField ordering is explicit (`ORDER`), cover amounts render as AUD currency with `en-AU` locale formatting, and unlisted fields sort alphabetically at the end.\\n\\n`preview-cache.ts` is a small in-process cache so `POST /artifacts/extract` never bills Document Intelligence twice for the same file on the subsequent upload. Keyed by `partyId:classCode:sha256`, TTL 15 minutes, capped at 200 entries. `previewSatisfiesUpload(result)` returns `true` when the preview already did the expensive work (extracted, or already attempted cloud OCR) \u2014 in which case the upload persists the cached result and skips the background `artifact.extract` job. A restart loses the cache and the job simply runs the ladder again: the safe failure mode is extra spend, never a wrong skip.\\n\\nThe stored extraction shape (`forStorage`) drops evidence snippets deliberately: they are quotations from the document, and the document itself is already the record of what it says. What remains on `t_artifact.attrs.extraction` is status, source, attempted tiers, confidence, engine, verdict, duration, page count, per-field read values (value + confidence + rule), and findings.\\n\\n---\\n\\n## Configuration Reference\\n\\n| Setting | Purpose | Default |\\n|---|---|---|\\n| `ARTIFACT_STORAGE_MODE` | `filesystem` or `azure` | `azure` when `config.isDeployed`, else `filesystem` |\\n| `ARTIFACT_STORAGE_DIR` | Filesystem storage root | `.data/artifacts` |\\n| `AZURE_STORAGE_ACCOUNT` | Blob account (required in azure mode) | \u2014 |\\n| `AZURE_STORAGE_CONTAINER` | Blob container | `evidence` |\\n| `config.docIntel.endpoint` | Document Intelligence endpoint; unset disables the cloud tier | \u2014 |\\n| `config.docIntel.maxConcurrent` | In-flight cloud reads | \u2014 |\\n| `config.docIntel.budgetMs` | Wall-clock budget per cloud read | \u2014 |\\n| `config.docIntel.maxPagesPerDocument` | Page cap in the request URL and meter reservation | \u2014 |\\n| `config.docIntel.monthlyPageCap` | Global `t_meter` cap for `docintel` | \u2014 |\\n| `config.docIntel.dailyPagesPerParty` | Per-party 24h cap counted from the audit log | \u2014 |\\n\\nThe Document Intelligence model id (`prebuilt-read`) and API version (`2024-11-30`) are pinned constants, not configuration, because on this service the model id *is* the price.\\n\\n---\\n\\n## Integration Points\\n\\n| Consumer | What it uses |\\n|---|---|\\n| `modules/parties`, `modules/directory` | `getEvidenceForParties`, `getEvidenceForParty`, `summariseDocuments`, `primaryActionReason` |\\n| `shared/services/checklist`, `requirements`, `verified-status`, `employee-placement` | `getEvidenceForParty`/`getEvidenceForParties`, `loadArtifactClasses` |\\n| `modules/onboarding`, `modules/requests`, `modules/notifications`, `modules/reporting` | `iso`/`isoDate` normalisers, evidence queries |\\n| `shared/jobs/handlers` | `artifactStorage` for the extract/upload job handlers |\\n| `modules/retention/sweep` | `artifactStorage` for deleting soft-deleted artifact bytes |\\n| `shared/services/audit-query` | `iso` for audit row serialisation |\\n| `extraction/index.ts` exports | consumed by the artifacts routes (extract, upload, review queue) and the `artifact.extract` job |\\n\\nThe module's invariants, in order of importance:\\n\\n1. **Identity documents never enter the extraction pipeline or artifact storage** (`A002` skipped at the orchestrator, deleted from surfaced evidence, rejected at the route).\\n2. **Extraction never invents registry truth** \u2014 it only decides \\\"clean enough to show the PC as accepted\\\" vs \\\"hold\\\".\\n3. **A rule pack throwing, a cloud read failing, or a meter refusing never fails an upload** \u2014 the document is stored and reviewed; the trade keys the fields in by hand.\\n4. **Every billable cloud read is metered, capped, audited, and attributable per party.**\\n5. **A wrong suggestion is worse than none** \u2014 parsers return `null` rather than guessing, and prefills are withheld below `LOW_CONFIDENCE`.\",\"documents-evidence-extraction-frontend-src\":\"# Documents, Evidence &amp; Extraction \u2014 frontend-src\\n\\n# Documents, Evidence &amp; Extraction \u2014 frontend-src\\n\\n## Overview\\n\\nThis module implements the two document-facing experiences in the Assureworx frontend \u2014 **onboarding evidence collection** and the **profile compliance register** \u2014 plus the shared infrastructure between them:\\n\\n- A file \u2192 extract \u2192 prefill \u2192 upload pipeline backed by the artifact API\\n- Read-only document intelligence (`POST /artifacts/extract`) that reports what was read from a file without ever claiming verification\\n- Dense evidence display components (`EvidenceTable`, `EvidenceBoard`, `EvidenceMeter`)\\n- Network lineage visualization (`LineageTree`, `LineageSummary`) used by the trades directory\\n- Evidence class-code grouping helpers shared by verify and profile screens\\n\\nThe two `DocumentsClient` components (one under `(app)/profile/documents`, one under `onboarding/documents`) are distinct implementations. They share `EvidenceUploadForm`, `useDocumentExtraction`, and `DocumentReadPreview`, but differ in data source, layout, and surrounding wizard/profile chrome.\\n\\n## File map\\n\\n| File | Role |\\n|---|---|\\n| `profile/documents/page.tsx` | Server component: portal guard, loads initial `EvidenceRow[]` from `/artifacts/mine` |\\n| `profile/documents/DocumentsClient.tsx` | Client page: desktop table + mobile list, inline upload, toasts, refresh |\\n| `onboarding/documents/page.tsx` | Thin server wrapper; no prefetch \u2014 client fetches checklist |\\n| `onboarding/documents/DocumentsClient.tsx` | Wizard step: checklist-driven upload list, progress footer, extraction preview |\\n| `components/domain/EvidenceUploadForm.tsx` | Shared choose \u2192 extract \u2192 upload form (also used by work checks and profile drawer) |\\n| `lib/use-extraction.ts` | `useDocumentExtraction` hook; aborts stale reads; surfaces `{ reading, preview, read, reset }` |\\n| `components/domain/DocumentRead.tsx` | `DocumentReadPreview` (uploader-facing) and `DocumentReadSummary` (reviewer-facing) |\\n| `components/domain/DocumentScanOverlay.tsx` | Full-viewport scan-beam overlay during extraction |\\n| `components/domain/EvidenceTable.tsx` | Dense desktop document register with sortable columns and expandable rows |\\n| `components/domain/EvidenceBoard.tsx` | Profile/company evidence list with PC review actions |\\n| `components/domain/EvidenceMeter.tsx` | Segmented current/pending/attention composition meter |\\n| `components/domain/LineageTree.tsx` | Expandable hierarchical tree of trade network |\\n| `components/domain/LineageSummary.tsx` | Purview-style pan/zoom tree map of the same network |\\n| `lib/evidence-groups.ts` | `licence` / `insurance` / `certificates` classifier over class codes |\\n\\n## Core upload &amp; extraction flow\\n\\nBoth document surfaces funnel through `EvidenceUploadForm`. The flow is identical whether the user is a trade uploading during onboarding, a trade replacing a document in their profile, or a worker submitting evidence from a work check.\\n\\n```mermaid\\nflowchart LR\\n    A[Choose file \u2192 validate] --&gt; C[useDocumentExtraction.read]\\n    C --&gt; D[POST /artifacts/extract]\\n    D --&gt; E{Preview usable?}\\n    E --&gt;|\\\"read.length &gt; 0\\\"| F[Prefill from preview.prefill]\\n    E --&gt;|skipped / failed| G[Manual entry]\\n    F --&gt; H{EXPIRED finding?}\\n    H --&gt;|yes| I[Block upload]\\n    H --&gt;|no| J[Confirm / edit]\\n    G --&gt; J\\n    J --&gt; L[POST /artifacts \u2192 refresh]\\n```\\n\\n### Client-side validation\\n\\nBefore anything is sent, the chosen file must pass three checks in `chooseFile`:\\n\\n- Extension is one of `ACCEPTED_EXTENSIONS` \u2014 `.pdf`, `.jpg`, `.jpeg`, `.png`\\n- Size is non-zero\\n- Size is \u2264 `MAX_FILE_BYTES` (10 MB)\\n\\nFailures set `formError` and clear the file input; the form does not proceed to extraction.\\n\\n### Extraction (`useDocumentExtraction`)\\n\\n`useDocumentExtraction` wraps `POST /artifacts/extract` (multipart: `file`, `classCode`). Key behaviors:\\n\\n- A previous in-flight read is aborted when a new file is chosen, so a stale response never overwrites newer suggestions\\n- `reading` drives the scan overlay and submit-button label\\n- A failed read returns `null` and silently falls back to manual entry \u2014 the call stores nothing, so a failure is never surfaced as an error (per ADR-0019)\\n- `reset()` aborts and clears state; used when closing a row or choosing a new file\\n\\nThe returned `ExtractionPreview` carries:\\n\\n- `status` \u2014 `\\\"extracted\\\"` or `\\\"skipped\\\"`\\n- `read` \u2014 `ExtractionReadValue[]` (`{ field, label, value, confidence }`)\\n- `findings` \u2014 `ExtractionFinding[]` (`{ code, level, message }`, levels `blocker | warning | info`)\\n- `prefill` \u2014 `{ issuedOn?, expiresOn?, authority?, reference? }` (high-confidence values only)\\n\\n### Preview &amp; manual entry\\n\\n`DocumentReadPreview` renders the extraction result:\\n\\n- **Reading** \u2192 scan overlay plus a hidden placeholder panel\\n- **Skipped / failed / empty read** \u2192 \\\"Could not read this document\\\" with guidance to fill details manually\\n- **Extracted** \u2192 read values, findings, and a caveat line; values below `LOW_CONFIDENCE` (0.8) display a `| check` marker\\n\\nThe form decides manual-entry mode with `readNeedsManualEntry`:\\n\\n```ts\\nif (!preview || preview.status === \\\"skipped\\\") return true;\\nreturn preview.status !== \\\"extracted\\\" || preview.read.length === 0;\\n```\\n\\nWhen extraction produced usable data, the form pre-fills `issuedOn`, `expiresOn`, `authority`, and `reference` from `preview.prefill`, and the user can open an \\\"Edit details\\\" toggle to correct them. When extraction failed, the fields are shown directly.\\n\\n### Expired-document block\\n\\nIf the preview contains an `EXPIRED` finding **and** `prefill.expiresOn` is set, the form blocks submission entirely \u2014 the submit button is disabled and shows `EVIDENCE_COPY.expired`. The user must choose a current document. This mirrors the API-side rejection.\\n\\n### Upload\\n\\nSubmission builds a `FormData` body with `file`, `classCode`, and any entered metadata (`issuedOn`, `expiresOn`, `authority`, `reference`), then calls `api.upload(\\\"/artifacts\\\", body, { signal, onProgress })`.\\n\\nProgress updates are throttled in `EvidenceUploadForm`:\\n\\n- `PROGRESS_THROTTLE_MS = 100` \u2014 no emission more often than every 100 ms\\n- `PROGRESS_MIN_STEP = 4` \u2014 jump updates by at least 4 points\\n- 100% is always emitted\\n\\nThe upload can be cancelled via an `AbortController`; `ApiClientError.code === \\\"UPLOAD_CANCELLED\\\"` restores the form to a retryable state without clearing the chosen file. `useBeforeUnload(Boolean(file))` warns the user if they navigate away with a file selected.\\n\\nOn success, the parent refreshes its evidence data (`await refresh()`) and, on the profile surface, shows a toast.\\n\\n## Feature surfaces\\n\\n### Profile documents (`(app)/profile/documents`)\\n\\n`page.tsx` runs `requirePortal(\\\"trade\\\")` and prefetches `/artifacts/mine` via `loadAppPage`, passing the result as `initialData` to `DocumentsClient`. The client hydrates with `useApi(\\\"/artifacts/mine\\\", { initialData })`, so the page renders immediately from server data and refetches only when an upload completes.\\n\\nThe client renders two layouts from the same data:\\n\\n- **Desktop** \u2014 `EvidenceTable` with sortable columns (Status / Expires / Name), an action column containing a View link (when `artifactId` exists) and an Upload/Replace/Cancel button, and an expandable detail row containing `EvidenceUploadForm`\\n- **Mobile** \u2014 a hand-rolled `MobileDocumentList` with progress summary (`\\\"X of Y current | N need action | M in review\\\"`), status chips, expiry/verification copy, rejection reasons, and an inline upload panel\\n\\nBoth layouts share the same `uploadPanelRef`, so expanding an upload on either surface scrolls the panel into view. `expiryCopy` and `verificationCopy` produce the expiry and verification-method lines; `verificationCopy` prefers the recorded `method` label from `EVIDENCE_METHOD_LABELS`, falling back to status-derived copy.\\n\\n### Onboarding documents (`onboarding/documents`)\\n\\nThis client fetches `RequirementChecklist` from `/onboarding/checklist` (no server prefetch). It renders inside `WizardShell` at the `documents` step and computes:\\n\\n```ts\\nconst required = evidence.filter((r) =&gt; r.required);\\nconst uploaded = required.filter((r) =&gt; r.status !== \\\"missing\\\").length;\\nconst allUploaded = required.length &gt; 0 &amp;&amp; uploaded === required.length;\\n```\\n\\nThe footer button is disabled until all required documents are uploaded, showing either `\\\"Continue to review\\\"` or `\\\"N documents to go\\\"`. An empty required list shows a hint pointing back to the trade-selection step.\\n\\nEach row expands into the upload form. Unlike the profile surface, the onboarding client owns its own `submit` (not shared with `EvidenceUploadForm`) \u2014 the logic is near-identical but uses `describeOnboardingError(err, \\\"upload this document\\\")` for friendlier failure copy and lacks the throttled progress emission. Note that this duplication exists; changes to upload behavior should consider both implementations.\\n\\n### Principal contractor review (`EvidenceBoard`)\\n\\n`EvidenceBoard` renders evidence for profiles and companies. When `onPcReview` is provided, rows in `verified` or `expiring_soon` status with an `artifactId` become reviewable:\\n\\n- Checkbox \u2192 `onPcReview(artifactId, \\\"accept\\\")`\\n- Uncheck \u2192 `onPcReview(artifactId, \\\"clear\\\")`\\n- Decline flow \u2192 inline reason input (`maxLength={1000}`), persists via `onPcReview(artifactId, \\\"decline\\\", reason)`\\n\\nReview state lives in `row.pcReview?.decision` (`\\\"accepted\\\" | \\\"declined\\\"`). Busy state is tracked by `pcReviewBusyId` (an artifact ID). Local errors surface in a banner above the list.\\n\\nSort order is status-first by `STATUS_ORDER` (`expired \u2192 rejected \u2192 missing \u2192 expiring_soon \u2192 pending_verification \u2192 verified`), with document-name and expiry-date alternatives. `evidenceCounts(evidence)` exports the aggregate used by profile clients and `EvidenceMeter`.\\n\\n### Network lineage (`LineageTree`, `LineageSummary`)\\n\\nBoth components render `NetworkLineage` (root principal contractor, companies, direct individuals, pending invites) and are switched by the trades directory via a `layout` prop (`\\\"tree\\\" | \\\"map\\\"`).\\n\\n`LineageTree` is a vertical, expandable hierarchy:\\n\\n- Root node \u2192 companies \u2192 sub-companies \u2192 individuals / invites\\n- `CompanyNode` disclosure chevrons; \\\"Expand all / Collapse all\\\" toolbar\\n- `PersonRow` shows relation label, classification, a document metric (`current/total`), and a verified chip\\n- `InviteRow` shows pending invite expiry and a \\\"Copy link\\\" button that builds `${window.location.origin}/onboarding/invite/${invite.token}`, writes to the clipboard, and confirms via toast\\n\\n`LineageSummary` is a left-anchored tree map:\\n\\n- Cards are sized by estimated text width (`estimateCardWidth`), then stretched to fill the stage; leftover width pours into column gaps\\n- Vertical leaf packing fills the viewport height, clamped to `MIN_LEAF_GAP` for dense trees\\n- Parent Y is centered between first and last child; links are cubic B\u00e9zier SVGs\\n- Collapsible nodes via chevron buttons; drag-to-pan on mouse and touch; `ResizeObserver` drives re-layout\\n- Node cards are `Link` elements (`/trades/{partyId}`, `/trades/individuals/{partyId}`) when a `href` exists\\n\\n## Evidence classification (`lib/evidence-groups.ts`)\\n\\nClass codes group into three buckets used by verify screens and profile filters:\\n\\n```ts\\nisLicenceClass(code)      // A101\u2013A119 minus A110, A112, A113, A114, A116\\nisInsuranceClass(code)    // A004, A005, A110\\nisCertificateClass(code)  // A112, A113, A114, A116, A001 + insurance classes\\nmatchesEvidenceGroup(group, code)  // \\\"licence\\\" | \\\"insurance\\\" | \\\"certificates\\\"\\n```\\n\\nThe comment in the file is a maintainability constraint: keep in sync with `ProfileClient` / `WorkChecksBoard` filters.\\n\\n## API contract\\n\\n| Endpoint | Method / body | Used by |\\n|---|---|---|\\n| `/artifacts/mine` | GET | Profile documents (initial + refresh) |\\n| `/onboarding/checklist` | GET | Onboarding documents |\\n| `/artifacts/extract` | POST multipart `file`, `classCode` \u2192 `ExtractionPreview` | `useDocumentExtraction` |\\n| `/artifacts` | POST multipart `file`, `classCode`, optional `issuedOn` / `expiresOn` / `authority` / `reference` | Upload forms |\\n| `/api/v1/artifacts/{artifactId}/content` | GET (view in new tab) | All surfaces with an `artifactId` |\\n\\n`EvidenceRow` statuses consumed across the module: `verified`, `expiring_soon`, `pending_verification`, `expired`, `rejected`, `missing`. `row.method` is one of `api` / `manual` / `self_declared`, labeled via `EVIDENCE_METHOD_LABELS`.\\n\\n## Design rules\\n\\n- **Extraction never verifies.** `DocumentReadPreview` deliberately reports what was *read*; verification is a human decision (ADR-0019). The review-side `DocumentReadSummary` does the same for already-uploaded artifacts.\\n- **Extraction never blocks.** A failed or skipped read just opens manual entry \u2014 the upload works exactly as it did before document intelligence existed.\\n- **Expired blocks.** An `EXPIRED` finding *with* a prefilled expiry date is the one case where upload is refused client-side.\\n- **Low confidence is visible, not trusted.** Values below 0.8 are shown with a `| check` marker and are not offered in the prefill (matching the API).\\n- **Two definitions of \\\"current\\\" coexist.** Profile summaries count `verified + expiring_soon` as current; `evidenceCounts` counts only `verified` as current and routes `expiring_soon` into `attention`. When reusing these components, be deliberate about which definition you need.\\n- **Progress updates are throttled** (`EvidenceUploadForm`) to avoid re-render storms on large files or multi-document contexts.\\n- **Abort everything.** `useDocumentExtraction` aborts stale reads; upload forms abort in-flight uploads; `reset()` clears both on row close.\\n\\n## Integration map\\n\\nThese are the known consumers outside the module:\\n\\n| Consumer | Uses |\\n|---|---|\\n| `VerifyEvidenceStep` (verify workflow) | `EvidenceUploadForm`, `matchesEvidenceGroup` for row filtering |\\n| `EvidenceDrawer` in `ProfileClient` | `EvidenceUploadForm` |\\n| `IndividualProfileClient`, `CompanyProfileClient` | `EvidenceBoard`, `evidenceCounts`, `EvidenceMeter` |\\n| `TradesDirectory` | `LineageTree` (with `layout` switch to `LineageSummary`) |\\n| `AdminClient` | `DocumentReadSummary` (reviewer-facing extraction report) |\\n| `VerifyTradeClient` | `isLicenceClass` |\\n\\nNetwork calls made by this module flow through `api.upload` / `useApi`, which feed the performance tracking in `lib/perf/store.ts` (`trackNetworkInstant` \u2192 `push` \u2192 `emit` / `isRecording`) \u2014 no extra instrumentation is needed in these components.\",\"documents-evidence-extraction\":\"# Documents, Evidence &amp; Extraction\\n\\n# Documents, Evidence &amp; Extraction\\n\\n## Purpose\\n\\nThis module is the end-to-end system for compliance documents and the evidence they represent \u2014 from file upload, through structured extraction, to the evidence status shown across onboarding and profile screens. It manages three concerns:\\n\\n1. **What evidence does a party have, and is it current?** \u2014 the UI-facing evidence status derived from stored artifact rows.\\n2. **Where are document bytes stored, and how are they retrieved?** \u2014 a single storage interface abstracting filesystem and Azure Blob backends.\\n3. **What does an uploaded document say, and can it be trusted?** \u2014 the extraction pipeline that reads documents, extracts structured fields using Australian-specific rules, and decides whether the result is clean enough to auto-accept.\\n\\n## Sub-modules\\n\\n- **[Backend source](backend-src.md)** \u2014 the source of truth for artifact storage, evidence status, and extraction/validation logic.\\n- **[Frontend source](frontend-src.md)** \u2014 the onboarding evidence-collection and profile compliance-register experiences, plus shared evidence display and lineage components.\\n\\n## How the sub-modules fit together\\n\\nThe frontend never touches document bytes or extraction directly \u2014 it goes through the artifact API, which the backend backs with storage, extraction, and evidence-status services.\\n\\n- **Onboarding and profile screens** drive a shared **file \u2192 extract \u2192 prefill \u2192 upload** pipeline; the backend stores the artifact and runs extraction/validation on it.\\n- **Evidence status flows back to the UI** as dense display components (`EvidenceTable`, `EvidenceBoard`, `EvidenceMeter`) and network-lineage views (`LineageTree`, `LineageSummary`).\\n- **Class-code grouping helpers** are shared between verify and profile screens so both surfaces interpret evidence consistently.\\n- **Every document action participates in cross-community performance telemetry** \u2014 both `DocumentsClient` implementations route through `useApi` \u2192 `trackNetworkInstant` \u2192 `push` \u2192 `emit`/`isRecording`, so uploads and extracts are observed by the community-wide perf store.\\n\\n## Key workflows\\n\\n1. **Evidence collection (onboarding)** \u2014 user uploads a file; the frontend runs extract \u2192 prefill \u2192 upload against the artifact API; the backend stores bytes, extracts structured fields, and validates them; the resulting evidence status renders back in the UI.\\n2. **Compliance register (profile)** \u2014 stored artifacts are aggregated by class-code into evidence status, displayed as dense tables/boards/meters, with lineage trees for network context.\\n3. **Read-only intelligence** \u2014 `POST /artifacts/extract` reports what was read from a file without claiming verification; previews are cached and presented to the user before any prefill is applied.\\n\\n```mermaid\\nflowchart LR\\n  A[Onboarding / Profile DocumentsClient] --&gt;|artifact API| B[Backend artifact-storage]\\n  B --&gt; C[Extraction pipelineAU-specific rules]\\n  C --&gt; D[Validation &amp; evidence status]\\n  D --&gt;|status| E[Evidence UI components]\\n  A -.-&gt;|perf telemetry| F[Community perf store]\\n```\\n\\nSee the [backend source](backend-src.md) and [frontend source](frontend-src.md) pages for the component-level detail.\",\"evidence-checks-compliance-backend-src\":\"# Evidence, Checks &amp; Compliance \u2014 backend-src\\n\\n# Evidence, Checks &amp; Compliance \u2014 backend-src\\n\\n## Overview\\n\\nThis module is the compliance backbone of the platform. It answers three questions about every party (company or person) on the system:\\n\\n1. **What evidence does this party need to provide, and what is its current state?**\\n2. **Is this party compliant enough to submit, be placed on site, or be shown as verified?**\\n3. **When an evidence document is uploaded, can we read it, extract its fields, and accept it automatically \u2014 or does it need a human?**\\n\\nThe module owns the storage of evidence artifacts, the derivation of evidence status (verified / expiring / expired / rejected / missing), the five public core checks shown on a party's compliance checklist, and the tiered document-extraction pipeline that turns uploaded files into structured, reviewable data.\\n\\nIt lives under `backend/src/shared/services/` (the services themselves) plus two thin route modules `backend/src/modules/cases/` and `backend/src/modules/checks/` that currently only wire health endpoints.\\n\\n---\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    Upload[Upload / Extract Request] --&gt; Gate[gateUploadPages / prepareCloudPayload]\\n    Gate --&gt; Ladder[extractDocument escalation ladder]\\n    Ladder --&gt; T1[pdf-text tier]\\n    Ladder --&gt; T2[cloud-ocr tierAzure Document Intelligence]\\n    T2 --&gt; Governor[Cost governorreserve / settle / audit]\\n    Ladder --&gt; Rules[Rule packs per class]\\n    Rules --&gt; Decide[decideArtifactStatus]\\n    Decide --&gt;|F02| Verified[Auto-accepted]\\n    Decide --&gt;|F01| Review[Admin / PC review queue]\\n    Checklist[buildChecklist] --&gt; Evidence[getEvidenceForParty]\\n    Evidence --&gt; Storage[(artifact-storagefilesystem / Azure Blob)]\\n```\\n\\nThe two halves of the module are deliberately independent:\\n\\n- **Evidence/checklist half** \u2014 reads from Postgres (`t_artifact`, `t_party`, `t_tag_class`) and derives status. It does not care how a document got there.\\n- **Extraction half** \u2014 runs when a document is uploaded. It is advisory: it proposes fields, flags problems, and decides whether auto-accept (F02) is safe. It never mutates registry truth or evidence status directly.\\n\\n---\\n\\n## Evidence Model\\n\\n### Artifact classes\\n\\nEvery evidence document type is an *artifact class* with a code:\\n\\n| Class | Meaning |\\n|---|---|\\n| `A001` | White Card (construction induction) |\\n| `A002` | Photo ID \u2014 **never** stored or extracted (identity is a Didit check, ADR-0014) |\\n| `A003` | ABN registration \u2014 never required as upload (ABR check `C01` is the ABN leg) |\\n| `A004` | Public liability insurance |\\n| `A005` | Workers compensation |\\n| `A110` | Professional indemnity |\\n| `A112`\u2013`A116`, `A010`, etc. | Trade licences, qualifications, and other classes |\\n\\nClass metadata lives in the `t_artifact_class` table (`loadArtifactClasses`) with `displayName`, `universalFlag`, `methodCd`, and `alertDays` \u2014 the look-ahead window before expiry at which a document becomes `expiring_soon`.\\n\\n### Evidence status derivation\\n\\n`deriveEvidenceStatus(artifact, alertDays)` maps a live artifact row to a UI status:\\n\\n| Status | Meaning |\\n|---|---|\\n| `missing` | No artifact uploaded for a required class |\\n| `pending_verification` | Artifact status `F01` \u2014 held for review |\\n| `verified` | Artifact status `F02`/`F04`, not near expiry |\\n| `expiring_soon` | Verified but within `alertDays` of `expires_on` |\\n| `expired` | `expires_on` is in the past |\\n| `rejected` | Artifact status `F03`, with `reason_txt` attached |\\n\\nThe status is derived on read, never stored. `getEvidenceForParties` batches this for many parties in three queries: parties, tags\u2192classes, and latest artifact per `(party_id, class_cd)`.\\n\\n### Required vs uploaded evidence\\n\\n`requiredUniversalClasses(meta)` determines the baseline:\\n\\n- Salaried employee (`K03`/`M01`): only `A001` (White Card).\\n- Everyone else: `A001` + `A004`, plus `A005` when `attrs.employing === \\\"true\\\"`.\\n- Classification-specific classes are added from the party's live tags via `t_tag_class`.\\n\\n`A002` is always excluded from the evidence rows, even if an old seed row exists. ABN (`A003`) may appear as a voluntary upload but is not what makes the ABN check pass.\\n\\n### Checklist aggregation\\n\\n`buildChecklist(db, partyId, options)` produces the `RequirementChecklist` used by parties, onboarding, directory, and payment-eligibility flows. The five core checks:\\n\\n| Key | What it aggregates | Pass condition |\\n|---|---|---|\\n| `abn` | ABR check `C01` + ABN ident | `hasAbnIdent` and `latestCheckPassed(db, partyId, \\\"C01\\\")` |\\n| `identity` | Didit identity status | `identityIsCurrent(identityStatus)` |\\n| `licence` | Evidence classes matching `A101`\u2013`A119` (excluding insurance classes) | All required licence artifacts verified or expiring |\\n| `insurance` | Classes `A004`, `A005`, `A110` | All required policies verified or expiring |\\n| `qualifications` | Classes `A001`, `A112`\u2013`A116` | All required certifications verified or expiring |\\n\\nEach check has a state machine: `not_required \u2192 pending \u2192 passed | failed`. The identity check is deliberately conservative \u2014 only `did_not_pass` is a hard `failed`; `expired`, `consent_lapsed`, and `in_progress` remain `pending` because they are fixable rather than refutations of the person.\\n\\n`readyToSubmit` is true when every required evidence row is present, ABN and identity checks pass (or are not required), and trade types are selected.\\n\\n---\\n\\n## Artifact Storage\\n\\n`artifactStorage()` returns a singleton with a uniform `put / get / remove / signedReadUrl` interface.\\n\\n- **Filesystem mode** (`ARTIFACT_STORAGE_MODE=filesystem`, the default outside deployed environments): writes to `ARTIFACT_STORAGE_DIR` (default `.data/artifacts`) with mode `0600` and `wx` (fail if exists). `safeLocalPath` rejects any key that escapes the configured root.\\n- **Azure mode** (`ARTIFACT_STORAGE_MODE=azure`): uses `BlobServiceClient` with `DefaultAzureCredential`, container defaults to `evidence`. `signedReadUrl` produces a short-lived (default 120s) SAS read URL using a user-delegation key.\\n\\nThe mode is selected by `config.isDeployed`, not `NODE_ENV`, so a host that sets `ASSUREWORX_ENV=prod` without `NODE_ENV` still gets Azure (the comment in source explains this choice: identity evidence on ephemeral container disk would be lost on every restart and sit outside backup/retention controls).\\n\\n`MAX_ARTIFACT_BYTES = 10 * 1024 * 1024` is the upload ceiling.\\n\\nExternal callers in the codebase: the retention sweep (`sweepSoftDeletedArtifacts`) and job handlers (`registerJobHandlers`) use `artifactStorage()` to remove or read artifact bytes.\\n\\n---\\n\\n## Extraction Pipeline\\n\\nThe extraction orchestrator (`extractDocument`) implements an escalation ladder described in ADR-0019 (amended by ADR-0032). It reads an uploaded document, proposes the fields the upload form asks for, and reports what the rules make of them.\\n\\n### The ladder\\n\\n```mermaid\\nflowchart TD\\n    Start[extractDocument] --&gt; Never{NEVER_EXTRACT?A002}\\n    Never --&gt;|yes| Skipped[status: skipped]\\n    Never --&gt;|no| Ladder[Iterate sourcesFor contentType]\\n    Ladder --&gt; Read[source.read]\\n    Read --&gt; Few{text.length &lt; MIN_USEFUL_CHARS}\\n    Few --&gt;|yes| Next[Try next tier]\\n    Few --&gt;|no| Rules[applyRules for class]\\n    Rules --&gt; Score{confidence &gt;= ESCALATE_BELOW?}\\n    Score --&gt;|yes| Done[Return best]\\n    Score --&gt;|no| Improve{canOcrImprove read?}\\n    Improve --&gt;|yes| Next\\n    Improve --&gt;|no| Done\\n    Next --&gt;|no more tiers| Unreadable[status: unreadable]\\n```\\n\\nThe tiers, cheapest first:\\n\\n1. **Local PDF text layer** \u2014 `pdf-text` (used when `textLayerIsSound` / `canOcrImprove` allows). A PDF with an extractable text layer is read locally at no cost.\\n2. **Cloud OCR** \u2014 `cloud-ocr`, Azure Document Intelligence `prebuilt-read` (model pinned as a constant \u2014 see below).\\n\\nA tier's candidate is accepted if its confidence reaches `ESCALATE_BELOW`; otherwise the next tier gets a turn and the best of both wins. The ladder stops early when `canOcrImprove(read)` is false \u2014 if the local tier handed us every character in the file exactly, a dearer reader returns the same characters and the shortfall is in the rules, not the reading.\\n\\nIdentity documents (`A002`) never enter the pipeline, per ADR-0019 \u00a75. The extraction result is `skipped` with an explanatory reason.\\n\\n### Cloud-read tier (`cloud-read.ts`)\\n\\nThis is the most heavily guarded component. Everything in it exists to make two failure modes impossible: **spending money unintentionally**, and **making a tradesperson wait for an unhealthy service**.\\n\\n**Cost governor**\\n\\n- The model id is a constant, not configuration \u2014 because the model id *is* the price on this endpoint. `prebuilt-read` bills the `S0 Read Pages` meter at US$1.50/1k pages; add-on features and other prebuilt models bill at US$6\u2013$200 on the same verb. No `features`, `queryFields`, or `outputContentFormat` parameter is ever sent.\\n- Pages are capped twice: in the request itself (`pages=1-N` query parameter) and in `core.t_meter` before the call.\\n- `reserve()` checks the party's 24-hour pages-used from the audit log (`t_event`, not process memory), then reserves units from the monthly meter. Order matters: cheap in-memory checks run first, and the monthly cap is reserved last so a party-level refusal never consumes global budget.\\n- After a successful read, `settle(actualPages)` refunds the unreserved difference. On any failure, `releaseAll()` gives the whole reservation back.\\n- Every billable outbound read is audited with verb `extraction.cloud_read`, recording page count, model, region, and duration \u2014 making the spend attributable per party. The audit is best-effort: it logs a warning, never fails an upload.\\n\\n**Failure containment**\\n\\n- `Semaphore` caps concurrent in-flight analyses (`config.docIntel.maxConcurrent`). A caller that cannot get a slot inside its budget gives up rather than queueing, because the upload is waiting.\\n- `CircuitBreaker` takes the tier offline after 5 consecutive infrastructure failures for 60s. Document-level rejections (corrupt PDF, unsupported page count, a document the service refused) never count \u2014 they are the document's fault.\\n- The whole read runs inside a wall-clock budget (`config.docIntel.budgetMs`). The orchestrator also imposes a 30s outer ceiling on every tier.\\n- The user-facing error for every refusal is the same deliberately uninformative string: `\\\"This file could not be read automatically.\\\"` A monthly cap, a tripped breaker, and a throttled provider share one honest outcome \u2014 key the fields in by hand. The real reason goes to `console.warn` with the `[doc-intel]` prefix.\\n\\n**Preprocessing** (`preprocess.ts`)\\n\\nBefore any bytes leave the process:\\n\\n- `gateUploadPages` rejects binders and policy wordings above `MAX_UPLOAD_PAGES = 10` with code `TOO_MANY_PAGES` and a message telling the user to upload only the certificate cover or licence face.\\n- `prepareCloudPayload` trims PDFs to the DI page ceiling (matching the `pages=1-N` bill), downscales long image edges to `CLOUD_IMAGE_MAX_EDGE = 2000`, and re-encodes large photos as JPEG. The original upload bytes are unchanged for storage and review \u2014 only the cloud-read path receives the prepared buffer.\\n\\n### Parsers (`parse.ts`)\\n\\nPure, synchronous, fail-safe primitives. Each parser returns `null` rather than guessing:\\n\\n- `findDates` \u2014 all dates in document order, with pattern-specific confidence. Australian documents are day-first, so `03/04/2026` is 3 April; when both components could be a month, confidence drops to 0.74 to say so. Two-digit years pivot at 70 (`CENTURY_PIVOT`): `26` \u2192 2026, `85` \u2192 1985.\\n- `findLabelledDate` / `findLabelledDateRange` \u2014 the first date (or `from \u2026 to` pair) after a set of labels, with a 90-character `LABEL_WINDOW`. The range reader is described in the source as \\\"the most reliable single signal in the whole pipeline\\\" because certificates of currency almost always state their period of insurance this way.\\n- `findMoney` / `findLabelledMoney` \u2014 currency amounts with `k/m/b` scales. `findLabelledMoney` returns the **largest** amount in the window, not the nearest, because a liability schedule commonly lists an excess right beside the limit and the limit is the number that matters.\\n- `isValidAbn` / `isValidAcn` \u2014 checksum validation (ATO modulus 89 for ABN, ASIC modulus 10 for ACN). `findAbns` rejects 11-digit runs that are really phone numbers; `findAcns` drops candidates that sit inside a valid ABN (the trailing nine digits of an ABN are the ACN).\\n- `findLabelledReference` \u2014 reads multi-token reference numbers (`Licence Number: EC 45871C`) and drops trailing tokens without digits, because a reference always ends in something numeric.\\n- `nameTokens` / `nameSimilarity` \u2014 loose company-name comparison that strips `pty`, `ltd`, `t/a`, and other noise for matching an ABN read off a page with the party on file.\\n\\n### Rule packs (`rules.ts`)\\n\\nA rule pack knows how one family of Australian compliance documents is laid out: which labels carry the expiry, what the reference number is called, and who the issuer is likely to be. Packs fail open \u2014 an unrecognised layout returns nothing, never a guess.\\n\\nThe file defines large dictionaries of Australian issuers (`LICENCE_AUTHORITIES` covering every state regulator from NSW Fair Trading to NT WorkSafe, `INSURERS` covering QBE, Allianz, CGU, Vero, Zurich, and ~25 more), plus constants for White Card units and High Risk Work Licence class codes.\\n\\nShared readers (`readDates`, `readAbn`, `readAcn`, `readJurisdiction`, `readName`, `readReference`) are composed by each class pack. `readAbn` prefers the ABN belonging to the uploading party when several appear on one certificate (broker, insurer, and insured ABNs commonly share a page), and lifts confidence to 0.99 when it finds that one.\\n\\n`readDates` has a deliberate hierarchy: a labelled `period of insurance` range beats a labelled single date, which beats the raw shape of two dates on the page (later date = expiry, penalised 0.3 confidence for being a guess). For insurance classes, the period range is *authoritative* \u2014 the \\\"date of issue\\\" is only when the certificate was printed, which the product does not care about.\\n\\n### Validation and decision (`validate.ts`, `decide-status.ts`)\\n\\n`validate` runs over the suggested fields and produces `Finding`s with levels `blocker` / `warning` / `info`, including `CLASS_MISMATCH`, `COVER_BELOW_MINIMUM`, and `DECLARED_EXPIRY_DIFFERS`. `scoreExtraction` combines rule confidence with per-character OCR confidence.\\n\\n`decideArtifactStatus` is the auto-accept gate:\\n\\n- `status !== \\\"extracted\\\"` \u2192 `F01` (hold)\\n- `confidence &lt; ESCALATE_BELOW` \u2192 `F01`\\n- any blocker finding \u2192 `F01`\\n- otherwise \u2192 `F02`, `autoAccepted: true`\\n\\n`hardRejectFromExtraction` separates the two blocker codes that mean *refuse the upload* (`CLASS_MISMATCH`, `COVER_BELOW_MINIMUM`) from blockers that only mean *hold it for review* (`DECLARED_EXPIRY_DIFFERS`, etc.). An expired high-confidence date is handled separately by `isExpiredHighConfidenceReject`.\\n\\n### Preview cache and presentation\\n\\n- `preview-cache.ts` is an in-process TTL cache (15 minutes, max 200 entries) keyed by `partyId:classCode:sha256`. It exists so `POST /artifacts/extract` (the preview) is not paid for twice when the actual upload immediately follows. Restarting loses the cache; the safe failure mode is extra spend, never a wrong skip. `previewSatisfiesUpload` returns true when a cached result was `extracted` or already attempted cloud OCR.\\n- `present.ts` converts results to the API shapes `ExtractionPreview` and `ExtractionSummary`, applying field ordering and `en-AU` currency formatting. Wording follows `wiki/brand.md`: it says what was *read*, never what was *verified*.\\n- `forStorage` persists the compact `StoredExtraction` form onto `t_artifact.attrs.extraction` \u2014 dropping evidence snippets, because the document itself is already the record of what it says.\\n\\n---\\n\\n## Key Exports\\n\\n| Symbol | Purpose |\\n|---|---|\\n| `getEvidenceForParty(db, partyId)` | Single-party evidence rows (parties, directory, nodes, employee placement, verified-status) |\\n| `getEvidenceForParties(db, partyIds)` | Batched evidence rows (directory, reporting, parties index) |\\n| `buildChecklist(db, partyId, options)` | Full requirement checklist (parties, onboarding, payments eligibility) |\\n| `summariseDocuments(evidence)` | Total / current / attention counts for directory rows |\\n| `primaryActionReason(rows, partyStatus)` | One-line attention reason for directory cards |\\n| `deriveEvidenceStatus(artifact, alertDays)` | Artifact \u2192 UI status mapping |\\n| `artifactStorage()` | Singleton artifact store (filesystem or Azure) |\\n| `extractDocument(bytes, contentType, ctx, opts)` | Orchestrated extraction with `allowCloud` option to skip metered OCR |\\n| `decideArtifactStatus(result)` | F01/F02 auto-accept decision |\\n| `hardRejectFromExtraction(result)` | Refuse-upload decision for class/cost mismatches |\\n| `prefillableFields(result)` | Confident-only values safe to pre-fill into the upload form |\\n| `forStorage(result)` | Compact persisted form for `attrs.extraction` |\\n| `cloudReadStatus()` | Operational view (breaker state, monthly meter remaining) for the platform console |\\n\\n---\\n\\n## Integration Points\\n\\nThe call graph shows this module is consumed from nearly every functional area:\\n\\n- **Parties module** \u2014 evidence rows, checklist, document summaries for party profiles.\\n- **Directory module** \u2014 per-party document summaries, people rollups (`loadPeopleCounts`), PC reviews of artifacts (`loadPcReviews` / `attachPcReviews`), and `primaryActionReason` for directory cards.\\n- **Onboarding module** \u2014 checklist for invite-driven onboarding (including `trade_types` tag resolution, where selected classifications add required document classes).\\n- **Payments eligibility** \u2014 `evaluateEligibility` calls `buildChecklist` to gate access.\\n- **Jobs/retention** \u2014 artifact bytes are read/removed via `artifactStorage()`.\\n- **Verified-status service** \u2014 `computeStatus` calls `getEvidenceForParty`.\\n\\nOutbound, the module relies on:\\n\\n- `identity-check.ts` (`identityStatusForCompliance`, `identityIsCurrent`) \u2014 Didit identity outcomes.\\n- `verified-status.ts` (`hasAbnIdent`, `latestCheckPassed`) \u2014 ABR check results.\\n- `meter.ts` (`reserveUnits`, `releaseUnits`, `readMeter`) \u2014 monthly page budget in `core.t_meter`.\\n- `audit.ts` \u2014 `extraction.cloud_read` event recording.\\n- `db/pool.ts` `withBypass` \u2014 for meter and audit writes that must not be blocked by tenant-row security.\\n\\n---\\n\\n## Operational Notes\\n\\n**Environment configuration**\\n\\n| Variable | Default | Purpose |\\n|---|---|---|\\n| `ARTIFACT_STORAGE_MODE` | `filesystem` locally, `azure` when deployed | Storage backend |\\n| `ARTIFACT_STORAGE_DIR` | `.data/artifacts` | Filesystem root |\\n| `AZURE_STORAGE_ACCOUNT` | \u2014 | Required for Azure mode |\\n| `AZURE_STORAGE_CONTAINER` | `evidence` | Blob container |\\n| `DOC_INTEL_MONTHLY_PAGE_CAP` | via `config.docIntel` | Monthly page budget in `t_meter` |\\n| `config.docIntel.maxConcurrent` | \u2014 | In-flight OCR analyses |\\n| `config.docIntel.maxPagesPerDocument` | \u2014 | DI request page ceiling |\\n| `config.docIntel.dailyPagesPerParty` | \u2014 | Per-party 24-hour cap |\\n| `config.docIntel.budgetMs` | \u2014 | Wall-clock budget per cloud read |\\n\\n**Deliberate non-configurables**\\n\\n- The Document Intelligence model id is `prebuilt-read`, pinned in code. Moving to a different model changes the bill and must be a reviewed code change, not a config change.\\n- `MAX_UPLOAD_PAGES = 10` and `CLOUD_IMAGE_MAX_EDGE = 2000` are constants with tests asserting their behaviour.\\n\\n**Failure philosophy**\\n\\n- Extraction is advisory. A rule pack throwing, a cloud read timing out, or a cap refusing the call must degrade to \\\"we could not read it\\\" \u2014 never to a failed upload or a confident wrong value.\\n- A pre-filled field must meet `LOW_CONFIDENCE`; a low-confidence expiry still reaches the reviewer as a finding, but is never shown to the tradesperson, because a pre-filled field gets confirmed without being read.\\n- The only user-facing message for any cloud-read refusal is `\\\"This file could not be read automatically.\\\"` \u2014 operational facts are for the log, not the uploader.\",\"evidence-checks-compliance-frontend-src\":\"# Evidence, Checks &amp; Compliance \u2014 frontend-src\\n\\n# Evidence, Checks &amp; Compliance \u2014 Frontend Module\\n\\n## Overview\\n\\nThis module implements the frontend for collecting, reading, verifying, and displaying compliance evidence \u2014 the documents and checks that prove a person or company is qualified to work. It lives in `frontend/src/components/domain/` with supporting hooks and rules in `frontend/src/lib/`.\\n\\nThe module covers three distinct concerns:\\n\\n- **Upload pipeline** \u2014 choosing a file, extracting what the platform can read from it, pre-filling the upload form, and submitting to the artifacts API.\\n- **Evidence registers &amp; meters** \u2014 two density variants of the document register (`EvidenceBoard`, `EvidenceTable`), a segmented composition meter (`EvidenceMeter`), and a generic status ring (`StatusDonut`).\\n- **Checks &amp; review surfaces** \u2014 the five public core checks (`CoreChecks`), the post-upload extraction summary (`DocumentReadSummary`), and the principal-contractor (PC) review flow embedded in `EvidenceBoard`.\\n\\n## Design Principles\\n\\n- **Reading is not verifying (ADR-0019).** `DocumentReadPreview` reports what the platform *read* out of a file \u2014 never what was *verified*. Verification remains a human decision. A document with an `EXPIRED` finding is blocked from upload, but the read values themselves are always presented as suggestions to check.\\n- **Wording discipline.** \\\"Verified\\\" is reserved for third-party checks and overall party status (S02). Uploaded documents are \\\"Collected\\\"; assessment methods are \\\"Auto check\\\", \\\"Document check\\\", or \\\"Self-declared\\\" \u2014 never \\\"verified\\\". `src/lib/check-labels.ts` centralises this wording so it cannot drift.\\n- **A failed read is not an error.** The extraction endpoint (`POST /artifacts/extract`) stores nothing, so a failed read is never surfaced as a blocking error \u2014 the form simply falls back to manual entry. This is documented in `use-extraction.ts` and reflected in `DocumentReadPreview`, which renders an informational panel rather than an error for unreadable files.\\n\\n## Component Directory\\n\\n| Component | Purpose | Key consumers |\\n|---|---|---|\\n| `EvidenceUploadForm` | Choose file \u2192 extract \u2192 upload evidence | Documents pages, `VerifyEvidenceStep`, profile `EvidenceDrawer` |\\n| `DocumentReadPreview` | Shows extraction result before upload | `EvidenceUploadForm`, onboarding `DocumentsClient` |\\n| `DocumentReadSummary` | Shows extraction result for an already-uploaded document | `AdminClient` |\\n| `DocumentScanOverlay` | Full-viewport \\\"scanning\\\" animation while extraction runs | `DocumentReadPreview` |\\n| `EvidenceBoard` | Evidence register with PC accept/decline review | `CompanyProfileClient`, `IndividualProfileClient` |\\n| `EvidenceTable` | Dense document register with sortable columns and expandable rows | profile `DocumentsClient` |\\n| `EvidenceMeter` | Segmented track + labelled counts for evidence composition | Profile clients |\\n| `StatusDonut` | Generic SVG composition ring with legend | `WorkforceMetricRow` |\\n| `CoreChecks` | The five public checks, rendered as a list with optional drawers | Profile clients, `ReviewClient` |\\n| `useDocumentExtraction` | Hook wrapping `POST /artifacts/extract` | `EvidenceUploadForm`, onboarding `DocumentsClient` |\\n| `check-labels.ts` | Shared wording for check states and evidence methods | `CoreChecks`, `EvidenceBoard`, `EvidenceTable` |\\n| `evidence-groups.ts` | Artifact class-code grouping (licence / insurance / certificates) | `VerifyTradeClient`, `VerifyEvidenceStep` |\\n\\n## Evidence Upload Pipeline\\n\\nThe upload flow is the most state-heavy part of the module. It is orchestrated by `EvidenceUploadForm` with support from `useDocumentExtraction` (extraction call) and `DocumentReadPreview` (result display).\\n\\n```mermaid\\nflowchart LR\\n    A[Choose file] --&gt; B{Valid?}\\n    B --&gt;|no| C[Inline form error]\\n    B --&gt;|yes| D[extraction.readPOST /artifacts/extract]\\n    D --&gt; E[DocumentReadPreview]\\n    E --&gt; F[DocumentScanOverlaywhile reading]\\n    D --&gt; G{Read values?}\\n    G --&gt;|no| H[Manual entry shown]\\n    G --&gt;|yes| I[Form prefilledfrom preview.prefill]\\n    H --&gt; J[Submit \u2192 POST /artifacts]\\n    I --&gt; J\\n```\\n\\n### File Validation\\n\\n`chooseFile` validates before any network call:\\n\\n- **Extension** must be `.pdf`, `.jpg`, `.jpeg`, or `.png` \u2014 otherwise \\\"Choose a PDF, PNG or JPEG file.\\\"\\n- **Empty file** \u2014 rejected with \\\"This file is empty\u2026\\\"\\n- **Size** \u2014 `MAX_FILE_BYTES = 10 MB` \u2014 rejected with \\\"This file is larger than 10 MB\u2026\\\"\\n\\nEach rejection resets the file input and clears previous upload/extraction state.\\n\\n### Extraction &amp; Prefill\\n\\nWhen a valid file is selected, `useDocumentExtraction.read(file, classCode)` is called. The hook:\\n\\n1. Aborts any in-flight extraction (`inFlight.current?.abort()`) \u2014 a second file choice must never let a stale read overwrite newer suggestions.\\n2. Sets `reading = true` and clears the previous preview.\\n3. Posts a `FormData` payload (`file` + `classCode`) to `/artifacts/extract`.\\n4. On success, stores the `ExtractionPreview` and returns it; on abort or failure, returns `null` without setting preview state.\\n\\nBack in `EvidenceUploadForm`, the resolved preview pre-fills the form:\\n\\n```ts\\nsetForm({\\n  issuedOn: preview.prefill.issuedOn || undefined,\\n  expiresOn: preview.prefill.expiresOn || undefined,\\n  authority: preview.prefill.authority || undefined,\\n  reference: preview.prefill.reference || undefined,\\n});\\n```\\n\\nOnly high-confidence values are offered as pre-fill. `DocumentReadPreview` marks any value with `confidence &lt; 0.8` with a `| check` suffix \u2014 matching the API's own threshold (`LOW_CONFIDENCE = 0.8`).\\n\\n### Read Preview States\\n\\n`DocumentReadPreview` renders four distinct states:\\n\\n| State | Condition | UI |\\n|---|---|---|\\n| Reading | `reading === true` | `DocumentScanOverlay` + panel with spinner (\\\"Reading your document\\\") |\\n| Hidden | `!preview \\\\|\\\\| preview.status === \\\"skipped\\\"` | `null` |\\n| Unreadable | `status !== \\\"extracted\\\" \\\\|\\\\| read.length === 0` | \\\"Could not read this document\\\" + guidance to photograph the full page |\\n| Extracted | `status === \\\"extracted\\\"` with values | \\\"Read from your document\\\" (or \\\"This document has expired\\\") + values grid + findings + caveat |\\n\\nWhen findings include `EXPIRED` **and** `preview.prefill.expiresOn` is set, the panel switches to the expired state: the title becomes \\\"This document has expired\\\", the caveat says an expired document cannot be uploaded, and the submit button is disabled.\\n\\nFindings render with level-specific styling (`blocker`, `warning`, `info`) and icons in a `\n` keyed by `finding.code`.\\n\\n### Submit, Progress &amp; Cancellation\\n\\nSubmit builds a `FormData` body (`file`, `classCode`, plus any non-empty `issuedOn`, `expiresOn`, `authority`, `reference`) and calls `api.upload(\\\"/artifacts\\\", body, { signal, onProgress })`.\\n\\nProgress updates are throttled to avoid re-render storms under multi-tradie / large-file load:\\n\\n- `PROGRESS_THROTTLE_MS = 100` \u2014 minimum time between emits\\n- `PROGRESS_MIN_STEP = 4` \u2014 minimum percentage jump to bypass the throttle\\n\\nThe status line reads \\\"Uploading {n}%\\\", then \\\"Upload received. Saving evidence\u2026\\\" at 100%.\\n\\nCancellation uses an `AbortController` stored in `uploadController.current`. When aborted, the catch block checks `err instanceof ApiClientError &amp;&amp; err.code === \\\"UPLOAD_CANCELLED\\\"` and shows \\\"Upload cancelled. Your selected file is ready to retry.\\\" instead of an error. The cancel button's label switches from \\\"Cancel\\\" to \\\"Cancel upload\\\" while `saving` is true.\\n\\nOther notable behaviours:\\n\\n- `useBeforeUnload(Boolean(file))` warns the user before leaving with an unsent file.\\n- After the scan overlay closes, the action bar scrolls into view on mobile (`actionsRef.scrollIntoView`).\\n- The `expiredBlocked` guard prevents submission of expired documents at the submit handler level as well as in the button's `disabled` state.\\n- In `compact` mode (nested drawers), the action bar is sticky at the bottom of the viewport.\\n\\n## Evidence Registers\\n\\n### EvidenceBoard\\n\\n`EvidenceBoard` is the profile-page register. It renders `EvidenceRow[]` with three columns \u2014 identity, status chip, and a detail column (expiry, method, view link, PC review controls).\\n\\n**Sorting** \u2014 default is \\\"Needs attention first\\\" using `STATUS_ORDER`:\\n\\n```ts\\nexpired: 0, rejected: 1, missing: 2, expiring_soon: 3,\\npending_verification: 4, verified: 5\\n```\\n\\nThe other options sort by document name (localeCompare) or expiry date (missing/absent dates sort last via `\\\"9999\\\"`).\\n\\n**Row tones** \u2014 `data-tone={row.status}` drives background styling: `expired`, `rejected`, and `missing` get an error-tinted background; `expiring_soon` gets a warning tint.\\n\\n**Document identity** \u2014 each row shows a class-specific `MetaIcon` picked by `DOC_ICONS` (A001 \u2192 hardhat, A002 \u2192 idCard, A003 \u2192 abn, A004 \u2192 insurance, A005 \u2192 people, A1xx \u2192 licence/shield etc.), falling back to `\\\"document\\\"`.\\n\\n**View link** \u2014 rows with an `artifactId` link to `/api/v1/artifacts/{artifactId}/content` (opened in a new tab; access is enforced by network ACL).\\n\\n**PC review** \u2014 enabled only when `onPcReview` is provided and `canPcReview(row)` passes (has `artifactId` AND status is `verified` or `expiring_soon`). See *PC Review* below.\\n\\nThe module also exports `evidenceCounts(evidence)`, which reduces a list into `{ total, current, pending, attention }` and is consumed by profile clients to feed `EvidenceMeter`.\\n\\n### EvidenceTable\\n\\n`EvidenceTable` is the denser register for the Documents tab. It provides:\\n\\n- **Sortable columns** \u2014 Status, Expires, Name \u2014 via buttons that toggle direction (`asc`/`desc`). The active sort button shows `\u2191`/`\u2193` and gets an active style.\\n- **Readiness column** \u2014 a label plus a percentage and a progress bar. `readiness(row)` maps statuses to labels and progress values:\\n\\n| Status | Label | Progress |\\n|---|---|---|\\n| `verified` | `Collected \\\\| exp {date}` / `ABR matched` / `Collected \\\\| no expiry` | 100 |\\n| `expiring_soon` | `Expires {date}` / `Expiring soon` | 72 |\\n| `pending_verification` | `Document check in progress` | 60 |\\n| `expired` | `Expired {date}` / `Expired` | 0 |\\n| `rejected` | `Review rejected` | 0 |\\n| `missing` | `Awaiting upload` | 0 |\\n\\n- **Document glyphs** \u2014 `documentMark(row)` derives a two-letter tile (AB for ABN, CP for workers compensation, WC for white card, PL for public liability, or initials of the document name). The exported `evidenceDocumentIcon(row)` helper is used by `MobileDocumentList` to pick a `MetaIcon`.\\n- **Expandable detail rows** \u2014 when `expandedClassCode` matches a row's `classCode`, the optional `renderDetail(row)` output renders immediately beneath it.\\n- **Responsive collapse** \u2014 the Type column hides below 1399px; below 760px rows become a card layout with named grid areas.\\n\\n### EvidenceMeter\\n\\n`EvidenceMeter` shows evidence composition as a segmented track with labelled counts. It is deliberately *not* a hero-metric strip \u2014 no lonely big numbers.\\n\\nInput counts are clamped defensively: `current \u2264 total`, `attention \u2264 total - current`, `pending \u2264 remaining`. The track renders four possible segments in a fixed visual order: **current** (blue), **pending** (violet), **attention** (deep violet), **empty** (grey).\\n\\nAccessibility is handled via an `aria-label` that spells out the composition, e.g. `\\\"4 of 9 documents collected, 2 need attention, 1 pending verification\\\"`. When `itemName` is `\\\"people\\\"`, the wording flips to \\\"current\\\" instead of \\\"collected\\\".\\n\\nThe legend (shown by default at `md` size) pairs each swatch with an icon (check / clock / alert) and a count. At `sm` size without a legend, an attention line (`\\\"{n} need attention\\\"`) appears only when attention is non-zero.\\n\\n### StatusDonut\\n\\n`StatusDonut` is a generic SVG composition ring: a 132-unit viewBox with 14-unit stroke arcs drawn via `strokeDasharray`/`strokeDashoffset`, rotated -90\u00b0 to start at 12 o'clock. Tones map to the RAG indicator ramp (`ready`, `pending`, `attention`, `blocked`, `idle`).\\n\\nPer its doc comment, colour never carries meaning on its own \u2014 every segment is also named and counted in the legend unless `showLegend={false}` (in which case the host supplies its own legend). The centre shows the total count and the unit noun.\\n\\n## Core Checks\\n\\n`CoreChecks` renders the five public checks from `RequirementChecklist[\\\"coreChecks\\\"]` \u2014 ABN, identity, licence, insurance, and qualifications \u2014 as a single ordered list.\\n\\n**State presentation:**\\n\\n| State | Label | Mark |\\n|---|---|---|\\n| `passed` | `coreCheckPassedLabel(key)` \u2014 \\\"Verified by Didit\\\" (identity), \\\"Verified by ABR\\\" (abn), otherwise \\\"Collected\\\" | check |\\n| `pending` | \\\"Pending\\\" | clock circle |\\n| `failed` | \\\"Failed\\\" | X |\\n| `not_required` | \\\"Not required\\\" | dash |\\n\\nThe passed-state wording is delegated to `coreCheckPassedLabel` so the distinction between third-party verified checks and collected documents stays consistent with the rest of the app.\\n\\n**Expandable drawers** \u2014 a check row becomes expandable when the caller provides both a `drawers[key]` node and an `onExpandedChange` handler. Expansion is fully controlled via `expandedKey`; clicking the row toggles between the key and `null`. The row button sets `aria-expanded` and `aria-controls` (pointing to `check-drawer-{key}`), and is disabled when no drawer exists.\\n\\n**Animation** \u2014 the drawer uses a `grid-template-rows: 0fr \u2192 auto` transition with a clipped inner wrapper, so it grows smoothly when upload/scan content appears. The chevron rotates 180\u00b0 when open. All transitions are disabled under `prefers-reduced-motion`.\\n\\nConsumers include `CompanyProfileClient`, `IndividualProfileClient`, and `ReviewClient`. In the verify context, the drawers host inline `EvidenceUploadForm` instances for missing evidence.\\n\\n## PC Review\\n\\n`EvidenceBoard` embeds the principal-contractor acknowledgement flow. It is opt-in: pass `onPcReview` to enable it.\\n\\n- **Eligibility** \u2014 `canPcReview(row)` requires an `artifactId` and a status of `verified` or `expiring_soon`.\\n- **Accept** \u2014 a checkbox labelled \\\"Accepted for your organisation\\\". Checking sends `onPcReview(artifactId, \\\"accept\\\")`; unchecking sends `\\\"clear\\\"`.\\n- **Decline** \u2014 an inline reason input (max 1000 chars) with \\\"Save decline\\\" / \\\"Cancel\\\" actions. The reason is pre-filled from a previous `pcReview.reason` when reopening. The save button is disabled while busy or when the reason is empty.\\n- **Busy state** \u2014 `pcReviewBusyId` disables all controls for the matching row.\\n- **Errors** \u2014 any rejection from `onPcReview` is surfaced in a `role=\\\"alert\\\"` strip above the list and cleared on the next attempt.\\n\\nRows whose `pcReview.decision === \\\"accepted\\\"` render the checkbox checked; declined rows show \\\"Your note: {reason}\\\" in the identity column, and the decline button reads \\\"Update decline\\\".\\n\\n## Review Surfaces\\n\\n`DocumentReadSummary` is the post-upload counterpart to `DocumentReadPreview`. It is shown to reviewers and admins against an already-uploaded document:\\n\\n| State | UI |\\n|---|---|\\n| `null` or `status === \\\"skipped\\\"` | nothing rendered |\\n| `status !== \\\"extracted\\\"` | \\\"Not read automatically\\\" + `extraction.reason` (or a generic fallback) |\\n| `status === \\\"extracted\\\"` | \\\"Read from the document\\\" + values grid + findings |\\n\\nIt reuses the same `Values` and `Findings` presentation as the pre-upload preview, but includes no caveat about checking before uploading \u2014 the decision phase has passed.\\n\\n## Domain Helpers\\n\\n### `src/lib/check-labels.ts`\\n\\nCentralises wording across checks and evidence:\\n\\n```ts\\ncoreCheckPassedLabel(key)\\n// \\\"Verified by Didit\\\"  (identity)\\n// \\\"Verified by ABR\\\"    (abn)\\n// \\\"Collected\\\"          (licence, insurance, qualifications, anything else)\\n\\nEVIDENCE_METHOD_LABELS\\n// { api: \\\"Auto check\\\", manual: \\\"Document check\\\", self_declared: \\\"Self-declared\\\" }\\n```\\n\\nThe module comment is worth reading before changing labels: identity/ABN are third-party check outcomes \u2192 \\\"Verified by\u2026\\\", uploaded documents are collected evidence \u2192 \\\"Collected\\\", and overall party status stays \\\"Verified\\\" (S02).\\n\\n### `src/lib/evidence-groups.ts`\\n\\nClass-code predicates used by verify/profile check drawers and the verify evidence step:\\n\\n- `isLicenceClass(classCode)` \u2014 trade licence/authority cards: `/^A1(0[1-9]|1[0-9])$/` excluding `A110`, `A112`, `A113`, `A114`, `A116`.\\n- `isInsuranceClass(classCode)` \u2014 `A004`, `A005`, `A110` (public liability / workers comp certificates of currency).\\n- `isCertificateClass(classCode)` \u2014 `A112`, `A113`, `A114`, `A116`, `A001`, plus all insurance classes (White Card and other certs/inductions shown with insurance).\\n- `matchesEvidenceGroup(group, classCode)` \u2014 dispatches to the above.\\n\\nThese must stay in sync with `ProfileClient` and `WorkChecksBoard` filters.\\n\\n### `src/lib/use-extraction.ts`\\n\\nThe extraction hook. Key behaviours:\\n\\n- `read(file, classCode)` aborts the previous in-flight request before starting a new one, guaranteeing the newest file choice wins.\\n- Results are only committed to state if the request was not aborted (`if (controller.signal.aborted) return null`).\\n- The `finally` block only clears `inFlight` and `reading` if this controller is still the current one \u2014 a stale read can never reset state for a newer one.\\n- The component unmount cleanup aborts any in-flight read.\\n\\n## Integration Map\\n\\n| Consumer | What it uses |\\n|---|---|\\n| `CompanyProfileClient` | `EvidenceBoard`, `EvidenceMeter`, `CoreChecks`, `evidenceCounts` |\\n| `IndividualProfileClient` | `EvidenceBoard`, `EvidenceMeter`, `CoreChecks`, `evidenceCounts` |\\n| `DocumentsClient` (profile) | `EvidenceTable`, `EvidenceUploadForm` |\\n| `MobileDocumentList` | `EvidenceUploadForm`, `evidenceDocumentIcon` |\\n| `DocumentsClient` (onboarding) | `DocumentReadPreview`, `useDocumentExtraction` |\\n| `ReviewClient` | `CoreChecks` |\\n| `AdminClient` | `DocumentReadSummary` |\\n| `VerifyEvidenceStep` | `EvidenceUploadForm`, `matchesEvidenceGroup` |\\n| `VerifyTradeClient` | `isLicenceClass` |\\n| `ProfileClient` \u2192 `EvidenceDrawer` | `EvidenceUploadForm` |\\n| `WorkforceMetricRow` | `StatusDonut` |\\n\\n## Status Reference\\n\\n**Evidence row statuses** (`EvidenceRow[\\\"status\\\"]`):\\n\\n| Status | Sort order | Board tone | Table readiness | PC review eligible |\\n|---|---|---|---|---|\\n| `expired` | 0 | error | 0% | no |\\n| `rejected` | 1 | error | 0% | no |\\n| `missing` | 2 | error | 0% | no |\\n| `expiring_soon` | 3 | warning | 72% | yes |\\n| `pending_verification` | 4 | default | 60% | no |\\n| `verified` | 5 | default | 100% | yes |\\n\\n**Core check states** (`RequirementChecklist[\\\"coreChecks\\\"][number][\\\"state\\\"]`):\\n\\n| State | Label |\\n|---|---|\\n| `passed` | `coreCheckPassedLabel(key)` \u2014 \\\"Verified by Didit\\\" / \\\"Verified by ABR\\\" / \\\"Collected\\\" |\\n| `pending` | \\\"Pending\\\" |\\n| `failed` | \\\"Failed\\\" |\\n| `not_required` | \\\"Not required\\\" |\",\"evidence-checks-compliance\":\"# Evidence, Checks &amp; Compliance\\n\\n# Evidence, Checks &amp; Compliance\\n\\nThe Evidence, Checks &amp; Compliance module is the compliance backbone of the platform. For every party \u2014 company or person \u2014 it answers three questions: **What evidence must this party provide, and in what state is it?** **Is this party compliant enough to submit, be placed on site, or be shown as verified?** **When a document is uploaded, can the platform read it, extract its fields, and accept it automatically \u2014 or does it need a human?**\\n\\n## Sub-modules\\n\\n- [Backend](evidence-checks-compliance-backend-src.md) \u2014 owns artifact storage, derives evidence status (verified / expiring / expired / rejected / missing), computes the five public core checks on a party's compliance checklist, and drives the tiered document-extraction pipeline.\\n- [Frontend](evidence-checks-compliance-frontend-src.md) \u2014 implements the upload pipeline, the evidence registers and meters (`EvidenceBoard`, `EvidenceTable`, `EvidenceMeter`, `StatusRing`), and the `CoreChecks` display.\\n- [Shared Services &amp; Extraction](evidence-checks-compliance-shared-extraction.md) \u2014 the TypeScript layer that queries and normalizes evidence (`getEvidenceForParties`, `loadPcReviews`, `iso`), builds checklists (`buildChecklist`, `okEvidence`), resolves engagement readiness (`resolveEngagementReadiness`), persists artifacts (`put`, `safeLocalPath`), and performs rule-based document extraction (`extractDocument`, cloud `read`, `parse`, `validate`).\\n\\n## How they fit together\\n\\nThe shared services are the connective tissue. `buildChecklist` calls `loadArtifactClasses` and `okEvidence` to assemble a party's checklist; `resolveEngagementReadiness` calls `loadArtifactClasses` for the same artifact-class data; `getEvidenceForParties` normalizes evidence to a canonical ISO shape (`iso`, `isoDate`). The backend provides the source-of-truth artifacts and derived statuses that these services consume; the frontend renders the results as registers, meters, and core checks, and drives the upload flow that feeds the backend.\\n\\n## Key workflows\\n\\n**Document ingestion &amp; extraction.** The frontend upload pipeline hands a file to the extraction services. `extractDocument` applies rules (`read` \u2192 `readDates` \u2192 `findLabelledDateRange` \u2192 `findLabels` for labelled dates; `fromDate` \u2192 `value` for values), extracts ABNs/ACNs via `readAbn`/`readAcn` using `evidenceAround`, and validates results against declared values (`withDeclared` \u2192 `compareDeclared`). Cloud reads `reserve` a result and `awaitResult` it under a `withBudget` budget; `prepareCloudPayload` truncates PDFs (`truncatePdf`) and `getPreviewExtraction` serves a preview cache. Extracted values pre-fill the upload form (`DocumentReadPreview` \u2192 `Values`) and are submitted via `put` to backend artifact storage.\\n\\n**Status &amp; readiness.** The backend derives per-evidence status; shared services aggregate it for decision points. `buildChecklist` produces the compliance checklist, `resolveEngagementReadiness` / `resolveEngagementReadinessForLink` decide whether a party is ready to proceed, and `primaryActionReason` determines the next action the UI should offer. The frontend surfaces these through `CoreChecks` (with `toggle`) and the evidence registers.\\n\\n**Registers &amp; meters.** `EvidenceBoard` (with `docIcon`) and `EvidenceTable` render the document registers at two densities; `EvidenceMeter` shows segmented composition and `StatusRing` shows the current state. `matchesEvidenceGroup` and `isLicenceClass` in `evidence-groups.ts` determine how licence-class documents are grouped.\\n\\n```mermaid\\ngraph LR\\n    Upload[\\\"Upload pipeline\\\"] --&gt; Extract[\\\"extractDocument: rules / parse / validate\\\"]\\n    Extract --&gt;|\\\"pre-filled values\\\"| Upload\\n    Upload --&gt;|\\\"put\\\"| Backend[\\\"Backend: artifact storage &amp; status derivation\\\"]\\n    Backend --&gt; Services[\\\"getEvidenceForParties / buildChecklist / resolveEngagementReadiness\\\"]\\n    Services --&gt; Displays[\\\"EvidenceBoard / EvidenceTable / EvidenceMeter / CoreChecks\\\"]\\n```\",\"frontend-app-shell-ui-components\":\"# Frontend App Shell &amp; UI Components\\n\\n# Frontend App Shell &amp; UI Components\\n\\n## Module overview\\n\\nThis module provides the authenticated application frame for Assureworx and the shared UI primitives used across feature pages. It is split into two directories:\\n\\n- **`frontend/src/components/shell`** \u2014 persistent chrome: `AppShell`, `Sidebar`, `TopBar`, `HistoryNav`, `NavIcon`, and the title-slot context.\\n- **`frontend/src/components/ui`** \u2014 reusable presentational components: `Button`, `Panel`, `Modal`, `Toast`, `Field`, `Skeleton`, `StatusChip`, `PartyAvatar`, `MetaIcon`, and others.\\n\\nThe shell is assembled by route-level layouts under `frontend/src/app/(app)/` and depends on session/API infrastructure in `frontend/src/lib` (`session`, `server-session`, `use-api`, `nav`, etc.).\\n\\n## Architecture\\n\\n```mermaid\\nflowchart TD\\n    Root[app/layout.tsxToastProvider + PerfRecorderHost]\\n    AppLayout[app/(app)/layout.tsxgetServerMe + SessionProvider]\\n    AppShell[AppShellshell state + scroll lock]\\n    Sidebar[Sidebarrole nav + org strip]\\n    TopBar[TopBarmenus, notifications, sheets]\\n    Main[Page contentPageHeader + feature components]\\n    PageHeader[PageHeaderportals h1 into TopBar slot]\\n\\n    Root --&gt; AppLayout\\n    AppLayout --&gt; AppShell\\n    AppShell --&gt; Sidebar\\n    AppShell --&gt; TopBar\\n    AppShell --&gt; Main\\n    PageHeader -. h1 portal .-&gt; TopBar\\n```\\n\\n## Route-level layouts\\n\\n### Root layout (`app/layout.tsx`)\\n\\nServer component that composes the two app-wide providers:\\n\\n- `ToastProvider` \u2014 global toast API (see `Toast` below).\\n- `PerfRecorderHost` \u2014 mounts the performance-event recorder.\\n\\nIt also sets `Metadata` (title template `\\\"%s | Assureworx\\\"`, favicon) and the `lang=\\\"en-AU\\\"` attribute.\\n\\n### App layout (`app/(app)/layout.tsx`)\\n\\nThe authenticated portal layout. It is a server component that resolves the session **before** rendering so the first paint already knows the user \u2014 there is no client splash waiting on `/auth/me`.\\n\\nKey flow:\\n\\n1. Calls `await getServerMe()`.\\n2. On success, renders `{children}&lt;\\/AppShell&gt;&lt;\\/SessionProvider&gt;`.\\n3. On error, uses `unstable_rethrow(err)` so Next.js redirect/notFound signals are not swallowed by the error card, then renders a recovery screen.\\n\\nError handling is deliberately nuanced:\\n\\n| Error | Behaviour |\\n|---|---|\\n| 401 | `getServerMe` redirects to login \u2014 never rendered as an error card |\\n| 429 / `RATE_LIMITED` | \\\"Slow down\\\" recovery UI; user is told they are still signed in |\\n| 5xx / network failure | \\\"Can't reach Assureworx\\\" recovery UI; user is told to refresh when the API is back |\\n\\n`getServerMe` is called through React `cache()`, so the layout and the page's own `requirePortal` share a single `/auth/me` request per navigation. The comment in code notes that fetching directly in the layout doubled auth traffic and pushed the shared dev rate-limit bucket into 429s \u2014 keep this behaviour intact.\\n\\nAlso notable: this layout uses plain inline styles for the error screen. A style object using CSS variables is fine; anything more elaborate should live in the component layer.\\n\\n### App error boundary (`app/(app)/error.tsx`)\\n\\nClient component that receives `error` and `reset` from Next's error boundary. It:\\n\\n- Logs the error in `useEffect`.\\n- Detects rate limiting via message regex (`/too many requests|rate.?limit/i`) or `status === 429`.\\n- Renders a \\\"Slow down\\\" or \\\"Something went wrong\\\" card with a `Button` that calls `reset`.\\n\\n### App loading fallback (`app/(app)/loading.tsx`)\\n\\nRenders `` with `aria-busy=\\\"true\\\"` for instant navigation feedback while the next route chunk mounts and fetches.\\n\\n### Entry page (`app/page.tsx`)\\n\\nRole-aware entry redirect. Fetches `/auth/me` via `serverGet`, then:\\n\\n- 401 \u2192 `redirect(\\\"/auth/login\\\")`.\\n- Any other error \u2192 `redirect(\\\"/profile\\\")` (the `(app)` layout will show its recovery UI).\\n- Success \u2192 `redirect(homeFor(me))`.\\n\\nThe comment warns against hardcoding `/dashboard` \u2014 trade users and platform admins have different homes, and a hardcoded redirect caused dashboard\u2194profile bounce loops.\\n\\n## AppShell\\n\\n`AppShell` (`components/shell/AppShell.tsx`) is the client component that owns the shell frame. It is a \\\"use client\\\" component rendered from the server layout.\\n\\n### State\\n\\n| State | Purpose |\\n|---|---|\\n| `navOpen` | mobile drawer open/closed |\\n| `sheetPresent` | whether any topbar sheet is mounted (incl. exit animation), used to keep the brand raised above the veil |\\n| `titleSlot` | the DOM node in the TopBar that receives the page's `\n` |\\n\\n### Title resolution\\n\\n`titleFor(pathname)` looks up a document title from a `TITLES` record, with a pre-sorted longest-prefix-first array (`TITLE_PREFIXES`) so `/settings/account` wins over `/settings`. The lookup is built once at module load \u2014 the comment notes it used to re-sort and re-scan on every render. The resolved title sets `document.title` in an effect.\\n\\n### Scroll management\\n\\nOn mount, the shell locks document scroll (`html`/`body` `overflow: hidden`, `height: 100%`) for every app route, restoring previous values on unmount. Workspaces themselves manage their own scrolling:\\n\\n- `isDashboard` \u2014 special dashboard workspace class when `pathname === \\\"/dashboard\\\"` and `role === \\\"principal\\\"`.\\n- `scrollableWorkspace` \u2014 `/admin` and `/support` pages get a class allowing the workspace to scroll while document scroll stays locked.\\n\\n### Structure\\n\\n```tsx\\n\\n  \\n    \n\\n      Skip to main content&lt;\\/a&gt;\\n      \\n      \\n      \n\\n        \n\\n          {children}&lt;\\/PerfProfiler&gt;\\n        &lt;\\/main&gt;\\n      &lt;\\/div&gt;\\n    &lt;\\/div&gt;\\n  &lt;\\/TopBarTitleSlotContext.Provider&gt;\\n&lt;\\/PerfProfiler&gt;\\n```\\n\\n### Role redirects deliberately absent\\n\\nThere is an explicit comment in the source: role gates live on the server in each page's `requirePortal`. The client must **not** also `router.replace()` on role mismatch \u2014 the comment documents that client + server redirects fighting produced the dashboard\u2194profile request storm and the subsequent 429 \u2192 fake \\\"signed out\\\" loop.\\n\\n## Sidebar\\n\\n`Sidebar` (`components/shell/Sidebar.tsx`) renders the persistent navigation panel.\\n\\n- **Role-based nav**: `navForRole(role, appRole)` returns the nav items; `homeForRole(role, appRole)` returns the brand link target.\\n- **Org strip**: shows `me.active?.orgName` (or `me.name` for `trade_individual` workers whose \\\"company\\\" is the person themselves), a role hint from `ROLE_HINTS`, and a global-access/network-owner/trade-workspace label.\\n- **Legal links**: `LegalLinks` rendered at the bottom with `tone=\\\"muted\\\"`; in drawer mode it closes the nav on navigate.\\n- **Active state**: `pathname === item.href || pathname.startsWith(`${item.href}/`)`, with `aria-current=\\\"page\\\"`.\\n\\n### Drawer behaviour\\n\\nWhen `drawerMode` is true (mobile, `max-width: 1023px`), the sidebar becomes a focus-trapped drawer:\\n\\n- A backdrop `` is rendered; visible only when open.\\n- On open, focus moves to the first focusable in the panel.\\n- `Escape` closes the drawer; `Tab` cycles within the panel.\\n- The panel gets `inert` when collapsed and is `aria-hidden` with `tabIndex={-1}` on all interactive children.\\n- When `expanded` is true (desktop, or mobile + open), `inert` is removed.\\n\\nThe `brandRaised` prop keeps the logo above the sheet veils when a topbar sheet is present or the drawer is open.\\n\\n## TopBar\\n\\n`TopBar` (`components/shell/TopBar.tsx`) is the header. It contains:\\n\\n1. **Mobile menu button** \u2014 toggles the drawer; `aria-expanded`/`aria-controls` wired to `sidebarId`.\\n2. **Page title slot** \u2014 a `` that `PageHeader` portals into.\\n3. **`HistoryNav`** \u2014 back/forward buttons driven by `useAppHistory`.\\n4. **Search** (principal only) \u2014 a dialog sheet with a trade-network search form; submit navigates to `/trades?q=...` (requires \u22653 chars).\\n5. **Notifications** \u2014 bell button with unread badge, opens a portal'd sheet listing up to 14 notifications.\\n6. **Account menu** \u2014 avatar (photo or initials), name/email/org card, account settings, password/Entra entry, org switcher, legal links, sign out.\\n\\n### Sheet rendering pattern\\n\\nSheets (search, notifications, account) are the most intricate part of the TopBar:\\n\\n- **Positioning**: `useSheetCoords(present, anchorRef)` computes `top` (header bottom \u2212 1px) and `right` (window width \u2212 trigger right). It listens to window `resize` and **capture-phase `scroll`** to keep the sheet pinned to its trigger. A `useLayoutEffect` updates coordinates synchronously before paint.\\n- **Mount/unmount with exit animation**: `usePresence(open)` returns `{ present, shown }` so the sheet stays mounted while animating out.\\n- **Portals**: sheets render via `createPortal(..., document.body)` so the app shell's overflow clipping never cuts them off.\\n- **Backdrop**: while any sheet is present, a portal'd backdrop button covers the page; clicking it closes all sheets. The main content area also gets `inert` while a drawer or sheet is open, preventing focus escape.\\n- **Outside-click + Escape**: a document-level `mousedown`/`keydown` listener closes sheets when click is outside both the trigger and the sheet, or on Escape.\\n- **Bridging**: when a sheet is shown, the trigger button gets a \\\"bridged\\\" class so it visually connects to the sheet.\\n\\n### Notifications\\n\\nNotifications are fetched via `useApi(\\\"/notifications\\\")`. The unread count is derived by filtering `!n.readAt`. Clicking a notification:\\n\\n1. Closes the sheet.\\n2. POSTs `/notifications/${n.id}/read` (failures ignored).\\n3. Refreshes the list.\\n4. Pushes `hrefForNotification(n)` via the router.\\n\\nTone icons (`NotifToneIcon`) map the notification tone (danger/warning/success/info) to an inline SVG.\\n\\n### Entra status\\n\\n`useApi(\\\"/auth/entra/status\\\")` is consumed so it reads the cache warmed by the session boot rather than firing a fresh request on every shell mount. When `entra?.enabled`, the account menu shows \\\"Change password\\\" which calls `startEntraSignIn({ loginHint: me.email, returnTo: \\\"/settings/account\\\", prompt: \\\"login\\\" })`. Otherwise it shows \\\"Password and security\\\" navigating to `/settings/account`.\\n\\n## Title slot pattern\\n\\n`PageHeader` and the top bar collaborate through `TopBarTitleSlotContext` (`components/shell/topbar-title-slot.ts`).\\n\\n- `AppShell` owns the context value and passes `titleSlotRef={setTitleSlot}` to `TopBar`.\\n- `TopBar` registers the actual DOM node into that ref.\\n- `PageHeader` (client component) reads the slot from context. If present, it portals the `\n` (optionally with a `NavIcon`) into it. If absent, it renders the heading inside an `sr-only` span so it stays in the accessibility tree without flashing in-page before the portal lands.\\n\\nThis means each page has exactly one visual `\n`, living in the top bar, and the page body shifts up. When a page has `actions`, `PageHeader` renders an in-page actions row; the title itself is never duplicated as a grey subtitle under the page.\\n\\n## UI component library\\n\\n### `Button`\\n\\nRenders either a `` or a Next.js `` with identical styling. Variants: `primary` | `secondary` | `danger` | `ghost`; sizes `md` | `sm`. Supports a `loading` state that disables the control, shows a spinner, and sets `aria-busy`. When used as a link with `disabled`/`loading`, it renders a `` instead of an anchor. This is the most widely used component in the codebase (the call graph lists ~30 consumers across onboarding, verification, pass, admin, and domain components).\\n\\n### `Panel`\\n\\nA titled section with optional `actions`, used for most page content blocks. `flush` removes body padding for tables. `titleStyle=\\\"section\\\"` renders a larger sentence-case heading for primary page sections; the default `\\\"label\\\"` is for smaller labelled groups.\\n\\n### `PageHeader`\\n\\nDiscussed above. Takes `title`, optional `actions`, optional `icon` (a `NavItem[\\\"icon\\\"]` rendered via `NavIcon`).\\n\\n### `Modal`\\n\\nCentered product modal portaled to `document.body` (so it sits above the shell regardless of overflow clipping). Props: `open`, `title`, `onClose`, `children`, `size` (`md` | `lg` | `xl`). While open:\\n\\n- Locks body scroll (restores previous overflow on close).\\n- Focuses the dialog on open via `requestAnimationFrame`.\\n- Closes on `Escape` or scrim click or the close button.\\n- Sets `role=\\\"dialog\\\"`, `aria-modal=\\\"true\\\"`, `aria-labelledby`.\\n\\n### `Field`\\n\\nExports `InputField` and `SelectField`. Both wire up `useId`-generated ids, `aria-describedby` for hint/error, `aria-invalid` on error, and render the required asterisk. Hint renders only when there is no error; errors render with `role=\\\"alert\\\"`.\\n\\n### `Toast`\\n\\nA context-based toast system:\\n\\n- `ToastProvider` (rendered in the root layout) owns the queue and renders a viewport with `role=\\\"region\\\" aria-label=\\\"Status messages\\\"`.\\n- `useToast()` returns a `toast(message, kind?)` function where kind is `\\\"success\\\"` (default) | `\\\"error\\\"` | `\\\"info\\\"`.\\n- Toasts auto-dismiss (4s success/info, 6s error), cap the queue at the last 4, and play a 220ms leave animation before removal.\\n\\nThe context is `null`-checked in `useToast` so misuse fails loudly.\\n\\n### `Skeleton` / `SkeletonRows`\\n\\n`Skeleton` is a single shimmer line; `SkeletonRows` stacks rows of avatar + text + pill shapes to approximate a table or panel. Used by the app loading fallback.\\n\\n### `EmptyState`\\n\\nIcon + title + body + optional action. Icons: `inbox` | `search` | `shield` | `site`. The `inbox` icon is the default and is used by `ComingSoonPage`.\\n\\n### `ComingSoonPage`\\n\\nPlaceholder for routes that land before their module ships. Renders a `PageHeader` + full-width `Panel` containing an `EmptyState` with the message \\\"Not in this release\\\".\\n\\n### `DraftStatus`\\n\\nSmall inline status pill for local draft persistence. Renders \\\"Draft saved\\\"/\\\"Draft restored\\\" on this device with a live `role=\\\"status\\\"`, plus an optional discard button. Returns `null` when `savedAt` is null.\\n\\n### `StatusChip` and mappers\\n\\n`StatusChip` renders a status mark pill (or icon-only mark in dense tables). The visual is driven by a `StatusKind` (`verified` | `pending` | `expiring` | `suspended` | `action_required` | `info` | `neutral`), with the actual mark images from `frontend/public`.\\n\\nThe file exports a set of **mapper functions** that translate logical API statuses into `{ kind, label }` pairs. This keeps the mapping on one side and the rendering generic:\\n\\n| Mapper | Input domain | Notes |\\n|---|---|---|\\n| `verifiedChip` | `VerifiedStatus` | verified/pending/action_required/suspended |\\n| `withdrewChip` | \u2014 | worker erased personal details; renders \\\"Withdrew their information\\\" |\\n| `evidenceChip` | `EvidenceStatus` | \\\"verified\\\" \u2192 \\\"Collected\\\" (deliberately avoids claiming an ABR/Didit \\\"Verified\\\" outcome) |\\n| `readinessChip` | `ReadinessState` | ready/pending/blocked/action_required |\\n| `requestChip` | `RequestStatus` | pending/accepted/completed/expired/declined |\\n| `fundingChip` | `FundingLabel` | documented as rendering exactly the server-decided label; the browser never derives \\\"Paid\\\" from order status or redirects. When `fundingLabel` is `paid_by_sponsor`, the label includes `funderName` if supplied |\\n| `orderChip` | `OrderStatus` | payer's own order; carries no money wording |\\n| `refundChip` | `RefundStatus` | platform console refund requests |\\n| `allocationChip` | `AllocationStatus` | one beneficiary line on a payer's order |\\n| `eligibilityChip` | `EligibilityState` | eligibility wizard progression |\\n\\n### `PartyAvatar` and `PartyIdentity`\\n\\n`PartyAvatar` renders a face photo when one is available and is not a generated monogram URL (`isGeneratedMonogramUrl`), falling back to initials computed by `initialsFromName`. Photos use `loading=\\\"lazy\\\" decoding=\\\"async\\\"` and a `broken` state fallback. The gold verified ring is driven only by the real profile status (`verified` prop).\\n\\n`PartyIdentity` composes `PartyAvatar` with a name (optionally a `Link`), a `secondary` line, and size/shape options. This is the canonical person/company name treatment used throughout directory lists (including deep in `SiteMapBoard` \u2192 `SiteAssignments` call chains).\\n\\n### `MetaIcon` and `tradeIcon`\\n\\nA registry of ~40 greyscale inline SVG glyphs sized 14\u201316px for data labels. Icons are decorative (`aria-hidden`, `focusable=\\\"false\\\"`). The `TRADE_ICONS` map translates trade classification codes from the API (`G01`\u2013`G17`) into glyphs via `tradeIcon(code)`; unmapped codes fall back to the generic `trade` tool.\\n\\n### `StatusDonut` (domain)\\n\\nA composition ring with a counted legend, used in workforce metrics (e.g. `WorkforceMetricRow`). Segments carry a `DonutTone` mapped to RAG indicator classes. Colour never carries meaning on its own \u2014 every segment is also named and counted in the legend. `size=\\\"compact\\\"` shrinks the ring for dense footers.\\n\\n## Cross-cutting patterns\\n\\n1. **Server-rendered session, client-consumed** \u2014 `getServerMe` (React-cached) resolves the user once per navigation in the layout; `SessionProvider` passes it down; `useSession()` exposes `me`, `role`, `switchOrg`, `signOut`. This avoids client splash screens and duplicate `/auth/me` traffic.\\n\\n2. **Portals for anything that must escape the shell** \u2014 topbar sheets and `Modal` use `createPortal` to `document.body` so the shell's overflow clipping and stacking contexts never cut them off.\\n\\n3. **`inert` for focus management** \u2014 while the drawer or a sheet is open, the main content gets `inert`, so tab order stays within the overlay. The sidebar also uses `inert` when collapsed in drawer mode.\\n\\n4. **Presence + coordinate hooks for anchored sheets** \u2014 `usePresence` (exit animations) and `useSheetCoords` (trigger-anchored positioning) together form the topbar sheet pattern. New sheets should follow the same structure: anchor div + `usePresence` + portal + coords + backdrop + outside-click/Escape handling.\\n\\n5. **Instrumentation via `PerfProfiler`** \u2014 the shell and workspace are wrapped in `PerfProfiler` with ids `AppShell` and `AppWorkspace` so route renders are tracked without feature pages needing to opt in.\\n\\n6. **No client-side role redirects** \u2014 role gating is exclusively a server concern; client redirects on role mismatch caused a documented production incident (redirect storm \u2192 429 \u2192 false signed-out loop).\\n\\n## Integration points\\n\\n- **`frontend/src/lib/session` / `server-session`** \u2014 `SessionProvider`, `useSession`, `getServerMe`.\\n- **`frontend/src/lib/nav`** \u2014 `navForRole`, `homeForRole`; used by `Sidebar` and `AppShell`.\\n- **`frontend/src/lib/use-api` / `api`** \u2014 TopBar notifications and Entra status.\\n- **`frontend/src/lib/use-presence`** \u2014 topbar sheet mount/unmount.\\n- **`frontend/src/lib/use-media-query`** \u2014 drawer breakpoint in `AppShell`.\\n- **`frontend/src/lib/use-app-history`** \u2014 `HistoryNav`.\\n- **`frontend/src/lib/notification-href`** \u2014 `hrefForNotification`, `toneForNotification`.\\n- **`frontend/src/lib/entra`** \u2014 `startEntraSignIn`, `EntraStatus`.\\n- **`frontend/src/components/legal/LegalLinks`** \u2014 rendered in the sidebar and account menu.\\n- **`frontend/src/components/dev/PerfProfiler` / `PerfRecorderHost`** \u2014 shell instrumentation.\\n- **`@assureworx/shared-types`** \u2014 `Me`, `NotificationRow`, all status unions consumed by the mappers.\\n- **Page feature components** \u2014 every page under `(app)/` renders within this shell and uses `PageHeader` for its title; the call graph shows `PageHeader` + `useTopBarTitleSlot` used by `DashboardClient` and `TradesDirectory`, among others.\",\"identity-verification-onboarding-backend-src\":\"# Identity Verification &amp; Onboarding \u2014 backend-src\\n\\n# Identity Verification &amp; Onboarding\\n\\n## Overview\\n\\nThis module verifies that a person is who they say they are, using the Didit identity provider, under explicit versioned consent \u2014 while deliberately never persisting identity documents, face photos, biometric data, document numbers, or any other personal information from the check.\\n\\nIt spans three HTTP surfaces:\\n\\n- **`/api/v1/identity/*`** \u2014 the identity check lifecycle: consent, starting a provider session, receiving results, reconciliation, and the \\\"my ID changed\\\" path.\\n- **`/api/v1/onboarding/*`** \u2014 the onboarding wizard: invitation acceptance, connecting existing profiles, ABN verification, trade types, and submission.\\n- **`/api/v1/verification/*`** \u2014 public ABN lookup for the verify journey.\\n\\nThe module is governed by three architectural decisions, referenced throughout the code:\\n\\n- **ADR-0014** \u2014 the vendor boundary. No Didit type, endpoint, or status string may appear outside `modules/identity/didit.ts`. Everything that crosses the boundary is an explicit allowlist.\\n- **ADR-0015** \u2014 consent is versioned and append-only; check currency is derived on read, never stored.\\n- **ADR-0026** \u2014 only non-identifying operational diagnostics are persisted. A provider payload that carries a document number, MRZ, names, DOB, or photo URLs must lose all of it at the boundary.\\n\\nThere is deliberately **no mock identity provider**. A mock ABN lookup is a lookup nobody relies on; a mock identity pass would be displayed to a principal contractor as a real check. Local development points the backend at the Didit sandbox application instead.\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    A[Person in app] --&gt; B[POST /identity/start]\\n    B --&gt; C{Gates: consent, status, cap}\\n    C --&gt;|blocked| E[4xx error code]\\n    C --&gt;|ok| F[DiditVerifier.start]\\n    F --&gt; G[Provider session + redirect]\\n    G --&gt; H[Webhook delivery]\\n    G --&gt; I[Reconciliation poll]\\n    H --&gt; J[applyResult]\\n    I --&gt; J\\n    J --&gt; K[(t_check terminal: H02 / H03 / H04)]\\n```\\n\\nA check starts only when three gates pass: the person has a live consent grant on the *current* consent wording, no current check already exists, and the monthly provider cap has not been reached. The provider session is the source of truth; the webhook is the primary delivery path for its outcome, and reconciliation is the backstop.\\n\\nTwo outcome-delivery paths feed `applyResult`:\\n\\n1. **Webhook** \u2014 Didit POSTs a signed decision to `/identity/webhook`. This is the source of truth. The route is mounted before `express.json()` because HMAC verification needs the raw bytes.\\n2. **Reconciliation** \u2014 a scheduled task (and the `POST /identity/reconcile` route) polls the provider for sessions that are still pending. This is what makes local development work (Didit refuses to deliver webhooks to private addresses) and what rescues a person who finished the provider flow but never came back to the app.\\n\\nBoth paths are idempotent at the database layer: a retried webhook that carries the same conclusion is a no-op, and a settled check can never be downgraded back to pending.\\n\\n## Consent management\\n\\nFiles: `modules/identity/consent.ts`, re-exports from `shared/services/identity-consent.ts`\\n\\nConsent is the legal basis for every check. Its invariants:\\n\\n- **Versioned wording.** `CONSENT_VERSION = \\\"2026-08-04.1\\\"` and `CONSENT_TEXT` are the current wording. The version is how the system can later prove what a given person agreed to. Whenever the wording changes, the version bumps \u2014 and a live grant on an older version is **not** enough to start a new check.\\n- **Append-only.** Granting is an insert (`action_cd = granted`); withdrawal is a *new* row (`action_cd = withdrawn`), never an update. The table grant in migration 011 omits `DELETE`, so this is enforced by the database, not by convention.\\n- **Expiring.** Grants last `config.identityConsentMonths` (24 months per the current wording). `expires_at` is set at insert: `NOW() + interval`.\\n- **Destructive withdrawal.** Withdrawing consent deletes the check rows too (`destroyIdentityChecks`), per APP 11.2 \u2014 the outcome is destroyed, not just the permission. The consent trail survives, because it is the evidence the withdrawal was handled properly.\\n\\n### The \\\"can start a check\\\" rule\\n\\n```ts\\nconsentAllowsNewCheck(consent, now?)\\n```\\n\\nReturns true only when the consent is live *and* `versionTxt === CONSENT_VERSION`. A person who agreed to older wording must agree again; the new row is what the system can prove they saw.\\n\\n### Routes\\n\\n| Route | Behavior |\\n|---|---|\\n| `GET /identity/consent` | Returns `{ text, version }` \u2014 the exact wording that will be recorded. |\\n| `POST /identity/consent` | Requires `body.accepted === true`. Serialises per-party with an advisory lock (`consent:{partyId}`) so a double-click cannot append two grants. Re-records only when there is no live grant or the wording is stale; otherwise no-op. |\\n| `POST /identity/consent/withdraw` | Withdraws (new Y02 row), destroys all C02 checks for the party, audits `consent.withdrawn` + `identity.checks_destroyed`, and recomputes the derived status. |\\n\\nBoth mutations return the full `identitySummary` so the UI can re-render in one round trip.\\n\\n## Provider boundary (Didit adapter)\\n\\nFiles: `modules/identity/didit.ts`, `modules/identity/types.ts`\\n\\n`types.ts` defines the vendor-agnostic contract. Nothing outside `didit.ts` may import a provider type, name a provider endpoint, or know a provider exists.\\n\\n### The interface\\n\\n```ts\\ninterface IdentityVerifier {\\n  readonly providerCd: string;\\n  start(input: { partyId: string; consentId: string; callbackUrl: string }): Promise;\\n  fetchResult(providerRef: string): Promise;\\n  parseWebhook(input: { rawBody: Buffer; signature?: string; timestamp?: string }):\\n    { partyId: string | null; providerRef: string; result: VerificationResult | null };\\n}\\n```\\n\\nNote that `start` takes a **consent id**, not just a party id \u2014 \\\"no consent, no provider call\\\" is a type-level constraint.\\n\\n`identityVerifier()` returns the configured `DiditVerifier` as a cached singleton, or `null` when credentials are absent. The webhook secret is *not* required for this to return non-null: on localhost there is no webhook destination (Didit blocks private addresses), so requiring it would make end-to-end local runs impossible. The webhook route itself still refuses every delivery until a secret exists.\\n\\n### What crosses the boundary\\n\\n`VerificationResult` is the entire allowlist:\\n\\n- `state` \u2014 `verified` | `unverified` | `check_failed`\\n- `reason` \u2014 a short code, never provider prose\\n- `providerRef` \u2014 the provider session reference\\n- `documentExpiresOn` \u2014 the only identifying document field allowed across (ADR-0015 \u00a71)\\n- `diagnostics` \u2014 non-identifying telemetry (ADR-0026)\\n- `sessionExpiresAt` \u2014 when the provider session stops being resumable\\n\\n`VerificationDiagnostics` is built field-by-field in `extractDiagnostics`; the adapter never spreads a provider object into it, because a provider adding a schema field must not silently start persisting personal information. The diagnostics contain per-step outcomes (`document`, `liveness`, `face_match` in the order the person experiences them), capture quality scores, match/liveness confidence, warning **codes** (capped at 10, prose rejected by a `SAFE_CODE` regex), environment (`live`/`sandbox`), and document kind + issuing country \u2014 never the numbers on the document.\\n\\n### Status mapping\\n\\nDidit's ten session statuses are mapped to the three check states. Unknown statuses fail **closed** \u2014 a status the system does not understand is a check it cannot vouch for, never a pass:\\n\\n| Provider status | Result state | Reason |\\n|---|---|---|\\n| `Approved` | `verified` | \u2014 |\\n| `Declined` | `unverified` | `declined` |\\n| `In Review` | `check_failed` | `in_review` |\\n| `Abandoned` / `Expired` | `check_failed` | `not_completed` |\\n| `Kyc Expired` | `check_failed` | `provider_expired` |\\n| anything else | `check_failed` | `unknown_status` |\\n| `Not Started`, `In Progress`, `Resubmitted`, `Awaiting User` | *no result* (`null`) | still running |\\n\\n### Webhook signature verification\\n\\n`parseWebhook` verifies:\\n\\n1. **A secret is configured** \u2014 else `DiditSignatureError(\\\"No webhook secret configured.\\\")`.\\n2. **Headers present** \u2014 `x-signature-v2` and `x-timestamp`.\\n3. **Timestamp freshness** \u2014 within `config.didit.webhookToleranceSeconds`; rejects replay.\\n4. **Body is valid JSON.**\\n5. **HMAC-SHA256** over Didit's canonical form: sorted keys, compact separators, whole-valued floats truncated (`sortKeys(shortenFloats(parsed))`). Each candidate secret is compared with `timingSafeEqual`; no early exit reveals which secret matched.\\n\\nSecrets are plural \u2014 Didit issues one secret *per destination*, not per application. Rotation and multi-destination setups legitimately see deliveries signed by different secrets, and the adapter accepts any registered one.\\n\\nThe route layer additionally validates that `vendor_data` (the party UUID) looks like a UUID before acting, and acknowledges *every* authentic delivery with 200 \u2014 including in-flight statuses and unrecognised party references \u2014 because a non-2xx triggers Didit's retry policy.\\n\\n### Error handling\\n\\n`DiditVerificationError` carries a `retryable` flag and is thrown for provider connectivity/API problems. `call()` deliberately swallows the underlying fetch error, because runtime error messages can carry the URL and request body. Provider error bodies are read for a short non-PII `detail` (e.g. \\\"Invalid workflow_id.\\\") so misconfigured environments are diagnosable in dev without logging payloads.\\n\\n## Check lifecycle\\n\\nFiles: `modules/identity/service.ts`, `modules/identity/index.ts`\\n\\n### Check states and rows\\n\\nA check is a row in `t_check` with `kind_cd = 'C02'`, `provider_cd = 'didit'`. Status codes:\\n\\n| Code | Meaning |\\n|---|---|\\n| `H01` | Pending / in progress. May carry a `pending:{uuid}` placeholder ref before the provider call, then the real provider session id. |\\n| `H02` | Verified (passed). |\\n| `H03` | Unverified (the person failed the check). |\\n| `H04` | Check failed \u2014 lands in manual review. Includes `unknown_status`, `in_review`, `not_completed`, and `session_expired`. |\\n\\nThe `result` jsonb column holds `{ resumeUrl }` while the check is in flight, and is reset to `{}` on settlement. Diagnostics live in a separate `diag` column precisely so they survive that reset.\\n\\n### Starting a check: `POST /identity/start`\\n\\nThe subject is always the **person behind the login** (`subjectPartyId`), never the company they administer \u2014 identity is a human property.\\n\\nThe gate transaction (tenant-scoped, with the party row locked `FOR UPDATE`):\\n\\n1. **Consent** \u2014 `currentConsent` + `consentAllowsNewCheck`. Fail: `400 CONSENT_REQUIRED`.\\n2. **In-flight check** \u2014 if the derived status is `in_progress` and the pending row carries a `resumeUrl`, return it (`RESUME`); the person re-enters the existing session instead of being billed for a second one. If no resume URL, `409 IN_PROGRESS`.\\n3. **Currency** \u2014 `shouldStartNewCheck(status)`, i.e. the status is not `current`, `expiring_soon`, or `in_progress`. Fail: `409 ALREADY_CURRENT`.\\n4. **Monthly cap** \u2014 `reserveCall(tx, \\\"didit\\\", config.didit.monthlyCap)`, reserved *before* any outbound call so a provider that bills cannot outrun the counter. Fail: `503 CAPACITY_REACHED`.\\n5. **Placeholder row** \u2014 `reservePendingStart` inserts an H01 row with `provider_ref_txt = 'pending:{uuid}'` and the actor's sign-in email. A concurrent start now sees `in_progress` and cannot reserve again.\\n\\nOnly then does the outbound `verifier.start()` happen. On provider failure the slot and placeholder are rolled back (`abandonPendingStart` + `releaseCall`) so the person is not stuck at `in_progress`; the caller gets `502 PROVIDER_UNAVAILABLE` (the provider's message only when `config.allowDevAuth`).\\n\\nOn success, `recordStarted` attaches the real provider session id and resume URL to the placeholder row \u2014 or inserts a fresh row if the placeholder vanished (abandoned by a concurrent failure path) \u2014 and audits `identity.check_started`.\\n\\n### Applying a result: `applyResult`\\n\\nShared by the webhook, `POST /reconcile`, and the scheduled reconciliation. It is the single write path for outcomes.\\n\\nIdempotency rules, enforced under a row lock (`FOR UPDATE` on the C02 row matching `provider_ref_txt`):\\n\\n- Same status as the existing row \u2192 no-op, returns `false`.\\n- Existing row is already terminal (`!= 'H01'`) \u2192 no-op. A provider re-sending an older status after a retry must not make a settled check flap.\\n- Otherwise: update the H01 row to its terminal status, clear `result` to `{}`, store `diag`, set `checked_at`; or insert a fresh terminal row if the webhook arrived without a matching start (session created out of band).\\n\\nThen: audit `identity.check_completed`, recompute the portable status (`evaluateAndPersistStatus`), and stage the outcome SMS (`stageIdentityOutcomeSms`). Returned SMS ids are collected in `smsOut` and pumped by the caller after the transaction commits.\\n\\n### Stale sweeps\\n\\n`sweepStalePendingChecks` settles H01 rows whose `session_expires_at` has passed, marking them `H04 / session_expired`. This exists because a person who closes the tab mid-check would otherwise be stuck at `in_progress` forever: the webhook has no decision to send, reconciliation has nothing to reconcile once the session is gone, and `/start` refuses to open a second check. The reason code is deliberately distinct from a check that ran and failed \u2014 \\\"your session timed out, start again\\\" is a different sentence from \\\"your check could not be completed\\\".\\n\\nThe sweep runs inside `runIdentityReconcile` *before* polling, because a dead session is never worth polling, and clearing it frees the person to start again.\\n\\n### Declaring a document change\\n\\n`POST /identity/declare-change` is the \\\"my ID document has changed\\\" path (ADR-0015 \u00a72). The system cannot detect a replaced licence, so this one-click mechanism makes the holder's obligation real. `markSuperseded` sets `reason_cd = 'superseded'` on the latest H02 row. The outcome stays true \u2014 it did pass on that date \u2014 but the derived status becomes `superseded`, which is no longer relied upon and *does* allow a new check.\\n\\n### Destruction on withdrawal\\n\\n`destroyIdentityChecks` deletes all C02 rows for the party, records the audit event, and recomputes the derived status. `skipStatusRecompute` is available for account-erasure flows that already set the terminal erasure status.\\n\\n## Reconciliation &amp; scheduling\\n\\nFiles: `modules/identity/reconcile.ts`, `modules/identity/scheduler.ts`\\n\\n`runIdentityReconcile()`:\\n\\n1. Sweeps stale pending checks first (pure SQL, no provider needed).\\n2. If no verifier is configured, pumps any SMS staged by the sweep and returns \u2014 the sweep still happens even when the provider key was removed.\\n3. Polls up to `BATCH = 25` pending rows: `kind_cd = 'C02'`, `status_cd = 'H01'`, real (non-`pending:`) provider ref, session not yet expired, ordered by `updated_at`.\\n4. For each row, calls `verifier.fetchResult`; a polling failure for one session is logged and skipped (the next tick retries; the sweep catches it if the session dies meanwhile).\\n5. Applies results via `applyResult` with `actorId = null`, `scopePartyId = null` \u2014 the system, not a user, is the caller. Settled count increments only when `applyResult` reports a change.\\n6. Pumps staged SMS ids once at the end.\\n\\nThe scheduler (`startIdentityReconcileTimer`) runs the task in every replica \u2014 it relies on conditional SQL updates rather than a leader election, and skips rather than queues if a pass overruns (a `running` flag). The interval is jittered (0.85\u20131.15\u00d7) to avoid a fleet of replicas stampeding in sync, the timer is `unref()`'d so it cannot hold the process open, and an initial tick fires ~45s after boot.\\n\\nThe timer runs **even when the provider is unconfigured**, because the stale-session sweep is pure SQL and rows left pending by a previously configured environment should not stay pending forever.\\n\\n## HTTP API\\n\\n### Identity routes (`modules/identity/index.ts`)\\n\\nAll routes except the webhook require auth + tenant context.\\n\\n| Route | Auth | Purpose |\\n|---|---|---|\\n| `GET /identity/health` | none | Liveness only; deliberately does not advertise whether Didit is wired. |\\n| `GET /identity/me` | user | The person's identity summary. |\\n| `GET /identity/consent` | user | Current consent wording + version. |\\n| `POST /identity/consent` | user | Grant consent (requires `accepted: true`). |\\n| `POST /identity/consent/withdraw` | user | Withdraw consent and destroy check outcomes. |\\n| `POST /identity/start` | user | Start a check (gates described above). |\\n| `POST /identity/reconcile` | user | Reconcile this person's pending check against the provider. Idempotent: with nothing pending it returns the current summary. |\\n| `POST /identity/declare-change` | user | Mark the latest pass superseded (holder-declared document change). |\\n| `GET /identity/meter` | `platform_admin` | Monthly spend visibility: counts only, no session data. |\\n| `POST /identity/webhook` | HMAC | Provider delivery. See below. |\\n\\n### Webhook route specifics\\n\\n- Mounted before `express.json()` (raw body needed for HMAC) and before the CSRF guard (the signature *is* the authentication).\\n- `express.raw({ type: \\\"*/*\\\", limit: \\\"1mb\\\" })`.\\n- No verifier configured \u2192 `503 PROVIDER_UNCONFIGURED`.\\n- Signature/timestamp failure \u2192 `401 BAD_SIGNATURE`. The log line is the reason only \u2014 never the body, never the headers.\\n- Authentic but no actionable result (in-flight status, missing party ref, non-UUID `vendor_data`) \u2192 `200 { success: true }` to stop Didit retrying.\\n- Actionable \u2192 `applyResult` under `withBypass` (no actor, no tenant \u2014 the audit event inside `applyResult` is the accountability record), then `pumpSmsMany`.\\n\\nThe callback URL handed to the provider is built from an allowlist of named journeys (`RETURN_PATHS`), never from the request body \u2014 the provider redirects a browser to whatever the backend hands it, so accepting a path would be an open redirect with a third party doing the redirecting.\\n\\n## Status derivation &amp; currency\\n\\nFile: `shared/services/identity-check.ts`\\n\\nThe check's outcome (`status_cd`) is an immutable historical fact. **Currency is derived on read**, because it changes with the calendar rather than with anything that happened.\\n\\n`deriveIdentityStatus(check, alertDays, { maxAgeMonths, consentExpiresAt, consentGranted })` produces one of:\\n\\n| Status | When |\\n|---|---|\\n| `not_checked` | No C02 row has ever run. |\\n| `in_progress` | Latest row is H01. |\\n| `current` | H02 and within the expiry horizon. |\\n| `expiring_soon` | H02 and inside `identityAlertDays` of expiry. |\\n| `expired` | H02 but past the expiry (document date or, when the document carries none, the `maxAgeMonths` ceiling). |\\n| `superseded` | H02 with `reason_cd = 'superseded'` (holder declared replacement). |\\n| `consent_lapsed` | The authorising consent has expired. The outcome is still true, but may no longer be relied upon. |\\n| `did_not_pass` | H03. |\\n| `could_not_complete` | H04. |\\n\\n`identityStatusFor` combines the latest check with consent *currency* (whether the consent has lapsed), and is the read used everywhere a single person's identity matters.\\n\\nWho counts as the identity subject:\\n\\n- A person (`kind_cd = 'K03'`) is their own identity subject.\\n- An organisation aggregates over its R04 admins via the `core.fn_identity_admin_party_ids` SECURITY DEFINER function \u2014 a network principal contractor cannot read another org's grant rows but must still know which admins carry C02. This is the shared concern that keeps the summary functions in `shared/` rather than inside the identity feature module.\\n\\n`identityStatusForCompliance` implements the aggregation with the rank order in `IDENTITY_STATUS_RANK`, preferring the most actionable incomplete state. During the onboarding wizard, `preferredPersonPartyId` pins the subject to the admin completing the wizard rather than a sibling admin.\\n\\n`identityIsCurrent` returns true only for `current` and `expiring_soon` \u2014 an expired or superseded document fails compliance even though the underlying check is still a true fact.\\n\\n## Onboarding integration\\n\\nFile: `modules/onboarding/index.ts`\\n\\n### Invitation surface (public, token-keyed)\\n\\n`GET /invite/:token` renders the onboarding context: the sender, the root principal, the verification requirements selected for this invitation, and \u2014 critically \u2014 whether the invited identity **already exists** on the platform. Detection runs the full resolution ladder (`resolveInviteIdentity`), not just the `target_party_id` stamp the sender left behind (ADR-0012).\\n\\nTwo client paths:\\n\\n- **`POST /invite/:token/accept`** \u2014 for someone with no profile. Creates the person party, optionally a company party (`direct_company` invitations), the relationship edges (L02 network / L03 direct / L04 subcontract), the `D03` email identifier, an actor with a pending Entra subject, the role grant (R04 company admin / R05 individual), a starter subscription (`PL1`), and marks the request U02 (accepted) with the token cleared. If the identity already exists, it fails with `409 PROFILE_EXISTS` and points at the connect path \u2014 re-onboarding a portable profile is the exact bug this prevents.\\n- **`POST /invite/:token/connect`** \u2014 for someone with an existing profile. Holding the emailed token is the consent signal; pressing connect is the audited act. No new party, no new login, no new subscription. It creates the network edge (with the company-vs-person resolution in `resolveConnectSubject`), builds the invitation's checklist, computes the `nextPath` from what is still missing, and marks the request U03 (completed) when the checklist is already ready.\\n\\nBoth routes lock the request row `FOR UPDATE` so concurrent accepts/connects serialize cleanly, and both expire stale invitations on read.\\n\\n### The authenticated wizard\\n\\n- **`POST /abn`** \u2014 ABN verification. Invalid ABNs record an H03 `C01` check *without* attaching the number to the profile. Valid ABNs are claimed with an advisory lock (`ident:D01:{abn}`), checked against a **global** clash read (RLS bypassed, because the unique index is global while RLS is scoped), and recorded as a `D01` identifier plus an H02/H03 `C01` check. A live, active ABN adopts the registered entity name while the profile still has a placeholder name.\\n- **`GET/PUT /trade-types`** \u2014 read the global tag catalogue; replace the party's selected classifications (max 17, `G\\\\d{2}` codes).\\n- **`GET /checklist`** \u2014 builds the compliance checklist from the active invitation's requirement keys, with identity scoped to the logged-in person.\\n- **`POST /submit`** \u2014 requires `readyToSubmit`; completes all open U02 requests for the party, notifies the root principals, and audits `onboarding.submitted`.\\n\\n### Removed identity route\\n\\n`POST /onboarding/identity` returns **410 `MOVED_TO_IDENTITY_MODULE`**. Identity checks moved to `/api/v1/identity/*`; the 410 exists so old clients get a clear migration signal rather than a confusing 404. There is deliberately no mock.\\n\\n### Verification module\\n\\n`POST /verification/abn-lookup` is the public, rate-limited ABN lookup used by the verify journey pre-login. It wraps `abnVerifier.verify` with the GUID stripped, returning the same shape as the onboarding ABN flow.\\n\\n## Data model notes\\n\\n| Table | Rows relevant to this module |\\n|---|---|\\n| `t_consent` | One row per grant/withdrawal. `purpose_cd`, `action_cd` (`granted`/`withdrawn`), `consent_txt`, `version_txt`, `expires_at`. Append-only; no `DELETE` grant. |\\n| `t_check` | `kind_cd = 'C01'` for ABN checks, `'C02'` for identity. Status H01\u2013H04, `reason_cd`, `doc_expires_on`, `diag` (ADR-0026 allowlist), `result` (resume URL while in flight, then `{}`), `session_expires_at`, `contact_email_txt`. |\\n| `t_party_ident` | `D01` = ABN (globally unique, hence the bypassed clash read), `D03` = email. |\\n| `t_rel` | `L01` employment, `L02` org\u2192org network, `L03` person\u2192principal, `L04` subcontract. |\\n| `t_grant` | `R04` company admin, `R05` individual. |\\n| `t_ledger` | Starter plan `PL1` created on invite accept. |\\n\\n## Key invariants\\n\\nThese are the properties contributors must preserve:\\n\\n1. **The vendor boundary is absolute.** No Didit type, endpoint, or status string outside `didit.ts`; no provider field crosses into `t_check` except through the `VerificationResult` allowlist.\\n2. **Fail closed.** An unrecognised provider status is `check_failed/unknown_status`, never a pass. An unauthenticated webhook is rejected, not ignored. No webhook secret configured \u2192 every delivery refused.\\n3. **Idempotent settlement.** `applyResult` is the only write path for outcomes; retries and out-of-order deliveries cannot flap a terminal row.\\n4. **Consent gates everything.** No live grant on the current wording \u2192 no check. Withdrawal destroys outcomes, not just permission.\\n5. **No billing without a reservation.** The monthly meter is reserved before the outbound provider call and released if the call never succeeds.\\n6. **Versions are proof.** Bump `CONSENT_VERSION` whenever a word changes \u2014 the version is how the system proves later what a person agreed to.\\n7. **No mock identity.** Local development uses the Didit sandbox; the reconciliation path covers the missing webhook delivery.\",\"identity-verification-onboarding-frontend-src\":\"# Identity Verification &amp; Onboarding \u2014 frontend-src\\n\\n# Identity Verification &amp; Onboarding \u2014 Frontend\\n\\n## Overview\\n\\nThis module covers the two front-end surfaces that manage a trade user's compliance status:\\n\\n1. **The work profile** (`(app)/profile`) \u2014 the ongoing, authenticated experience where a trade user monitors and maintains their verification status, uploads evidence, verifies their ABN, manages a pass photo, and controls work availability.\\n2. **The onboarding wizard** (`onboarding`) \u2014 the new-user flow that collects the ABN, required evidence documents, and identity check before the profile exists.\\n\\nBoth surfaces converge on the same business outcome: the **`workReady`** decision shown on the profile \u2014 *\\\"Assureworx Verified\\\"* \u2014 which is driven by server party status, core checks, evidence, payment state, and (for person roles) a pass photo.\\n\\nThe module is entirely client-rendered React (Next.js App Router), with thin server pages that authenticate and pre-fetch initial data.\\n\\n## Architecture\\n\\n```\\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510      \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\\n\u2502  Onboarding wizard          \u2502      \u2502  Work profile               \u2502\\n\u2502                             \u2502      \u2502                             \u2502\\n\u2502  AbnClient                  \u2502      \u2502  ProfileClient              \u2502\\n\u2502  Onboarding DocumentsClient \u2502      \u2502  DocumentsClient            \u2502\\n\u2502  IdentityClient             \u2502      \u2502                             \u2502\\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518      \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n              \u2502                                    \u2502\\n              \u25bc                                    \u25bc\\n      \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510                    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\\n      \u2502  WizardShell  \u2502                    \u2502    useApi     \u2502\\n      \u2502  (stepper)    \u2502                    \u2502 (cache seed + \u2502\\n      \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518                    \u2502   fetch)      \u2502\\n                                           \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n              \u2502                                    \u2502\\n              \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n                              \u25bc\\n                    \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\\n                    \u2502   Assureworx API  \u2502\\n                    \u2502  /parties/me/*    \u2502\\n                    \u2502  /onboarding/*    \u2502\\n                    \u2502  /artifacts       \u2502\\n                    \u2502  /payments/me/*   \u2502\\n                    \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n```\\n\\nThe profile hands ID-check initiation to the separate `/verify` journey (link \\\"Start ID check\\\" \u2192 `/verify`), which is its own module but shares the evidence taxonomy and `identityApi` helpers.\\n\\n### Entry points\\n\\n| Route | Server page | Client component | Purpose |\\n|---|---|---|---|\\n| `/profile` | `profile/page.tsx` | `ProfileClient` | Work status, check board, work history, details |\\n| `/profile/documents` | `profile/documents/page.tsx` | `DocumentsClient` | Evidence document table + upload |\\n| `/onboarding/abn` | `onboarding/abn/page.tsx` | `AbnClient` | Confirm ABN against the ABR |\\n| `/onboarding/documents` | `onboarding/documents/page.tsx` | `DocumentsClient` | Upload required evidence |\\n| `/onboarding/identity` | `onboarding/identity/page.tsx` | `IdentityClient` | Consent + Didit identity check |\\n\\nAll of these except the profile routes render inside `WizardShell`, the shared onboarding stepper. `requirePortal(\\\"trade\\\")` guards the profile routes.\\n\\n---\\n\\n## The Work Profile\\n\\n### Server pages and data seeding\\n\\n`profile/page.tsx` is an async server component that:\\n\\n- Calls `requirePortal(\\\"trade\\\")` to enforce role access.\\n- Fetches `/parties/me/profile` via `loadAppPage`.\\n- Fetches `/parties/me/engagements` via `loadAppPageOptional` \u2014 the optional variant returns `null` on soft failure so the page still renders and `ProfileClient` reloads engagements client-side.\\n\\n`documents/page.tsx` does the same for `/artifacts/mine` via `loadAppPage`.\\n\\nThe `loadAppPage` helpers do more than fetch: they prime the client-side API cache (`seedApiCache` \u2192 `writeCache`) so the corresponding `useApi` call with `initialData` does not issue a duplicate request.\\n\\n### ProfileClient \u2014 the status engine\\n\\n`ProfileClient` is the largest component in the module. It receives the server-seeded `initialProfile` and `initialJobs`, then layers three `useApi` hooks:\\n\\n- `/parties/me/profile` (profile + checklist)\\n- `/parties/me/engagements` (active/completed jobs)\\n- `/payments/me/funding` \u2014 only when `me.personPartyId` exists; `null` disables the hook for company-only accounts\\n\\n#### Deriving the displayed status\\n\\nThe component computes a single `displayStatus` from several independent gates:\\n\\n| Gate | Logic |\\n|---|---|\\n| `checksDone` | No core check is `pending`/`failed`, and at least one required check exists \u2014 computed from `requiredChecks` vs `passedChecks` |\\n| `anyCheckFailed` | Any core check in state `failed` |\\n| `paymentSettled` | No person party, **or** funding label is `paid_by_you`, `paid_by_your_organisation`, or `paid_by_sponsor` |\\n| `awaitingPayment` | Funding is `payment_required`, `payment_failed`, `payment_processing`, `billing_review_required`, or `awaiting_employer_payment` |\\n| `hasPassPhoto` | `me.photoUrl` exists and is **not** a generated monogram URL \u2014 i.e. a real face photo |\\n| `showPassPhoto` | Session role is `trade` and active role is `trade_company_admin` or `trade_individual` |\\n\\nThe final decision:\\n\\n```ts\\nworkReady =\\n  data.party.status === \\\"verified\\\" &amp;&amp;\\n  checksDone &amp;&amp;\\n  paymentSettled &amp;&amp;\\n  !awaitingPayment &amp;&amp;\\n  (!showPassPhoto || hasPassPhoto);\\n```\\n\\n`displayStatus` maps to one of four states:\\n\\n- `verified` \u2014 `workReady` is true\\n- `suspended` \u2014 any check failed\\n- `action_required` \u2014 awaiting payment, or server status is `verified`/`action_required` but gates aren't met\\n- `pending` \u2014 otherwise\\n\\n`statusCopy` then selects the headline/body pair, with three special cases that override the generic state copy:\\n\\n1. `awaitingEmployerPayment &amp;&amp; checksDone` \u2192 *\\\"Waiting for your employer to pay\\\"*\\n2. `awaitingPayment &amp;&amp; checksDone` \u2192 *\\\"Pay to get Verified\\\"*\\n3. `showPassPhoto &amp;&amp; !hasPassPhoto &amp;&amp; checksDone &amp;&amp; paymentSettled &amp;&amp; verified` \u2192 *\\\"Add your pass photo\\\"*\\n\\nThese overrides exist because an otherwise-ready user can be blocked purely by payment or a missing gate-facing photo \u2014 the generic \\\"finish your checks\\\" copy would be misleading.\\n\\n#### Status hero\\n\\nThe hero panel (`statusHero`) renders:\\n\\n- The status headline/body and a CTA that jumps to the first incomplete check (`firstNeeds`) or to `/profile/documents`.\\n- A profile avatar. For pass-holding roles the avatar is a button that opens `PassPhotoPanel`'s camera capture (`passPhotoRef.current?.openCapture()`); for others it is a file-input label that calls `onPhotoChange`.\\n- Metrics: `passedChecks/requiredChecks`, active jobs, connected builder count, and `statusSince`.\\n- A mobile-only \\\"Open pass\\\" action that fires the `assureworx:show-pass` window event \u2014 `ProfileClient` listens for this event and opens `PassOverlay`.\\n\\nThe `verified`/`suspended` status also shows a star-shaped seal (`statusEmblem`), hidden while checks are still in progress.\\n\\n#### Photo upload\\n\\n`onPhotoChange(file)`:\\n\\n1. Validates the MIME type is `image/*`.\\n2. Compresses the file on-device via `squareJpegFromFile` (the same path used by `FaceCapture`) to keep camera-roll shots under the 500 KB API cap.\\n3. Selects the endpoint:\\n   - `/parties/me/photo` for pass-holding roles and non-company parties (person face),\\n   - `/parties/me/org-photo` for company logos.\\n4. PUTs `{ dataUrl }`, then calls `Promise.all([refresh(), refreshSession()])` so both profile data and session identity update.\\n\\n#### ABN verification\\n\\nThe ABN drawer is an inline form (`abnDrawer`) rather than an `EvidenceDrawer`:\\n\\n- `verifyAbn` strips non-digits, requires exactly 11, and POSTs to `/onboarding/abn`.\\n- A valid + active result shows a success line and closes the drawer.\\n- A valid but **inactive** ABN shows a warning \u2014 the number is registered on the profile, but compliance requires the ABR status to be Active.\\n- The drawer is available whenever the ABN is needed **or** already on the profile (`needs.abn || data.party.abn`), so users can re-check.\\n\\n#### Check drawers\\n\\n`matchesCheck(key, classCode)` maps a `CheckKey` to evidence class codes:\\n\\n| Check key | Class codes |\\n|---|---|\\n| `abn` | `A003` |\\n| `identity` | `A002` |\\n| `insurance` | `A004`, `A005`, `A110` |\\n| `qualifications` | `A112`, `A113`, `A114`, `A116`, `A001` |\\n| `licence` | `/^A1(0[1-9]|1[0-9])$/` minus `A110`, `A112\u2013A116` |\\n\\n`EvidenceDrawer` renders the rows for one check key. It uses `pickUploadTarget` to rank rows by status \u2014 `missing`/`expired`/`rejected` first, then `expiring_soon`, then `pending_verification` \u2014 and offers a single \\\"Upload\\\" button for the worst row.\\n\\nDrawer composition:\\n\\n- **identity** \u2014 no inline upload; renders a primary `Start ID check&lt;\\/Button&gt;` that hands off to the verify journey. The drawer copy notes that Didit checks the ID and Assureworx keeps only the result/expiry, not the images.\\n- **licence / insurance / qualifications** \u2014 inline `EvidenceUploadForm` per row; `onUploaded` refreshes the profile.\\n- **abn** \u2014 the custom form described above.\\n\\nDrawer content is memoized (`drawers` useMemo) and depends on `needs.*` flags plus ABN form state.\\n\\n#### Sections\\n\\nThe profile is split into three tab sections; inactive sections are unmounted:\\n\\n- **profile-status** \u2014 funding callout, status hero, company workforce strip, and the `WorkChecksBoard` (the expanded check list with drawers).\\n- **profile-work** \u2014 `EngagementList` for active and completed jobs from `/parties/me/engagements`.\\n- **profile-details** \u2014 personal/trade facts, editable home location, company availability toggle + region chips, connected builders (`data.networks`), and the inline `IdentityPanel`.\\n\\nOn mobile (\u2264640px) the ribbon is replaced by a fixed bottom nav. Each section carries `data-mobile-panel` / `data-mobile-active` attributes, and the active section becomes the scroll container. The CSS enforces `flex: 0 0 auto` on status-stack children \u2014 a comment documents that shrinking previously clipped the status hero.\\n\\nThe Details section includes:\\n\\n- `saveLocation` \u2014 validates suburb-or-postcode and a 4-digit postcode, PUTs `/parties/me/location`.\\n- `saveAvailability` (company only) \u2014 PUTs `/parties/me/availability` with `openToNewBusiness` + `regions`; toggling on with no regions defaults to `[\\\"NSW\\\"]`.\\n\\n### Work profile \u2014 Documents page\\n\\n`DocumentsClient` (in `profile/documents`) is the evidence-management surface:\\n\\n- Loads `/artifacts/mine` via `useApi`.\\n- Desktop: `EvidenceTable` with an expandable inline `EvidenceUploadForm` per row (`renderDetail`).\\n- Mobile: `MobileDocumentList` card layout with per-row upload/cancel and a progress summary line (`X of Y current | N need action | M in review`).\\n- View link for uploaded artifacts: `/api/v1/artifacts/{artifactId}/content` (opened in a new tab).\\n- `expiryCopy` / `verificationCopy` derive human-readable strings from `expiresOn`, `method`, `verifiedAt`, and status. Method labels come from `EVIDENCE_METHOD_LABELS`.\\n\\nNote the CSS comment: the \\\"operational-row pilot\\\" now owns Documents at every viewport \u2014 the second `@media (max-width: 640px)` block forces the desktop table to display and hides the mobile cards, which are kept mounted for rollback.\\n\\n---\\n\\n## The Onboarding Wizard\\n\\nThe wizard is a sequence of steps rendered inside `WizardShell`, which provides the step indicator, title, footer CTA, and `confirmLeave` guard. The same shell is used by the invite-token flow (`OnboardingInviteClient`), so the visual/UX contract is shared across signup paths.\\n\\n### Step 1 \u2014 ABN (`AbnClient`)\\n\\n- Draft persistence: `useOnboardingDraftOwner` resolves the draft owner key, and `usePersistentDraft` stores the value in `localStorage` under `awx.draft.onboarding.abn.{owner}`. The draft is cleared when the input is empty (`isEmptyAbn`) or after a successful verification (`clearDraft(false)`).\\n- `verify` POSTs the 11-digit ABN to `/onboarding/abn`; results render as a success or warning card.\\n- The footer \\\"Continue to identity\\\" button is disabled until `result?.valid` \u2014 a valid-but-inactive ABN does **not** unlock the next step.\\n- Errors are mapped through `describeOnboardingError`, which produces user-friendly copy for network, validation, and server failures.\\n\\n### Step 2 \u2014 Documents (onboarding)\\n\\n`onboarding/documents/DocumentsClient.tsx` is the most involved wizard step:\\n\\n- Loads the requirement checklist from `/onboarding/checklist` via `useApi`.\\n- Only `required` rows count toward progress; the footer shows \\\"N documents to go\\\" and disables \\\"Continue to review\\\" until all are uploaded.\\n\\n**File selection** (`chooseFile`):\\n\\n- Accepts `.pdf`, `.jpg`, `.jpeg`, `.png`, max 10 MB (`MAX_FILE_BYTES`), non-empty.\\n- Immediately runs `useDocumentExtraction().read(file, classCode)`, which returns an `ExtractionPreview` with:\\n  - `prefill` \u2014 `issuedOn`, `expiresOn`, `authority`, `reference`\\n  - `findings` \u2014 e.g. an `EXPIRED` code\\n  - `read` \u2014 extracted text used by `DocumentReadPreview`\\n- `readNeedsManualEntry` forces manual fields when there is no preview, the status is `skipped`, extraction failed, or nothing was read.\\n- `expiredBlocked` blocks submission when the document scan found `EXPIRED` and a prefilled expiry exists \u2014 the user must choose a current document.\\n\\n**Upload** (`submit`):\\n\\n- Builds `FormData` with the file, `classCode`, and optional `issuedOn` / `expiresOn` / `authority` / `reference`.\\n- Calls `api.upload(\\\"/artifacts\\\", body, { signal, onProgress })`, which reports progress from 0\u2013100.\\n- `cancelUpload` aborts via an `AbortController`; the catch branch distinguishes `UPLOAD_CANCELLED` (`ApiClientError.code`) to show \\\"ready to retry\\\" copy.\\n- `useBeforeUnload(Boolean(file))` warns before navigating away while a file is selected.\\n\\n### Step 3 \u2014 Identity (`IdentityClient`)\\n\\nThe identity step is a thin wrapper over `identityApi` from `@/lib/identity`:\\n\\n- `load` fetches `identityApi.me()` and `identityApi.consentText()` in parallel; the consent text fetch is allowed to fail silently (falls back to empty).\\n- If the returned status is `in_progress`, a one-shot `identityApi.reconcile()` runs (guarded by the `returned` ref) to settle the check result.\\n- `done` is true when status is `current` or `expiring_soon`.\\n- `blockedCopy` explains blocked states: `consent_required`, `already_current`, `capacity` (provider paused), `unavailable`.\\n- The consent action posts the user's acceptance; errors flow through `messageFor` \u2192 `describeOnboardingError`.\\n- Uses `identityDetail` / `IDENTITY_LABEL` from `@/lib/identity` to render a human-readable summary of the check result.\\n\\n---\\n\\n## Shared Infrastructure\\n\\n| Helper | Role |\\n|---|---|\\n| `useApi` | Client data hook. Accepts `initialData` (skips the first fetch), a `null` path (disables), and exposes `{ data, loading, error, refresh }`. Seeds the response cache and records network timing via `trackNetworkInstant` into `lib/perf/store.ts`. |\\n| `api` | Typed HTTP client (`api.get/put/post/upload`). Throws `ApiClientError` with a `code` and user-safe `message`. `api.upload` supports `AbortSignal` + `onProgress`. |\\n| `loadAppPage` / `loadAppPageOptional` | Server-side fetch helpers that prime the client cache. Optional variant returns `null` on soft failure. |\\n| `WizardShell` | Onboarding layout: step indicator, footer CTA, `confirmLeave` guard. |\\n| `EvidenceUploadForm` | Shared compact upload form, used by profile drawers, profile documents, and onboarding documents. |\\n| `usePersistentDraft` + `useOnboardingDraftOwner` | localStorage-backed wizard drafts with saved-at/restored state. |\\n| `useDocumentExtraction` | OCR/prefill pipeline for evidence files. |\\n| `describeOnboardingError` | Maps unknown errors to step-specific copy. |\\n| `evidenceChip` / `verifiedChip` / `StatusChip` / `evidenceFileAction` / `EVIDENCE_COPY` | Shared status chips, action labels, and copy. |\\n\\n## Connections to the Rest of the Codebase\\n\\n- **Verify journey (`/verify`)** \u2014 Identity verification is a separate module (`verify/scan`, `verify/checks`, `verify/photo`, `verify/pass`, `verify/pay`, `verify/details`). `ProfileClient`'s identity drawer links to it, and `IdentityHandoff` bridges back via `nextInvitationPath` / `continueAfterId`. The verify demo session helpers (`loadSession`, `createSession`, `updateSession`, `clearSession`, `isBoundVerifyMode`) are shared with the invite flow.\\n- **Invite flow** \u2014 `OnboardingInviteClient` uses the same `WizardShell` and can route into credential setup (`isRequireCredentialSetup`, `startCredentialSetup`).\\n- **Domain components** \u2014 `WorkChecksBoard`, `EvidenceTable`, `EvidenceUploadForm`, `IdentityPanel`, `EngagementList`, `FundingCallout`, `WorkforceMetricRow`, `TradeTypeList`, `TradeTypeIcon`, `PassPhotoPanel`, `PassOverlay` are all shared components consumed by this module.\\n- **Public profiles** \u2014 `CompanyProfileClient`/`IndividualProfileClient` render `ProfileSheet`/`ProfileHeader`; they consume the same verified-status data model this module produces.\\n- **Evidence taxonomy** \u2014 `matchesCheck` (this module) parallels `matchesEvidenceGroup` / `isLicenceClass` in `src/lib/evidence-groups.ts`, used by the verify journey. Changes to class-code mappings must stay in sync across both.\\n- **Performance instrumentation** \u2014 `ProfileClient` is wrapped in `PerfProfiler`, and each section in `PerfSection`; `useApi` contributes network timings to the same perf store.\\n- **Session** \u2014 `useSession` provides `me`, `role`, and `refresh`; photo uploads and pass-photo changes call `refreshSession()` so the session identity (not just profile data) stays current.\",\"identity-verification-onboarding\":\"# Identity Verification &amp; Onboarding\\n\\n# Identity Verification &amp; Onboarding\\n\\nThis module verifies that trade users are who they say they are and shepherds them through onboarding to a verified compliance state. Identity checks are performed through the Didit provider under explicit versioned consent, and the system deliberately never persists identity documents, face photos, biometric data, document numbers, or any other personal information from the check. The module spans backend API surfaces plus frontend onboarding and profile experiences that converge on a single **`workReady`** decision \u2014 the *\\\"Assureworx Verified\\\"* status.\\n\\n## Sub-modules\\n\\n- [Identity Verification &amp; Onboarding \u2014 backend-src](identity-verification-onboarding-backend-src.md) \u2014 Didit integration, identity check lifecycle, onboarding wizard APIs, and public ABN verification.\\n- [Identity Verification &amp; Onboarding \u2014 frontend-src](identity-verification-onboarding-frontend-src.md) \u2014 Work profile and onboarding wizard UI, shared services for identity status, verification templates, and demo/verification flows.\\n\\n## How the sub-modules fit together\\n\\nThe backend exposes three HTTP surfaces:\\n\\n- **`/api/v1/identity/*`** \u2014 consent, Didit session start, webhook receipt, reconciliation, and the \u201cmy ID changed\u201d path.\\n- **`/api/v1/onboarding/*`** \u2014 invitation acceptance, profile connection, ABN verification, trade types, and submission.\\n- **`/api/v1/verification/*`** \u2014 public ABN lookup for verification.\\n\\nThe frontend provides two surfaces that consume those APIs:\\n\\n- **Work profile** (`(app)/profile`) \u2014 the ongoing authenticated view where users monitor verification status, upload evidence, verify their ABN, manage a pass photo, and control availability.\\n- **Onboarding wizard** (`onboarding`) \u2014 the new-user flow collecting ABN, evidence documents, and identity check before the profile exists.\\n\\nBoth surfaces use shared frontend services \u2014 `identity-status`, `identity-check`, `verification-template`, and `verified-status` \u2014 to compute the `workReady` state. The backend independently tracks identity checks, stores only consent and status metadata, and reconciles Didit webhooks via a scheduler (`modules/identity/scheduler.ts`).\\n\\n```mermaid\\ngraph TB\\n  subgraph Backend[Backend Source]\\n    API[Identity/Onboarding/Verification APIs]\\n    Didit[Didit Provider + Webhooks]\\n    Scheduler[Reconcile Scheduler]\\n  end\\n\\n  subgraph Frontend[Frontend Source]\\n    Profile[Work Profile]\\n    Wizard[Onboarding Wizard]\\n    Shared[Shared Services]\\n  end\\n\\n  Wizard --&gt; API\\n  Profile --&gt; API\\n  Shared --&gt; API\\n  API --&gt; Didit\\n  Scheduler --&gt; API\\n  Profile --&gt; Shared\\n  Wizard --&gt; Shared\\n```\\n\\n## Key workflows\\n\\n### Identity check lifecycle\\n\\nThe frontend starts a check through the identity API; the backend creates a Didit session and records consent. Didit posts a webhook (parsed with signature verification in `modules/identity/didit.ts`), the backend reconciles the result through `service.ts`, and a scheduled timer (`scheduler.ts`) reconciles in-flight checks. The frontend reflects the updated status using `identityStatusForCompliance` and `deriveIdentityStatus`.\\n\\n### Onboarding wizard\\n\\nA user accepts an invitation via `OnboardingInviteClient`, connects to an existing profile, then progresses through ABN verification, trade-type selection, evidence documents, identity check, and review. The backend validates each step through `/api/v1/onboarding/*`, with `modules/onboarding/index.ts` orchestrating invitation loading, requirement keys, and subject resolution.\\n\\n### Ongoing profile verification\\n\\nThe work profile surfaces the consolidated `workReady` decision computed by `computeStatus` from identity currency, party status, and document catalogue requirements. Users can upload evidence, change their pass photo, and update work availability directly from the profile.\\n\\n### Verify demo and staff flows\\n\\nA parallel verification journey (`verify/*`) supports demo and staff capture flows via `verify-demo.ts`, with session management, field validation, and next-step navigation. These flows reuse the same identity and requirement services to keep behavior consistent with the production onboarding path.\",\"identity-verification-wallet-passes-backend-src\":\"# Identity Verification, Wallet &amp; Passes \u2014 backend-src\\n\\n# Identity Verification, Wallet &amp; Passes\\n\\n## Overview\\n\\nThis module covers three related capabilities in the backend:\\n\\n1. **Identity verification** \u2014 a consent-managed, privacy-preserving integration with Didit (an identity proofing provider) that verifies a person's photo ID against a live face photo.\\n2. **ABR lookups** \u2014 a small public-facing adapter for ABN verification used by onboarding and the `/verify` journey.\\n3. **Wallet passes** \u2014 issuing and serving Apple Wallet (`.pkpass`) and Google Wallet passes, plus a live gate-resolution endpoint that site scanners use to check whether a pass is currently valid.\\n\\nThe identity and wallet halves share little code, but they are designed around the same principles: a strict **vendor boundary**, an **allowlist of persisted data**, and **idempotent, auditable state transitions**.\\n\\n```\\nbackend/src/modules/\\n\u251c\u2500\u2500 cases/          # Tier-1 private incidents (health only \u2014 separate module)\\n\u251c\u2500\u2500 checks/         # Compliance checks (health only \u2014 separate module)\\n\u251c\u2500\u2500 identity/       # Didit verification, consent, reconciliation\\n\u251c\u2500\u2500 verification/   # ABR lookup\\n\u2514\u2500\u2500 wallet/         # Apple/Google passes, gate resolution\\n```\\n\\n---\\n\\n## Identity Verification\\n\\n### Architecture and data flow\\n\\n```mermaid\\nflowchart LR\\n    U[Person in browser] --&gt;|POST /identity/start| API[identity Router]\\n    API --&gt; GATE{Consent live?No check in flight?Meter capacity?}\\n    GATE --&gt;|fail| ERR[4xx / 503]\\n    GATE --&gt;|pass| RES[reservePendingStartH01 placeholder]\\n    RES --&gt; DIDIT[DiditVerifier.start]\\n    DIDIT --&gt;|session_id + url| REC[recordStartedattach provider ref]\\n    REC --&gt;|redirectUrl| U\\n\\n    DIDIT --&gt;|webhook delivery| WH[identityWebhookRouter]\\n    WH --&gt;|HMAC verify| PARSE[parseWebhook]\\n    PARSE --&gt;|VerificationResult| APPLY[applyResult]\\n    APPLY --&gt; CHECK[(core.t_check)]\\n    APPLY --&gt; AUDIT[(audit log)]\\n    APPLY --&gt; SMS[SMS queue]\\n\\n    TIMER[Reconcile timer] --&gt;|batch pending H01| FETCH[DidtVerifier.fetchResult]\\n    FETCH --&gt; APPLY\\n    SWEEP[sweepStalePendingChecks] --&gt;|expired sessions| CHECK\\n```\\n\\nA check proceeds through these stages:\\n\\n1. **Consent** \u2014 the person must agree to the current consent wording (`CONSENT_VERSION`). Consent is append-only in `core.t_consent`.\\n2. **Gating** \u2014 `POST /identity/start` checks consent, a current in-flight check, and the monthly provider cap.\\n3. **Provider session** \u2014 a placeholder `H01` row is reserved first, then Didit is called. The returned session URL is stored on the row so an interrupted check can be resumed.\\n4. **Settlement** \u2014 either Didit delivers a webhook, or the reconcile timer polls for the decision. Both paths converge on `applyResult`.\\n5. **Stale sweep** \u2014 sessions whose provider expiry passed without an outcome are settled to `H04` with reason `session_expired`.\\n\\n### Status model\\n\\nA check lives in `core.t_check` with `kind_cd = 'C02'` and one of these `status_cd` values:\\n\\n| Code | Meaning | `VerificationResult.state` |\\n|------|---------|---------------------------|\\n| `H01` | In progress (pending) | \u2014 |\\n| `H02` | Verified | `verified` |\\n| `H03` | Unverified (provider said no) | `unverified` |\\n| `H04` | Check failed / manual review / session expired | `check_failed` |\\n\\nThe derived, user-facing status (e.g. `verified`, `pending`, `action_required`, `superseded`, `suspended`) is computed by `deriveIdentityStatus` in `shared/services/identity-check.ts`, based on the latest check, consent expiry, and document expiry. The wallet module reads the same derivation via `evaluateAndPersistStatus` in `shared/services/verified-status.ts`.\\n\\n### Vendor boundary (ADR-0014)\\n\\n`identity/didit.ts` is the *only* file that may reference Didit. The boundary is enforced two ways:\\n\\n- **Types** \u2014 `IdentityVerifier`, `VerificationResult`, `VerificationDiagnostics`, and `StartedVerification` (defined in `identity/types.ts`) are the only vocabulary the rest of the module uses. No Didit type, endpoint, or status string appears outside `didit.ts`.\\n- **Persistence** \u2014 nothing in `didit.ts` logs, returns, or stores a response body. The `VerificationResult` is an allowlist: state, reason code, checked-at timestamp, provider reference, document expiry date, session expiry, and the non-identifying diagnostics of ADR-0026.\\n\\nThe parsing in `didit.ts` is deliberately field-by-field rather than typed against Didit's schema. A Didit decision payload also contains document numbers, MRZ lines, names, dates of birth, and presigned image URLs \u2014 none of it may be read, so none of it can leak into a later refactor.\\n\\n#### What crosses the boundary\\n\\n| Field | Why it is allowed |\\n|-------|-------------------|\\n| `state` / `reason` | The outcome, mapped from Didit statuses |\\n| `providerRef` | Session identifier, not personal information |\\n| `documentExpiresOn` | The only identifying document field allowed (ADR-0015 \u00a71, ADR-0026) |\\n| `diagnostics` | Operational telemetry only (see below) |\\n| `sessionExpiresAt` | Drives the stale-pending sweep |\\n\\n#### Diagnostics allowlist (ADR-0026)\\n\\n`VerificationDiagnostics` captures *how* a check went, not *who* the person is:\\n\\n- **`steps`** \u2014 per-step outcome for `document`, `liveness`, `face_match` (the three steps a person experiences, in order).\\n- **`capture`** \u2014 image quality scores (focus, brightness, resolution, overall) used for coaching copy like \\\"too dark\\\". Scores about an image, not its contents.\\n- **`scores`** \u2014 liveness and face-match confidence, 0\u2013100. Similarity scores, never biometric templates.\\n- **`warnings`** \u2014 short provider warning codes, capped at `MAX_WARNINGS = 10` and validated against a `SAFE_CODE` regex that rejects prose.\\n- **`environment`** \u2014 `\\\"live\\\"` or `\\\"sandbox\\\"`, so an auditor can distinguish test data.\\n- **`document`** \u2014 document type and issuing country only; assurance quality without identifying numbers.\\n\\nThe adapter builds this object field by field; it never spreads a provider object into it.\\n\\n### Consent lifecycle\\n\\nConsent is owned by `identity/consent.ts` and persisted in `core.t_consent` as an append-only ledger. Withdrawal is a new row with `action_cd = withdrawn`, never an `UPDATE` \u2014 the migration omits `DELETE`, so the database enforces this.\\n\\n**Versioned wording.** `CONSENT_VERSION` (currently `\\\"2026-08-04.1\\\"`) is bumped whenever the wording changes. `consentAllowsNewCheck` requires both a live grant *and* a version match \u2014 an old grant is not enough to start a new check; the person must agree to what is shown today. The version is how the system can later prove what a given person actually agreed to.\\n\\n**Key functions:**\\n\\n- `grantConsent(tx, { partyId, actorId, source })` \u2014 inserts a `granted` row with a 24-month expiry (from `config.identityConsentMonths`).\\n- `withdrawConsent(tx, { partyId, actorId, source })` \u2014 inserts a `withdrawn` row with `expires_at = NULL`.\\n- `consentAllowsNewCheck(consent, now)` \u2014 live and current-version check.\\n- `currentConsent(tx, partyId)` and `consentIsLive` are re-exported from `shared/services/identity-consent.ts` so callers have a single import site \u2014 the shared module needs them for currency derivation.\\n\\n**Routes:**\\n\\n- `GET /identity/consent` \u2014 returns the exact wording and version that will be recorded.\\n- `POST /identity/consent` \u2014 body `{ accepted: true }`. Serialised with an advisory lock (`consent:${partyId}`) so a double-click cannot append two grants. Re-records only when there is no live grant or the version is stale.\\n- `POST /identity/consent/withdraw` \u2014 as easy as granting (OAIC APP Guidelines B.54). Withdrawal **destroys the check outcomes** through `destroyIdentityChecks` (APP 11.2) \u2014 the consent trail survives, but the check rows, including document expiry dates, are deleted.\\n\\nBoth grant and withdraw write audit events (`consent.granted`, `consent.withdrawn`, `identity.checks_destroyed`).\\n\\n### Starting a check\\n\\n`POST /identity/start` (rate-limited by `identityStartLimiter`) is the entry point. The flow is:\\n\\n1. Resolve the subject via `subjectPartyId` \u2014 identity is always checked against the **person** behind the login (`ctx.personPartyId`), never the company.\\n2. **Gate transaction** (under a party row lock `SELECT ... FOR UPDATE`):\\n   - Consent must pass `consentAllowsNewCheck`.\\n   - If the latest check is `in_progress` and carries a `resumeUrl`, return `RESUME` with that URL \u2014 the person re-enters the existing session instead of paying for a second one.\\n   - If the derived status does not allow a new check (`shouldStartNewCheck`), return `ALREADY_CURRENT`.\\n   - Reserve meter capacity via `reserveCall(tx, \\\"didit\\\", config.didit.monthlyCap)` \u2014 **before** any outbound call, so the provider cannot outrun the counter.\\n   - Insert a placeholder `H01` row via `reservePendingStart` with a `pending:`-prefixed provider ref.\\n3. **Outbound call** \u2014 `identityVerifier().start(...)`. The callback URL is constructed from a hard-coded allowlist (`RETURN_PATHS`), never from the request body \u2014 otherwise the provider would be the vehicle for an open redirect.\\n4. **Attach** \u2014 `recordStarted` updates the placeholder with the real `providerRef`, `resumeUrl`, and `sessionExpiresAt`. If the provider call failed, `abandonPendingStart` deletes the placeholder and `releaseCall` returns the meter slot.\\n\\nError mapping: `CAPACITY_REACHED` \u2192 503, `IN_PROGRESS` \u2192 409, `ALREADY_CURRENT` \u2192 409, `CONSENT_REQUIRED` \u2192 400, provider failure \u2192 502 `PROVIDER_UNAVAILABLE`. In dev (`config.allowDevAuth`) the provider detail is included in the 502 so misconfiguration is diagnosable; in production it is not.\\n\\n### Webhook\\n\\nThe webhook router (`identityWebhookRouter`) is mounted **before** `express.json()` and before the CSRF guard in `app.ts`. Both are deliberate: HMAC verification needs the untouched raw bytes, and the signature *is* the authentication \u2014 this is not a cookie-authenticated mutation.\\n\\n`POST /identity/webhook` (mounted path varies by environment; the router itself is `identityWebhookRouter`):\\n\\n1. Rate-limited by `identityWebhookLimiter`, body parsed as `express.raw({ type: \\\"*/*\\\", limit: \\\"1mb\\\" })`.\\n2. `DiditVerifier.parseWebhook` verifies:\\n   - A webhook secret is configured (otherwise every delivery is rejected \u2014 an unauthenticated caller could mark anyone verified).\\n   - `x-signature-v2` and `x-timestamp` headers exist.\\n   - The timestamp is within `config.didit.webhookToleranceSeconds`.\\n   - The signature matches an HMAC-SHA256 over **canonical JSON** \u2014 sorted keys, compact separators, truncated whole-valued floats (`sortKeys` + `shortenFloats`). Every configured secret is compared with `timingSafeEqual`, and the loop does not early-exit, so neither the loop nor the comparison reveals which secret matched.\\n3. On signature failure, responds `401 { code: \\\"BAD_SIGNATURE\\\" }` with a log line containing only the reason \u2014 never the body or headers.\\n4. Everything authentic is acknowledged with `200`, including statuses with no decision yet (`parsed.result === null`). A non-2xx triggers Didit's retry policy and the delivery is dropped after the second attempt.\\n5. If the payload carries a decision, `applyResult` runs inside `withBypass` (no tenant scope \u2014 the caller is the provider), then queued SMS IDs are pumped via `pumpSmsMany`.\\n\\n### Reconciliation and scheduler\\n\\nWebhooks are the **source of truth**, but they are not sufficient on their own. Didit refuses to deliver webhooks to private addresses (an SSRF guard), which means local development has no webhook path at all \u2014 the return trip from the provider is the only way an outcome arrives. The reconcile timer makes that return trip an optimisation rather than a requirement.\\n\\n**`identity/reconcile.ts` \u2014 `runIdentityReconcile()`:**\\n\\n1. **Sweep first** \u2014 `sweepStalePendingChecks` settles `H01` rows whose `session_expires_at` has passed to `H04` with reason `session_expired`. This is what frees a person who closed the tab mid-check to start again. (Distinct reason code from a check that ran and failed: \\\"your session timed out\\\" is a different sentence from \\\"your check could not be completed\\\".)\\n2. **Poll a bounded batch** (`BATCH = 25`) of pending checks whose session has not yet expired. `fetchResult` **reads** an existing session \u2014 it never creates one, so it cannot bill and needs no meter reservation.\\n3. Each successful outcome goes through `applyResult`. One unreachable session does not stop the pass; the next tick retries it.\\n\\nThe loop is deliberately shaped like the payments scheduler: run in every replica, skip when a pass overruns (`running` flag), jittered interval. Unlike payments, it runs even when the provider is unconfigured, because the stale-sweep is pure SQL and still needs to happen for rows left behind by a previously configured environment.\\n\\n**`identity/scheduler.ts`:**\\n\\n- `startIdentityReconcileTimer()` \u2014 starts a jittered `setInterval`, fires an initial tick after ~45s, and `unref()`s the timer so it never keeps the process alive.\\n- `stopIdentityReconcileTimer()` \u2014 clears the interval for graceful shutdown.\\n- Interval comes from `config.identityReconcileIntervalMinutes`; `0` disables.\\n\\n### Applying a result: `applyResult`\\n\\n`identity/service.ts::applyResult` is the single chokepoint where any outcome \u2014 webhook, reconcile, or script \u2014 becomes a persisted state change. Its contract:\\n\\n- **Idempotent on `(provider_ref, status)`** \u2014 a retried webhook with the same conclusion is a no-op (returns `false`).\\n- **No downgrades** \u2014 an existing terminal row (`H02`/`H03`/`H04`) is never flipped back to pending, even if the provider re-sends an older status after a retry.\\n- **Row-locked** \u2014 selects the matching `C02` check with `FOR UPDATE` so webhook and reconcile cannot settle the same check concurrently.\\n- **Out-of-band fallback** \u2014 if no matching start row exists (session created out of band), inserts a new check row.\\n- **Clears the resume URL** \u2014 `result` is reset to `'{}'::jsonb` on settlement; the resume URL was only useful while the check was in flight. Diagnostics survive in their own `diag` column.\\n\\nAfter the state change, `applyResult` chains three side effects:\\n\\n1. `audit(...)` with verb `identity.check_completed`.\\n2. `evaluateAndPersistStatus(...)` \u2014 recomputes the party's derived `VerifiedStatus`.\\n3. `stageIdentityOutcomeSms(...)` \u2014 queues a result SMS (pumped by the caller via `pumpSmsMany`).\\n\\n### Other service functions\\n\\n| Function | Purpose |\\n|----------|---------|\\n| `identitySummary(db, partyId, opts)` | Builds the `IdentitySummary` for `/me` and returned after consent/withdraw/declare-change. Reads check, consent, and meter **sequentially** \u2014 they share one pg client, which cannot run queries in parallel. Computes `blockedReason` in priority order: `unavailable` \u2192 `already_current` \u2192 `consent_required` \u2192 `capacity`. |\\n| `reservePendingStart` | Inserts the `H01` placeholder with `provider_ref_txt = 'pending:'` before the outbound call. |\\n| `recordStarted` | Attaches the real provider session to the placeholder (or inserts if the placeholder was abandoned). Persists `contactEmail` (sign-in email at start) so a settled check can be joined to a profile. |\\n| `abandonPendingStart` | Deletes a placeholder when Didit never accepted the start. Guarded by `provider_ref_txt LIKE 'pending:%'` so it cannot delete a real session. |\\n| `sweepStalePendingChecks` | Batch-settles expired sessions (see above). Bounded 1\u20131000 rows. |\\n| `markSuperseded` | \\\"My ID document has changed\\\" (ADR-0015 \u00a72). Marks the latest `H02` check with `reason_cd = 'superseded'`; the outcome stays true but is no longer relied upon. The derived status becomes `superseded`. |\\n| `destroyIdentityChecks` | Deletes all `C02` rows for a party (consent withdrawal or account erasure). Returns row count; optionally skips status recompute. |\\n\\nNote the `isoDate` helper in `service.ts`: a `DATE` column comes back from pg as a `Date` at **local midnight**, and calling `.toISOString()` would shift it back a day for timezones east of Greenwich. It reads local components instead \u2014 a licence expiring on the 2nd must not expire on the 1st for Australian users.\\n\\n### Routes summary (identity)\\n\\n| Route | Auth | Purpose |\\n|-------|------|---------|\\n| `GET /identity/health` | none | Liveness only; deliberately does not advertise Didit wiring (recon). |\\n| `GET /identity/me` | required | Current identity summary. |\\n| `GET /identity/consent` | required | Exact consent wording + version. |\\n| `POST /identity/consent` | required | Grant consent (body `{ accepted: true }`). |\\n| `POST /identity/consent/withdraw` | required | Withdraw consent + destroy checks. |\\n| `POST /identity/start` | required | Start a check; returns `redirectUrl`. |\\n| `POST /identity/reconcile` | required | Reconciliation for a webhook that has not arrived. Idempotent: with nothing pending it returns the current position rather than erroring, so the UI can call it unconditionally on return from the provider. |\\n| `POST /identity/declare-change` | required | Declare the checked document replaced. |\\n| `GET /identity/meter` | `platform_admin` only | Spend visibility: `{ provider, remaining, ... }`. Counts only, no session data. |\\n| `POST /identity/webhook/` | signature | Provider delivery (raw body, HMAC). |\\n\\n---\\n\\n## ABR Verification\\n\\n`verification/index.ts` exposes a small public router:\\n\\n- `GET /verification/health` \u2014 reports `abrProvider: abnVerifier.providerCd`.\\n- `POST /verification/abn-lookup` \u2014 rate-limited by `abrLookupLimiter`, no auth. Accepts `{ abn: string }` (11\u201314 chars), normalises via `normaliseAbn`, verifies via the shared `abnVerifier` (which has a mock fallback when `ABR_GUID` is unset \u2014 safe there because a mock ABN lookup is a lookup nobody relies on, unlike a mock identity pass). Returns the `AbnVerifyResult` plus `provider`.\\n\\n---\\n\\n## Wallet Passes\\n\\nThe wallet module issues passes for verified trade parties (companies and sole traders) to Apple Wallet and Google Wallet, and serves a live gate-resolution endpoint.\\n\\n### Configuration\\n\\n`wallet/config.ts::loadWalletConfig()` reads everything from environment variables and returns a `WalletConfig` with `apple` and `google` sections. The `configured` flags are derived:\\n\\n- **Apple**: `WALLET_APPLE_PASS_TYPE_ID` + `WALLET_APPLE_TEAM_ID` + WWDR cert + signer cert + signer key. Certs/keys may be file paths (`*_PATH`) or inline PEM (`*_PEM`, with `\\\\n` handling).\\n- **Google**: `WALLET_GOOGLE_ISSUER_ID` + service-account `client_email` + `private_key`. The key may come from a JSON service-account file path (which also supplies `client_email`), an inline PEM, or `GOOGLE_APPLICATION_CREDENTIALS`-style JSON via `envOrPrivateKey`.\\n\\nGoogle's logo/hero images must be public HTTPS URLs \u2014 `WALLET_ASSET_BASE_URL` or `PUBLIC_APP_URL` + `/wallet/*.png` is used unless overridden, because Google's servers fetch them and `localhost` will not work.\\n\\n### Wallet route surface\\n\\n`wallet/index.ts` defines `walletRouter`, with the Apple PassKit web service mounted at `/apple`.\\n\\n| Route | Auth | Purpose |\\n|-------|------|---------|\\n| `GET /wallet/status` | required | `{ appleConfigured, googleConfigured }` |\\n| `GET /wallet/gate/resolve` | public (rate-limited) | Live gate lookup from QR payload. |\\n| `GET /wallet/pass` | required | The credential as the app renders it. Returned even when invalid so the UI can grey it out. |\\n| `POST /wallet/passes` | required | Issue. Returns `{ appleDownloadPath, googleSaveUrl }`. |\\n| `GET /wallet/passes/:token/pkpass` | public (rate-limited) | Download the built pass; token is a short-lived bearer capability. |\\n| `/wallet/apple/*` | Apple auth | PassKit web service (below). |\\n\\n### Pass subject\\n\\n`wallet/subject.ts` defines `PassSubject` \u2014 everything the credential shows, always read from the party tables rather than the browser:\\n\\n- `passId` \u2014 deterministic from the party UUID (`passIdFor`).\\n- `subjectName` / `subjectKind` \u2014 the verified party (individual or trade company).\\n- `holderName` / `holderPartyId` \u2014 the signed-in person whose face is on the badge (person for company admins).\\n- `abn` \u2014 from `t_party_ident` with `kind_cd = 'D01'`.\\n- `issuedAt` \u2014 from `t_party.status_at` (the verified-since timestamp, *not* \\\"now\\\" \u2014 a badge dates from the decision).\\n- `expiresAt` \u2014 earliest hard evidence expiry, capped at one year from `issuedAt` (`PASS_VALIDITY_YEARS`).\\n- `validity` \u2014 `\\\"current\\\"` only when portable status is `verified` *and* an ABN exists. Site readiness is never asserted here (ADR-0005 / ADR-0018) \u2014 the gate resolves that live per site.\\n- `paid` \u2014 whether verification has been paid for (ADR-0025).\\n- `statusLabel` \u2014 `Verified`, or for invalid passes: `Suspended`, `Payment required`, `Action required`, `Not verified`, `Invalid`. `Payment required` outranks generic wording because it is the only blocker the holder can clear in one step.\\n\\n`readPassSubject(ctx)` requires `trade_company_admin` or `trade_individual` roles and reads with tenant context. `readPassSubjectByPartyId(partyId, personId?)` is the bypass path used by the PassKit web service and gate helpers \u2014 the `personId` from the Apple auth token keeps the holder's face on company-admin refreshes.\\n\\nWallet issue (`POST /wallet/passes`) calls `assertPassIssuable`, which additionally requires Verified status \u2014 the app may *show* a greyed pass, but it may not *issue* one.\\n\\n### Deterministic pass IDs and auth tokens\\n\\n`wallet/pass-id.ts` is the security core:\\n\\n- `passIdFor(partyId)` \u2014 HMAC-SHA256 over `wallet.pass.v1:` with `passIdSecret()`, mapped into a Crockford-style alphabet (no `I, L, O, U, 0, 1` so it survives being read aloud on site). Format `AWX-XXXX-XXXX`. Deterministic, so re-issuing replaces the same pass instead of stacking duplicates, and a printed ID keeps resolving.\\n- `appleAuthTokenFor(partyId, passId, personId?)` \u2014 deterministic PassKit `authenticationToken` (\u226516 chars). Embeds the party and holder UUIDs so the web service can authorise updates without a reverse lookup: `v1..` (sole trader) or `v1...` (company admin).\\n- `parseAppleAuthToken(raw, passId)` \u2014 parses and verifies the token with `timingSafeEqualStr`. Returns `{ partyId, personId }` or `null`.\\n- `passIdSecret()` \u2014 prefers `WALLET_PASS_ID_SECRET`, falls back to `SESSION_SECRET` so local dev keeps working, but production should set the dedicated secret so session rotation does not rewrite every QR/serial.\\n\\n### Apple PassKit web service\\n\\n`wallet/apple-webservice.ts` implements the subset of the PassKit web service that matters without APNs:\\n\\n| Endpoint | Purpose |\\n|----------|---------|\\n| `POST /v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier/:serialNumber` | Register a device for a pass. Responds `201` (new) or `200` (already registered \u2014 Apple accepts either). |\\n| `DELETE .../registrations/...` | Unregister. |\\n| `GET /v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier` | List serials for a device. `204` when none; otherwise `{ lastUpdated, serialNumbers }`. |\\n| `GET /v1/passes/:passTypeIdentifier/:serialNumber` | Re-fetch the pass. Rebuilds a fresh `pkpass` with the current validity/status \u2014 this is how a voided pass shows `Invalid` when opened. |\\n| `POST /v1/log` | Acknowledges Apple's log messages without echoing PII to our logs. |\\n\\nAll device-facing endpoints authenticate with the `ApplePass` authorization header via `appleAuth`, which validates the token against the serial. The `appleAuth` helper also enforces that the requested `passTypeIdentifier` matches the configured `passTypeId`.\\n\\nPasses are rebuilt on every fetch (`buildApplePkpass`), not cached, so the `Last-Modified` header is always current and a fetched pass always reflects the latest status.\\n\\n### Building the Apple pass\\n\\n`wallet/apple.ts::buildApplePkpass(body, cfg, opts)` uses `passkit-generator`:\\n\\n- Asserts Apple is configured, else `503 WALLET_APPLE_NOT_CONFIGURED` with a pointer to the runbook.\\n- Builds the QR barcode message: `awx.verify.v1|||AU|`.\\n- Includes a `thumbnail` from the account's selfie photo (`subject.photoUrl`), processed with `sharp` (rotated, resized, PNG). If the selfie is missing or unreadable, the pass is built without a thumbnail rather than failing.\\n- Sets `voided: !valid`; when invalid, the expiry date is set to *now*.\\n- Adds `webServiceURL` + `authenticationToken` only when `apple.webServiceUrl` is configured (empty disables it \u2014 localhost builds work without it).\\n- Field layout: header `STATUS`, primary `NAME`, secondary `TRADE | REGION` and `LICENCE`, auxiliary `ABN` (formatted `XX XXX XXX XXX`) and `EXPIRES`, back fields including a `Gate` explanation that makes clear a pass alone does not mean ready everywhere.\\n\\n### Google Wallet\\n\\n`wallet/google.ts::buildGoogleSaveUrl(body, cfg)` returns an \\\"Add to Wallet\\\" JWT URL:\\n\\n- Asserts Google is configured, else `503 WALLET_GOOGLE_NOT_CONFIGURED`.\\n- Object ID is `.`; class ID is `.`.\\n- The JWT carries `genericObjects` (not `genericClasses` \u2014 class template belongs on the class, and heavy save payloads commonly fail with \\\"Something went wrong\\\"). Claims: `iss` = service account email, `aud: \\\"google\\\"`, `typ: \\\"savetowallet\\\"`, `origins` from config, RS256, 10-minute expiry.\\n- Images (logo/hero/wideLogo) are attached only when public HTTPS URIs are configured.\\n\\n### Pass download store\\n\\n`wallet/pass-store.ts` is a short-lived, single-use download store for `.pkpass` buffers. Design:\\n\\n- **TTL**: 90 seconds (`TTL_MS`). The URL is a bearer capability \u2014 short expiry limits replay.\\n- **MAX_DOWNLOADS = 2**: Safari often GETs a `.pkpass` twice (preview + add). The token is burned after two successful reads so a leaked URL cannot be replayed indefinitely.\\n- **Reservation**: `reservePassToken` is called *before* the CPU-heavy pass build; `PUT` happens after. `consumePassBuffer` polls (up to `waitMs`, default 5s) while the token is in the `pending` map, so the client does not need to know about the build latency.\\n- **Storage**: Postgres `core.t_pass_dl` when available (with `hits_left` column detection for migration 027), with an in-process `Map` fallback **only** in `local`/`dev` environments \u2014 a multi-replica production deployment must not rely on process-local state.\\n- **Cleanup**: expired rows are pruned on insert; consumed tokens are deleted transactionally when `hits_left` reaches zero.\\n\\n### Gate resolution\\n\\n`wallet/gate.ts::resolveGatePass(tx, { passId, abn, siteId? })` is the live lookup behind `GET /wallet/gate/resolve`. The QR is a **pointer, not a readiness stamp** (ADR-0018): the scanner always asks Assureworx whether this person is currently Verified and, when a site is known, whether their placement on that site is ready.\\n\\nFlow:\\n\\n1. Normalise the ABN (digits only, must be 11 chars).\\n2. Look up the party by ABN in `t_party_ident` (`kind_cd = 'D01'`), excluding deleted rows.\\n3. Recompute the expected `passIdFor(party.id)` and reject mismatches \u2014 a pass printed from a different environment or stale secret no longer resolves.\\n4. Map `status_cd` through `PARTY_STATUS` to a portable status; `passValid = status === \\\"verified\\\"`.\\n5. If `siteId` was supplied, look up the latest non-deleted `t_link` row for `(party, site)` and map its `state_cd` through `LINK_STATE`.\\n6. Build a short operator-facing `message`:\\n   - Invalid + suspended \u2192 \\\"Pass invalid | profile suspended\u2026\\\"\\n   - Verified but not placed \u2192 \\\"not placed on this site. Check the site board.\\\"\\n   - Site `ready` \u2192 \\\"Verified profile | ready for this site.\\\"\\n   - Site `blocked`/`action_required`/`pending` \u2192 not ready, check missing requirements.\\n   - No site asked \u2192 \\\"scan with a site to confirm placement readiness.\\\"\\n\\nThe response is `Cache-Control: no-store` \u2014 the whole point is that stale caching would defeat live readiness.\\n\\n---\\n\\n## Cross-cutting concerns\\n\\n### Rate limiting\\n\\n| Limiter | Applied to |\\n|---------|-----------|\\n| `identityStartLimiter` | `POST /identity/start` |\\n| `identityReconcileLimiter` | `POST /identity/reconcile` |\\n| `identityWebhookLimiter` | Webhook delivery |\\n| `abrLookupLimiter` | `POST /verification/abn-lookup`, `GET /wallet/gate/resolve` |\\n| `walletLimiter` | `GET /wallet/pass`, `POST /wallet/passes`, pkpass download |\\n| `walletIssueLimiter` | `POST /wallet/passes` (stacked on `walletLimiter`) |\\n| `walletAppleWebServiceLimiter` | All PassKit web-service routes |\\n\\n### Audit\\n\\nAll state-changing operations write structured audit events via `shared/services/audit.ts`:\\n\\n- `consent.granted` / `consent.withdrawn` \u2014 with purpose and consent version.\\n- `identity.check_started` \u2014 provider + provider ref only; \\\"outcome-shaped\\\", no payload, no document fields.\\n- `identity.check_completed` \u2014 provider ref, state, reason, and document expiry date (a date, not an identifier).\\n- `identity.document_superseded` \u2014 `{ declaredBy: \\\"holder\\\" }`.\\n- `identity.checks_destroyed` \u2014 reason and row count.\\n\\nThe `identity.check_completed` audit from the webhook path runs with `actorId = null` and `scopePartyId = null` \u2014 the provider is the caller, and `withBypass` is paired with the audit event inside `applyResult` so the billing/tenant invariants still hold.\\n\\n### SMS notifications\\n\\nOn settlement, `stageIdentityOutcomeSms` queues an SMS via `shared/services/sms/notify.ts`, but only if the party has a reachable phone. Callers collect SMS IDs in an `smsOut` array and pump them through `pumpSmsMany` *after* the transaction commits \u2014 never inside it, so a slow SMS provider can never hold a check transaction open.\\n\\n---\\n\\n## Scripts and tests that touch this module\\n\\n- `backend/scripts/verify-identity.ts` \u2014 an end-to-end verification script exercising `grantConsent`, `recordStarted`, `applyResult`, `markSuperseded`, `withdrawConsent`, and `DiditVerifier` directly.\\n- `backend/scripts/probe-google-wallet.mts` and `ensure-google-wallet-class.mts` \u2014 validate Google Wallet config and class.\\n- `modules/identity/didit-boundary.test.ts` \u2014 asserts the vendor boundary: no Didit type or status string may appear outside `didit.ts`.\\n- `modules/identity/consent-version.test.ts` \u2014 asserts `consentAllowsNewCheck` requires the current version.\\n- `shared/services/identity-currency.test.ts` \u2014 exercises `deriveIdentityStatus` / `identityIsCurrent` / `needsAction`.\\n- `modules/retention/erase.ts` and `retention/sweep.ts` call `destroyIdentityChecks` for personal erasure.\\n\\n---\\n\\n## Operational notes\\n\\n- **Identity checks are never mocked.** There is intentionally no mock fallback the way ABN lookup has one: a mock ABN result is a lookup nobody relies on, but a mock identity pass would be displayed to a principal contractor as a real check.\\n- **Local development** has no webhook delivery \u2014 Didit blocks private addresses as an SSRF guard. The reconcile endpoint (`POST /identity/reconcile`) and the `identityReconcileTimer` cover the gap. The webhook route itself still rejects every delivery until a secret exists.\\n- **Provider secrets are per-destination, not per-app.** `DiditVerifier` accepts an array of webhook secrets so rotation and multiple destinations work; every candidate is compared in constant time.\\n- **Meter cap is enforced before the provider call.** `reserveCall` happens in the same transaction as the consent/status gates, so a provider that bills cannot outrun the monthly cap. On a failed start, the slot is returned with `releaseCall`.\\n- **Wallet downloads are single-use with a 90s TTL.** The pkpass download URL is a bearer capability; `Cache-Control: no-store` on every pass-serving endpoint prevents intermediaries from caching a pass that may have been voided.\",\"identity-verification-wallet-passes-frontend-src\":\"# Identity Verification, Wallet &amp; Passes \u2014 frontend-src\\n\\n# Identity Verification, Wallet &amp; Passes \u2014 frontend\\n\\n## Overview\\n\\nThis module is the user-facing verification journey and credential surface of Assureworx. It takes a person from \u201cstart verification\u201d (or an employer invitation link) through identity, ABN, licence, and certificate checks, and ends with an Assureworx pass \u2014 a scannable QR credential that can be added to Apple Wallet or Google Wallet and shared with site gate staff.\\n\\nThe module lives under `frontend/src/app/verify/*` with supporting components in `frontend/src/components/verify`, `frontend/src/components/pass`, and `frontend/src/components/domain`. All journey pages are Next.js client components (`\\\"use client\\\"`) and rely on a session object in `sessionStorage` for progress tracking.\\n\\nA core invariant shapes the whole module: **passing checks is not the same as being Verified.** On a real (bound) account, the platform refuses to consider a verification complete until it is paid for (ADR-0025). The pass page therefore renders the *server\u2019s* truth about a credential, not whatever the local journey has accumulated.\\n\\n---\\n\\n## The session model\\n\\nAll journey progress lives in a `VerifySession` managed by `@/lib/verify-demo`. The client components use four functions:\\n\\n- `createSession(mode, seed)` \u2014 initialises a new session (`mode` is `\\\"account\\\"`, `\\\"invited\\\"`, or `\\\"demo\\\"`) and writes it to sessionStorage\\n- `loadSession()` \u2014 reads the current session; returns `null` if absent\\n- `updateSession(patch)` \u2014 merges a patch and persists; returns the updated session or `null`\\n- `clearSession()` \u2014 removes the session (used when the journey ends)\\n\\nThe session tracks, among other things: `fullName`, `email`, `abn`, `trade`, `region`, `suburb`, `postcode`, `licenceNo`, `selfie` + `selfieSimulated`, `startedAt`, `checksComplete`, `issuedAt`, `passId`, `abrEntityName`, `walletAdded`, `employerSponsored`, `verificationRequirementKeys`, `inviterName`, `inviteKind`, `onboardingJourney`, and `pendingStaff`.\\n\\nThree properties drive most routing decisions:\\n\\n- **`mode`** \u2014 `\\\"demo\\\"`, `\\\"account\\\"`, or `\\\"invited\\\"`. `isBoundVerifyMode(mode)` returns true for `\\\"account\\\"` and `\\\"invited\\\"`; a bound journey hits real APIs and renders server credentials. Demo mode is anonymous and self-contained.\\n- **`verificationRequirementKeys`** \u2014 `string[] | null`. `null` means an unscoped self-serve flow. A non-null array is a scoped checklist from an invitation or employer (e.g. `\\\"abn\\\"`, `\\\"identity\\\"`, `\\\"trade_types\\\"`, `\\\"artifact:...\\\"`).\\n- **`employerSponsored`** \u2014 when true, the employer pays for verification on Team; the journey skips payment and the pass page shows a \u201cwaiting for your employer\u201d state.\\n\\n### Routing\\n\\n`nextInvitationPath(session, stage)` is the routing authority. After each step completes, the client calls it with the step it just finished and pushes the returned route. It composes the requirement keys, invite kind, onboarding journey, and stored evidence to decide which step comes next \u2014 so steps are skipped, not just sequenced.\\n\\nThe checks screen also uses `checksFor(mode)` to build its check list, `requiresVerification(session, key)` to test individual requirement keys, and `needsStaffCapture(session)` to decide whether the staff-staging step applies.\\n\\n```mermaid\\nflowchart TD\\n    Start[\\\"/verify or /verify/i/[token]\\\"] --&gt; Details[\\\"Details\\\"]\\n    Details --&gt; Mid[\\\"ABR \u00b7 Journey \u00b7 Staff \u00b7 Trade \u00b7 Certificateseach skipped by nextInvitationPath\\\"]\\n    Mid --&gt; Scan[\\\"Scan / ID handoff\\\"]\\n    Scan --&gt; Photo[\\\"Pass photo\\\"]\\n    Photo --&gt; Checks[\\\"Running checks\\\"]\\n    Checks --&gt;|bound + self-funded| Pay[\\\"Payment\\\"]\\n    Checks --&gt;|demo or employer pays| Pass[\\\"Pass\\\"]\\n    Pay --&gt; Pass\\n```\\n\\n---\\n\\n## Journey steps\\n\\n### Entry points\\n\\n**`/verify` \u2014 `VerifyLandingClient`**\\n\\nThe landing page explains the process and starts a self-serve session. On \u201cStart my verification\u201d it hydrates a seed from `GET /auth/me` \u2014 email, name \u2014 and, for `trade_individual` accounts, probes `GET /memberships/me/employee-onboarding` to detect employer sponsorship. It then calls `createSession(\\\"account\\\", seed)` and pushes `/verify/details`.\\n\\n**`/verify/i/[token]` \u2014 `VerifyInviteClient`**\\n\\nHandles invitation links from employers or principal contractors. It fetches `GET /onboarding/invite/[token]` to obtain an `OnboardingContext` and branches:\\n\\n- **Existing profile** \u2192 offers the \u201cconnect\u201d path: `POST /onboarding/invite/[token]/connect`, which either completes immediately (`router.push(\\\"/profile?connected=1\\\")`) or creates an invited session and routes through `startEntraSignIn` / `startCredentialSetup`.\\n- **New person** \u2192 an accept form (first name, last name, company name for `direct_company` invites). Submitting `POST /onboarding/invite/[token]/accept` creates an invited session with the invite\u2019s requirement keys, then either starts credential setup (if the response requires it) or pushes `/verify/details`.\\n- **Expired / closed** \u2192 terminal error screens using `VerifyShell`.\\n\\n### Details \u2014 `/verify/details` \u2014 `VerifyDetailsClient`\\n\\nCollects the identity and business facts the checks will attach to. For bound journeys the email is **locked** to the signed-in account (or the invite\u2019s target email), because free-typing it would unlink the checks from the party. The page hydrates from `GET /auth/me`, and re-detects employer sponsorship via `GET /memberships/me/employee-onboarding` when the session does not already know about it.\\n\\nField rules:\\n\\n- Email: validated only in bound mode; must contain `@`\\n- Full legal name: at least two words\\n- ABN: exactly 11 digits, only when `needsAbn` (`!employerSponsored &amp;&amp; (keys === null || keys.includes(\\\"abn\\\"))`)\\n- Suburb/postcode: only for non-scoped (`verificationRequirementKeys === null`) flows; either a suburb or a 4-digit postcode is required\\n\\nOn submit, bound journeys verify the signed-in email matches the form, persist location via `PUT /parties/me/location` when not invitation-scoped, and route through `nextInvitationPath(session, \\\"details\\\")`. A 401 triggers the auth funnel: LAN/dev environments redirect to `/auth/login?next=...&amp;email=...`, otherwise `startEntraSignIn({ loginHint, returnTo, prompt: \\\"login\\\" })`.\\n\\n### ABN check \u2014 `/verify/abr` \u2014 `VerifyAbrClient`\\n\\nBound journeys only. Shows the user\u2019s name + formatted ABN and runs a live lookup against the Australian Business Register (`POST /onboarding/abn`). A cached `abrEntityName` on the session short-circuits straight to \u201cpassed\u201d. Success persists `abrEntityName` and `abrProvider`; failure shows the provider\u2019s message with a \u201cFix details and retry\u201d action. The continue button label adapts to where `nextInvitationPath(session, \\\"abn\\\")` points (journey, staff, ID check, or continue).\\n\\n### Journey choice \u2014 `/verify/journey` \u2014 `VerifyJourneyClient`\\n\\nOnly reached by `direct_company` invites that have not yet chosen an onboarding path. Presents three options via `JOURNEYS`:\\n\\n- `myself` \u2014 sole trader\\n- `myself_employees` \u2014 sole trader + staged staff\\n- `bulk` \u2014 company admin onboarding many staff\\n\\nNon-`direct_company` invites are auto-routed to `myself`. Choosing persists `onboardingJourney` (and resets `pendingStaff` when choosing `myself`) then forwards to `nextInvitationPath`.\\n\\n### Staff staging \u2014 `/verify/staff` \u2014 `VerifyStaffClient`\\n\\nUsed by the `myself_employees` and `bulk` journeys. Stages `PendingStaffInvite` rows (`id`, `name`, `email`, `tradeNote`) in the session \u2014 up to 20 people, or 50 in bulk mode. Duplicate emails are rejected. The invites are not sent yet; they are flushed after the pass is reached via `flushPendingStaffInvites()`.\\n\\n### Evidence uploads \u2014 `/verify/trade` and `/verify/certificates`\\n\\nBoth wrap `VerifyEvidenceStep`, which provides the shared upload chrome (intro, back/continue, empty-state handling) and renders `EvidenceUploadForm` for the relevant evidence group.\\n\\n**`VerifyTradeClient`** (`group=\\\"licence\\\"`) decides whether an empty continue is allowed: if the invite has no licence-class artifact keys (`key.startsWith(\\\"artifact:\\\") &amp;&amp; isLicenceClass(...)`) and either never asked for `trade_types` or the user already chose trades that do not map to a licence document, the step can be skipped. The empty hint and `emptyAction` (jump to `/onboarding/trade-types`) are derived from whether trades still need to be selected.\\n\\n**`VerifyCertificatesClient`** (`group=\\\"certificates\\\"`) continues to `nextInvitationPath(session, \\\"certificates\\\")` for invited sessions with scoped requirement keys; otherwise it continues to the pass (the demo/sample case). Its `beforeContinue` hook marks `checksComplete` + `issuedAt` on the session for the demo path.\\n\\n### Identity and photo\\n\\n**`/verify/scan` \u2014 `VerifyScanClient`**\\n\\nTwo very different behaviours depending on mode:\\n\\n- **Demo mode** \u2014 a local camera capture flow. Opens `getUserMedia`, shows a 3-second countdown, captures a mirrored 480\u00d7480 JPEG frame, runs a fake 1.7 s \u201canalysing\u201d phase, and offers retake/confirm. If the camera is unavailable (or the user prefers), a **simulated scan** draws the user\u2019s initials on a coloured canvas and stores it with `selfieSimulated: true`.\\n- **Bound mode** \u2014 renders `` instead of the camera. The Didit photo-ID + face-match check happens in a provider window; the captured ID selfie never lands on Assureworx servers.\\n\\n**`/verify/photo` \u2014 `VerifyPhotoClient`**\\n\\nA mandatory gate-facing pass photo *after* the Didit identity step. This is deliberately separate from the ID selfie: \u201cThat stays with the identity provider and never becomes your profile photo.\u201d The page loads `GET /auth/me`, ignores generated monogram avatar URLs (`isGeneratedMonogramUrl`), and lets the user capture (via `FaceCapture`), replace, or remove the photo through `PUT /parties/me/photo` / `DELETE /parties/me/photo`. Saving updates `session.selfie` and clears `selfieSimulated`.\\n\\n### Checks \u2014 `/verify/checks` \u2014 `VerifyChecksClient`\\n\\nThe orchestration screen. `checksFor(mode)` returns an ordered check list; each check is either **live** (calls an API) or **recorded** (simulated with a duration and a static detail string).\\n\\n- `abr` (live) \u2014 `POST /onboarding/abn` for bound modes, `POST /verification/abn-lookup` for demo\\n- `identity` (live) \u2014 reads `identityApi.me()` and requires status `\\\"current\\\"` or `\\\"expiring_soon\\\"`\\n- Licence and other recorded checks \u2014 timer + `check.detail(session)`\\n\\nChecks run strictly sequentially: a failure stops the queue, shows the specific error, and offers \u201cBack to ID check\u201d (identity failure) or \u201cFix details and retry\u201d (ABR failure). Success marks `checksComplete` + `issuedAt` on the session and routes to:\\n\\n```ts\\nconst nextAfterChecks =\\n  session &amp;&amp; isBoundVerifyMode(session.mode) &amp;&amp; !session.employerSponsored\\n    ? \\\"/verify/pay\\\"\\n    : \\\"/verify/pass\\\";\\n```\\n\\nThe profile status chip is honest about this: it shows `\\\"Checks passed\\\"` when payment is still required, and `\\\"Verified\\\"` only when the next destination is the pass (demo case, where the sample pass is marked as such).\\n\\n### Payment \u2014 `/verify/pay` \u2014 `VerifyPayClient`\\n\\nBound, non-employer-sponsored journeys must pay before the verification is issued. The pass page references this step for the \u201cPay to activate\u201d action; the payment itself is Checkout-based, and a webhook settles it.\\n\\n### Pass \u2014 `/verify/pass` \u2014 `VerifyPassClient`\\n\\nThe terminal step. See below.\\n\\n---\\n\\n## The bound/demo split at the pass step\\n\\n`VerifyPassClient` is explicit about its two credential sources (the code comment calls them *not interchangeable*):\\n\\n- **Bound journey** (invite or signed-in account) renders the **server\u2019s credential** from `GET /wallet/pass`. Validity, status wording, and the QR are whatever the platform actually believes \u2014 including `\\\"Payment required\\\"`, which is what an unpaid account gets no matter how many checks it passed.\\n- **Anonymous demo** has no party, no payment, and no server pass, so it renders a clearly-labelled **sample** from the session (`samplePassView`), with `validity: \\\"invalid\\\"` and `statusLabel: \\\"Sample\\\"`. It cannot be added to a wallet or shared.\\n\\nThe page\u2019s header comment notes this split exists because the page previously built a card locally in both cases and hardcoded `validity: \\\"current\\\"` with the label `\\\"Verified\\\"` \u2014 showing a verified-looking credential to people who had paid nothing.\\n\\nThe pass view itself is built with `passViewFromWallet(serverPass)` (server) or `samplePassView(session)` (demo), then rendered by the shared `DigitalPass` component.\\n\\n### Payment confirmation polling\\n\\nBecause a webhook settles the payment rather than the Checkout redirect, the pass page reconciles directly with the provider when arriving via `?from=checkout`. It posts `POST /payments/me/reconcile` and reloads the server pass, up to `CONFIRMING_ATTEMPTS` (10) attempts at `CONFIRMING_POLL_MS` (3 s) intervals. While polling, the page shows a \u201cConfirming payment\u201d state instead of the misleading \u201cPayment required\u201d.\\n\\n### Wallet issuing and sharing\\n\\n`GET /wallet/status` reports `appleConfigured` / `googleConfigured`. Wallet buttons only render when the pass is **issuable**:\\n\\n```ts\\nconst issuable = bound &amp;&amp; paid &amp;&amp; view.validity === \\\"current\\\";\\n```\\n\\n`POST /wallet/passes` with `{ region: view.region }` returns `appleDownloadPath` / `googleSaveUrl`; the client redirects there via `window.location.assign`. Sharing is held to the same bar \u2014 an unpaid or unverified pass has nothing worth sharing. Share text includes the pass ID, holder name, trade, region, and formatted ABN, and uses `navigator.share` with a clipboard fallback.\\n\\nError mapping for wallet failures is handled by `friendlyWalletError`, which special-cases `UNAUTHENTICATED`, `PAYMENT_REQUIRED`, `NOT_VERIFIED`, `ABN_REQUIRED`, and `*_NOT_CONFIGURED` codes.\\n\\n### Pass page states\\n\\n| State | Condition | Title / UI |\\n|---|---|---|\\n| Verified | `bound &amp;&amp; paid &amp;&amp; validity === \\\"current\\\"` | \u201cVerified pass\u201d, share + wallet actions |\\n| Settling | `bound &amp;&amp; !paid &amp;&amp; confirming` | \u201cConfirming payment\u201d, disabled button, polling note |\\n| Awaiting employer | `bound &amp;&amp; !paid &amp;&amp; employerSponsored` | \u201cWaiting for your employer\u201d, back-to-profile button |\\n| Payment required | `bound &amp;&amp; !paid &amp;&amp; !confirming` | \u201cPayment required\u201d, \u201cPay to activate\u201d |\\n| Sample | demo | Sample pass card + \u201cnot a credential\u201d note |\\n\\n---\\n\\n## Shared components\\n\\n- **`VerifyShell`** (`components/verify/VerifyShell.tsx`) \u2014 the journey chrome: step indicator, title, back link, footer actions, optional `invited` / `employerSponsored` flags, centre layout, and a loading skeleton.\\n- **`VerifyEvidenceStep`** \u2014 shared evidence-upload wrapper used by trade and certificates. Fetches the requirement checklist via `useApi`, renders `EvidenceUploadForm`, and supports `allowEmptyContinue` + `emptyHint` / `emptyAction` for scoped invites that do not require documents.\\n- **`IdentityHandoff`** \u2014 rendered by the scan step for bound journeys; hands off to the Didit provider flow.\\n- **`FaceCapture`** \u2014 camera capture component with a save/cancel/remove cycle, used by the pass-photo step (and the profile photo panel).\\n- **`DigitalPass`** (`components/pass/DigitalPass.tsx`) \u2014 the gate credential card. Renders the holder photo, facts (Pass ID, ABN, Licence, Expiry), QR (via `usePassQr`), and wallet action buttons. Supports `light`/`dark` tones and a pointer-tracking tilt effect that respects `prefers-reduced-motion`. Status label logic: expired/invalid passes show their stored `statusLabel`; current passes show \u201cVerified\u201d.\\n- **`pass-view` helpers** (`components/pass/pass-view.ts`) \u2014 `PassView` types, `passViewFromWallet`, `formatPassDate`, `expiringSoon`, `passIsExpired`, `initialsOf`, `squareJpegFromFile`.\\n- **`use-pass` / `PassOverlay`** \u2014 read the current user\u2019s pass credential and render a floating pass overlay; `usePassCredential` uses `passViewFromWallet` and a `blockerFor` helper to gate access.\\n- **`IdentityPanel`** (`components/domain/IdentityPanel.tsx`) \u2014 ongoing identity controls (ADR-0015): current status chip, \u201cMy identity has changed\u201d declaration, and consent withdrawal. Used on the profile page.\\n- **`VerifiedSplit`** (`components/domain/VerifiedSplit.tsx`) \u2014 \u201cDocs collected / People verified\u201d summary pair for Trade Network cards.\\n\\n---\\n\\n## Domain rules worth knowing\\n\\n- **ADR-0025 \u2014 payment before verified.** No payment, no verified credential. The checks screen and pass page both surface this explicitly in UI copy and code comments.\\n- **Email lock.** In bound journeys the details step locks the email to the account or invite target; the UI explains that free typing would unlink checks.\\n- **Didit selfie isolation.** The ID-check selfie captured by Didit never reaches Assureworx servers; the pass photo is a separate, mandatory capture.\\n- **`nextInvitationPath` owns sequencing.** Never assume a linear route through the steps \u2014 the helper skips anything the requirement keys do not ask for.\\n- **Demo is a simulation, clearly labelled.** Simulated selfies are flagged with `selfieSimulated`, sample passes are `validity: \\\"invalid\\\"` with a `\\\"Sample\\\"` label, and demo journeys never touch the wallet or share paths.\\n\\n---\\n\\n## API surface\\n\\n| Endpoint | Direction | Used by |\\n|---|---|---|\\n| `GET /auth/me` | read | Landing, details, photo |\\n| `GET /memberships/me/employee-onboarding` | read | Landing, details (employer sponsorship) |\\n| `GET /onboarding/invite/[token]` | read | Invite |\\n| `POST /onboarding/invite/[token]/accept` | write | Invite |\\n| `POST /onboarding/invite/[token]/connect` | write | Invite |\\n| `POST /onboarding/abn` | write | ABN step, checks (bound) |\\n| `POST /verification/abn-lookup` | write | Checks (demo) |\\n| `GET /onboarding/checklist` | read | Trade step, evidence steps (via `useApi`; also seeds the API cache) |\\n| `PUT /parties/me/location` | write | Details (non-scoped journeys) |\\n| `PUT /parties/me/photo` \u00b7 `DELETE /parties/me/photo` | write | Pass photo |\\n| `GET /wallet/pass` | read | Pass (server credential) |\\n| `GET /wallet/status` | read | Pass (wallet availability) |\\n| `POST /wallet/passes` | write | Pass (issue Apple/Google pass) |\\n| `POST /payments/me/reconcile` | write | Pass (post-checkout reconciliation) |\\n| `identityApi.me()` / `declareChange` / `withdrawConsent` | read/write | Checks, `IdentityPanel` |\\n| `GET /auth/entra/status` | read | Details (dev-auth detection) |\\n\\n---\\n\\n## Connections to the rest of the codebase\\n\\n- **Onboarding wizard** \u2014 `flushPendingStaffInvites` fires after the pass in bound journeys; the pass footer routes to `/onboarding/trade-types` (\u201cFinish your profile\u201d). The trade step can also be entered from `/onboarding/trade-types` when `trade_types` is a requirement.\\n- **Profile** \u2014 `ProfileClient` embeds `IdentityPanel` and reuses `squareJpegFromFile` for avatar uploads; the pass page routes employer-sponsored users back to `/profile`.\\n- **Client state** \u2014 `loadSession` / `updateSession` are consumed outside `/verify` too (onboarding wizard shell, trade-types, review, invite accept in other pages), so the session contract in `@/lib/verify-demo` is effectively the shared onboarding state.\\n- **Auth** \u2014 `@/lib/entra` provides `startEntraSignIn`, `startCredentialSetup`, and `isLanDevOrigin`; the details step funnels unsigned-in users through Entra (or the dev login redirect) with a `returnTo` back into the journey.\\n- **Wallet/pass server** \u2014 the pass step depends on `GET /wallet/pass` and `POST /wallet/passes`; those responses are converted to the shared `PassView` shape via `passViewFromWallet`, which is also used by the non-verify pass components (`use-pass`, `PassOverlay`).\",\"identity-verification-wallet-passes\":\"# Identity Verification, Wallet &amp; Passes\\n\\n# Identity Verification, Wallet &amp; Passes\\n\\n## Overview\\n\\nThe **Identity Verification, Wallet &amp; Passes** module group is Assureworx's identity and credential surface, spanning both the backend service layer and the user-facing Next.js frontend. Together they take a person from the start of a verification journey (or an employer invitation link) through identity, ABN, licence, and certificate checks, and end with a scannable Assureworx pass that can be added to Apple Wallet or Google Wallet and shared with site gate staff.\\n\\nThe group is split into two tightly-coupled sub-modules:\\n\\n- **[Backend \u2014 Identity Verification, Wallet &amp; Passes](backend-src.md)** \u2014 Didit identity verification with consent management, ABR/ABN lookups, and Apple/Google wallet pass issuance plus a live gate-resolution endpoint.\\n- **[Frontend \u2014 Identity Verification, Wallet &amp; Passes](frontend-src.md)** \u2014 the verification journey pages under `verify/*`, pass components, and the session-driven client flow.\\n\\n## How the Sub-Modules Fit Together\\n\\nThe frontend and backend share no code, but they are designed around the same principles: a strict **vendor boundary**, an **allowlist of persisted data**, and **idempotent, auditable state transitions**.\\n\\n- The **backend** owns all vendor communication. Didit verification calls, ABR lookups, and wallet pass generation happen behind strict adapter boundaries (`modules/identity/didit.ts`, `modules/verification`, `modules/wallet/`), so the frontend never talks to third parties directly.\\n- The **frontend** owns the journey state. A session object in `sessionStorage` carries progress between steps, and each step \u2014 photo capture, ABR entry, checks, pass view \u2014 is a `\\\"use client\\\"` page that calls the backend's public endpoints (`src/lib/use-api.ts`, `src/lib/verify-demo.ts`).\\n- The **wallet boundary** is where the two meet most visibly: the backend issues the pass buffers and serves them, while the frontend's `DigitalPass` component renders a preview, detects the visitor's platform (`detectPlatform`), and builds the appropriate save URL (`buildGoogleSaveUrl` for Google, Apple's web service for `.pkpass`).\\n\\n## Key Workflows\\n\\n1. **Verification journey** \u2014 a user arrives at an invite link (`VerifyInviteClient`) or starts verification directly; the frontend steps through identity, ABN, licence, and certificate checks, persisting progress in `sessionStorage`, with shared checklist and identity-status services (`checksFor`, `deriveIdentityStatus`) keeping the UI and backend state consistent.\\n\\n2. **Identity proofing** \u2014 the frontend hands the user to the Didit flow; the backend manages the vendor call (`fetchResult` \u2192 `call`), collects warnings and diagnostics, and persists only allowlisted fields. A reconciliation scheduler (`sweepStalePendingChecks`) sweeps stale pending checks and re-evaluates verified status idempotently.\\n\\n3. **Pass issuance and validation** \u2014 once checks pass, the frontend renders the pass (`VerifyPassClient` \u2192 `DigitalPass`), while the backend signs and stores the pass buffer (`putPassBuffer` / `takePassBuffer`). Site scanners hit the backend's gate-resolution endpoint at scan time to check whether the pass is currently valid \u2014 a live check, not just a static QR decode.\\n\\n```mermaid\\nflowchart LR\\n    U[User / Invite link] --&gt; F[Frontend journeyverify/* pages]\\n    F --&gt;|calls API| B[Backend module group]\\n    B --&gt; D[Didit identity verification]\\n    B --&gt; A[ABR / ABN lookup]\\n    B --&gt; W[Wallet pass issuanceApple / Google]\\n    F --&gt; P[DigitalPass preview &amp; save]\\n    W --&gt; G[Gate resolution endpoint]\\n    G --&gt; S[Site scanner]\\n```\\n\\n## Related Reading\\n\\n- [Backend \u2014 Identity Verification, Wallet &amp; Passes](backend-src.md)\\n- [Frontend \u2014 Identity Verification, Wallet &amp; Passes](frontend-src.md)\",\"infrastructure-deployment\":\"# Infrastructure &amp; Deployment\\n\\n# Infrastructure &amp; Deployment\\n\\n## Overview\\n\\nAssureworx is an npm-workspaces monorepo containing two deployable applications \u2014 `@assureworx/backend` (Express 5 API) and `@assureworx/frontend` (Next.js 15 App Router) \u2014 plus a shared package, `@assureworx/shared-types`. Both applications package as multi-stage Docker images built from the repository root and run as separate container workloads.\\n\\nThe deployment model is a backends-for-frontends (BFF) pattern (ADR-0008): the browser talks only to the Next.js origin, and `/api/v1/*` is proxied to the Express API so the httpOnly session cookie stays first-party. The frontend container runs behind a small Node HTTP proxy (`gate-proxy.mjs`) that provides health probes, an optional Basic Auth gate for dev/staging environments, and runtime injection of a dev-auth secret.\\n\\nThe backend is deliberately modular: `@assureworx/backend` is described in its `package.json` as \\\"deployable as one process or split later\\\", and its service boundaries are structured to support future extraction into separate microservices without changing the build model.\\n\\n## Repository Layout\\n\\n| Path | Role |\\n|---|---|\\n| `backend/` | Express API workspace. ESM (`\\\"type\\\": \\\"module\\\"`), TypeScript compiled by `tsc` to `dist/` |\\n| `frontend/` | Next.js App Router workspace. `output: \\\"standalone\\\"`, served through `gate-proxy.mjs` |\\n| `packages/shared-types/` | Shared TypeScript types and runtime constants; consumed as `.ts` source in dev, compiled to `dist/` in the backend image |\\n| `scripts/migrate.mjs` | Root-level database migrations (`npm run migrate`, `npm run migrate:status`) |\\n\\n## Build Pipeline\\n\\nBoth Dockerfiles share the same three-stage shape \u2014 `deps` (install), `build` (compile), `runner` (minimal runtime) \u2014 all based on `node:22-alpine`. Both runners create a non-root user `awx` and attach OCI provenance labels from `DEPLOY_COMMIT`, `DEPLOY_BUILT_AT`, and `DEPLOY_DIRTY` build args.\\n\\n### shared-types\\n\\n`@assureworx/shared-types` declares `main`, `types`, and `exports` pointing at `./src/index.ts`. This is deliberate: local `tsx` dev and Next.js's bundler can consume TypeScript directly. However, the package exports **runtime values** (e.g. `AU_REGIONS`), not just types, so the backend's Node runtime cannot use the source form.\\n\\nThe backend Dockerfile handles this in two places:\\n\\n1. **Build stage** \u2014 compiles shared-types explicitly with `npx tsc -p packages/shared-types/tsconfig.json` before building the backend.\\n2. **Runner stage** \u2014 rewrites `packages/shared-types/package.json` so `main`, `types`, and `exports` point at `./dist/index.js`, then copies only the compiled `dist/` directory.\\n\\nThe frontend image does not compile shared-types separately; Next.js bundles the workspace source at build time via the symlink npm workspaces creates in `node_modules`.\\n\\n### Backend Image (`backend/Dockerfile`)\\n\\n- **deps** \u2014 copies only the three relevant `package.json` files (root, backend, shared-types), then runs `npm ci -w @assureworx/backend --include-workspace-root`. Keeping the install layer scoped this way preserves cache efficiency.\\n- **build** \u2014 copies `node_modules` from `deps`, then all backend and shared-types sources. Compiles shared-types, then runs `npm run build -w @assureworx/backend` (`tsc -p backend/tsconfig.json`).\\n- **runner** \u2014 `NODE_ENV=production`, `PORT=4000`, `EXPOSE 4000`, non-root `awx` user. Copies `node_modules`, `backend/dist`, shared-types `dist` (with the rewritten `package.json`), and the root `package.json`. `WORKDIR /app/backend`, `CMD [\\\"node\\\", \\\"dist/server.js\\\"]`.\\n\\nThe runner excludes all backend TypeScript source and build tooling. The backend `tsconfig.json` (`rootDir: src`, `outDir: dist`, `module: NodeNext`, `declaration` + `sourceMap`) is the single source of truth for what lands in `dist/`.\\n\\n### Frontend Image (`frontend/Dockerfile`)\\n\\n- **deps** \u2014 same pattern with `-w @assureworx/frontend`.\\n- **build** \u2014 copies workspace sources, accepts `ARG API_ORIGIN=http://localhost:4000` and bakes it into the environment. `API_ORIGIN` is consumed at build time by `next.config.ts`'s `rewrites()`. Runs `npm run build -w @assureworx/frontend` with `NEXT_TELEMETRY_DISABLED=1`.\\n- **runner** \u2014 re-declares `API_ORIGIN` as both an `ARG` and an `ENV`. This is required because the build-time value is baked into the Next rewrite table, but server-side code (`server-api.ts` / RSC fetches) reads `process.env.API_ORIGIN` at runtime \u2014 without the runtime declaration those fetches would hit `http://localhost:4000` inside the container, pointing at the container itself.\\n\\nThe runner copies:\\n\\n- `frontend/public` \u2014 static assets\\n- `.next/standalone` \u2014 the self-contained Next.js server, unpacked to `/app`\\n- `.next/static` \u2014 client assets, placed at `frontend/.next/static`\\n- `frontend/gate-proxy.mjs` \u2014 the container entrypoint\\n\\nIt then creates `/app/frontend/.next/cache` and `chown`s `.next` and `gate-proxy.mjs` to the `awx` user. Without this, `next/image`'s optimizer fails with `EACCES` and every `` renders a broken icon. `PORT=3000`, `HOSTNAME=0.0.0.0`, `EXPOSE 3000`, `CMD [\\\"node\\\", \\\"frontend/gate-proxy.mjs\\\"]`.\\n\\n### Dependency Pinning\\n\\nThe root `package.json` uses `overrides` to pin `sharp` to `^0.35.3` and `postcss` to `^8.5.18`. The inline comment explains why: Next 15.5.22 resolves vulnerable ranges (`GHSA-f88m-g3jw-g9cj` libvips CVEs; `GHSA-qx2v-qp2m-jg93` postcss XSS). These are build-time only for Next, but the backend processes user-uploaded images with `sharp`, so the entire tree is pinned to one patched copy to avoid two divergent versions in the image.\\n\\n## Runtime Architecture\\n\\n```mermaid\\nflowchart LR\\n    B[Browser] --&gt;|\\\":3000\\\"| GP[gate-proxy.mjs]\\n    GP --&gt;|\\\"GET /__healthz, /healthz\\\"| OK[200 ok]\\n    GP --&gt;|Basic Auth gate| NX[Next.js standalone127.0.0.1:3001]\\n    NX --&gt;|\\\"/api/v1/* rewrite\\\"| API[Express API\\\":4000\\\"]\\n```\\n\\n### BFF / API Rewrite\\n\\n`frontend/next.config.ts` defines a single rewrite:\\n\\n```\\nsource:      /api/v1/:path*\\ndestination: ${API_ORIGIN}/api/v1/:path*\\n```\\n\\nThe browser only ever talks to the Next.js origin. Session cookies set by the Express API remain first-party, and the CSP's `connect-src 'self'` stays accurate. `API_ORIGIN` defaults to `http://localhost:4000` when unset.\\n\\n### gate-proxy\\n\\n`gate-proxy.mjs` is the frontend container entrypoint. It listens on `PORT` (default 3000) and `HOSTNAME` (default `0.0.0.0`), spawns the Next.js standalone server (`server.js` in the same directory) on `127.0.0.1:NEXT_INTERNAL_PORT` (default 3001) with `stdio: \\\"inherit\\\"`, and proxies every request to it.\\n\\nWhy not Edge middleware? The header comment states it directly: Next Edge middleware cannot reliably see Container App environment variables (they get inlined at build time). The proxy reads `DEV_SITE_*` values from the real process environment **per request**, so the gate can be flipped without rebuilding the image.\\n\\n**Basic Auth gate.** Enabled when `DEV_SITE_GATE_ENABLED` is truthy via `envTruthy()` (accepts `1`, `true`, `yes`, `on`). Credentials come from `DEV_SITE_BASIC_USER` and `DEV_SITE_BASIC_PASSWORD`. If the gate is enabled but credentials are missing, the proxy returns `503` (\\\"Site gate misconfigured\\\") rather than locking everyone out with a broken comparison. `checkBasicAuth()` decodes the Base64 `Authorization` header, splits on the first colon, and compares both values with a length check followed by a constant-time XOR loop (`timingSafeEqual`). Failures return `401` with `WWW-Authenticate: Basic realm=\\\"Assureworx DEV\\\", charset=\\\"UTF-8\\\"` and `Cache-Control: no-store`.\\n\\n**Unauthenticated path exemptions** (`isUnauthedGetPath`, GET/HEAD only):\\n\\n- `/__healthz`, `/healthz`\\n- `/_next/static/*`\\n- Public asset extensions: `png`, `jpe?g`, `gif`, `webp`, `svg`, `ico`, `woff2?`, `ttf`, `otf`, `map`\\n\\nThe asset exemption is not cosmetic: `next/image` fetches static assets server-side without forwarding the browser's Basic Auth header, so gating those paths would break every image.\\n\\n**Health probes.** `GET /__healthz` and `/healthz` return `200` with `ok` before any gate logic runs \u2014 required for Azure Container App liveness/readiness probes.\\n\\n**Dev auth injection.** When `DEV_AUTH_SECRET` is set, the proxy injects an `x-assureworx-dev-auth` header onto requests to `/api/v1/auth/dev-personas` (any method) and `POST /api/v1/auth/login`, allowing dev-persona login flows to reach the API authenticated. The header is added in `proxy()` before forwarding to Next, which passes it through the rewrite to Express.\\n\\n**Proxy mechanics.** Hop-by-hop headers (`connection`, `keep-alive`, `proxy-connection`, `transfer-encoding`) are stripped before forwarding to avoid double compression and connection-reuse issues. Upstream failures produce `502` with the error message; if headers were already sent, the response is ended as-is.\\n\\n**Shutdown.** On `SIGTERM`/`SIGINT`, `shutdown()` forwards the signal to the Next.js child, closes the public HTTP server, and exits `0`. If the child exits on its own, the proxy logs the code/signal and exits with the child's code (or `1`).\\n\\n## Configuration Reference\\n\\n| Variable | Default | Scope | Purpose |\\n|---|---|---|---|\\n| `PORT` | backend `4000`, frontend `3000` | runtime | Listen port |\\n| `API_ORIGIN` | `http://localhost:4000` | frontend build + runtime | Express API origin for rewrites and server-side fetches |\\n| `HOSTNAME` | `0.0.0.0` | frontend runtime | Bind address for gate-proxy |\\n| `NEXT_INTERNAL_PORT` | `3001` | frontend runtime | Port for the spawned Next.js standalone server |\\n| `NEXT_TELEMETRY_DISABLED` | `1` (in image) | both | Disable Next telemetry |\\n| `NODE_ENV` | `production` (in image) | both | Runtime mode |\\n| `DEV_SITE_GATE_ENABLED` | unset (off) | frontend runtime | Enable the Basic Auth gate |\\n| `DEV_SITE_BASIC_USER` / `DEV_SITE_BASIC_PASSWORD` | unset | frontend runtime | Gate credentials; missing when enabled \u2192 `503` |\\n| `DEV_AUTH_SECRET` | unset | frontend runtime | Injected as `x-assureworx-dev-auth` on dev-auth endpoints |\\n| `DEPLOY_COMMIT` / `DEPLOY_BUILT_AT` / `DEPLOY_DIRTY` | `unknown` / `unknown` / `true` | build | OCI image labels |\\n| `LAN_DEV_ORIGIN_HOST` | unset | frontend dev only | Allow-listed dev origin (see below) |\\n\\n## Security Model\\n\\n- **BFF origin headers** \u2014 `next.config.ts` applies security headers to all routes via `headers()`: `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, and a `Permissions-Policy` that grants `camera=(self)` only (for the pass badge and verify-selfie `getUserMedia` flows) while denying `microphone`, `geolocation`, and `payment`. `Strict-Transport-Security` (`max-age=31536000; includeSubDomains`) is added only in production.\\n- **CSP** \u2014 `default-src 'self'`; `script-src` allows `'unsafe-inline' 'unsafe-eval'` (required by App Router hydration) plus `http://localhost:8400` in dev for impeccable live mode; `img-src` allows `data:`, `blob:`, the Carto CDN tile hosts (`a`\u2013`d.basemaps.cartocdn.com`), and `*.blob.core.windows.net`; `frame-ancestors 'none'`, `base-uri 'self'`, `form-action 'self'`, `object-src 'none'`. The API applies its own helmet headers, so CSP is not duplicated there.\\n- **Basic Auth gate** \u2014 protects the dev/staging site from anonymous browsing; health endpoints and static assets stay exempt so platform probes and the image optimizer work.\\n- **Non-root containers** \u2014 both runners drop to the `awx` user. The backend runner contains no source or tooling, only compiled output.\\n- **Dependency overrides** \u2014 `sharp` and `postcss` are pinned repo-wide to patched versions (see above).\\n\\n## Development Workflows\\n\\nRoot scripts (`package.json`):\\n\\n- `npm run dev` \u2014 starts all workspaces; `dev:frontend` / `dev:backend` target one side.\\n- `build`, `lint`, `typecheck`, `test` \u2014 run across all workspaces.\\n- `migrate` / `migrate:status` \u2014 `node scripts/migrate.mjs`.\\n\\nBackend workspace scripts:\\n\\n- `dev` \u2014 `tsx watch --env-file=.env src/server.ts`; `build` \u2014 `tsc -p tsconfig.json`; `start` \u2014 `node dist/server.js`.\\n- `seed`, `invite`, `purge` \u2014 operational tooling (`src/seed.ts`, `scripts/mint-invite.mjs`, `scripts/purge-party.mjs`).\\n- `verify:identity` \u2014 identity verification script.\\n- `probe:*` \u2014 integration probes against real external services: `probe:webhook`, `probe:requirements` (engagement requirements), `probe:extraction`, `probe:cloud-read` (Azure), `probe:google-wallet`.\\n- `test` \u2014 `node --import tsx --test \\\"src/**/*.test.ts\\\"`; `test:db` targets `*.dbtest.ts` and loads `.env` via `--env-file-if-exists`.\\n\\nFrontend workspace scripts:\\n\\n- `dev` (`127.0.0.1:3000`), `dev:turbo` (Turbopack), `dev:alt` (port 3001), `dev:lan` (`0.0.0.0` with `--experimental-https` for phone testing).\\n- `perf:selftest`, `perf:prod`, `perf:localhost` \u2014 performance scenario runners under `src/lib/perf/`.\\n- `build` / `start` \u2014 standard Next commands.\\n\\nLocal development defaults: API on `:4000`, web on `:3000`, gate off (env unset), `API_ORIGIN` defaulting to `http://localhost:4000`.\\n\\n**Dev-origin allow-list.** Next 15 warns when a LAN dev server is hit from an IP not in `allowedDevOrigins`. The config includes `LAN_DEV_ORIGIN_HOST` (set by `scripts/run-mobile-demo.bat` to the machine's current Wi-Fi IPv4), the legacy `192.168.1.94`, and `*.local`.\\n\\n**ESLint note.** The frontend ESLint config registers the `@next/next` and `react-hooks` plugins explicitly (not just their rules) because `eslint-disable` comments naming `@next/next` rules resolve only when the plugin is registered \u2014 without it, those comments are themselves lint errors, which is what previously broke `next build`.\\n\\n## Relationship to the Rest of the Codebase\\n\\n- **`@assureworx/shared-types` is a runtime dependency, not just a type dependency.** The backend imports values like `AU_REGIONS` from it. This single fact drives the shared-types compile step and the `package.json` rewrite in the backend Dockerfile. Local `tsx` dev sidesteps this by consuming the `.ts` source directly; the Docker build produces the compiled artifact the Node runtime needs.\\n- **`API_ORIGIN` has dual consumption.** `next.config.ts` reads it at build time for rewrites; server components and `server-api.ts` read it at runtime. The frontend Dockerfile's `ARG`/`ENV` repetition in the runner stage exists precisely to satisfy both.\\n- **Migrations are host-side, not container-side.** `scripts/migrate.mjs` runs from the repo root in CI or locally, while seed/invite/purge run inside the backend workspace against a configured database.\\n- **Security headers are split by trust boundary.** The Next origin sets CSP and browser headers via `next.config.ts`; the Express API applies its own protection via `helmet`. Neither duplicates the other.\\n- **The `probe:*` scripts are the deployment verification toolkit.** They exercise webhook delivery, engagement-requirement computation, document extraction, Azure cloud reads, and Google Wallet pass generation against the deployed environment's real dependencies.\",\"legacy-experimental-artifacts\":\"# Legacy &amp; Experimental Artifacts\\n\\n# Legacy &amp; Experimental Artifacts\\n\\n## Overview\\n\\nThis module is an archive of page-level React components and stylesheets extracted from the Assureworx application. It captures three distinct UI surfaces that were either superseded or still experimental at the time of extraction:\\n\\n1. **Project detail &amp; site management** \u2014 `page 2.tsx` and `page 2 (1).tsx`\\n2. **Web onboarding invitation** \u2014 `page 3.tsx`\\n3. **Mobile verification invitation** \u2014 `page 4.tsx`\\n\\nAll pages are Next.js App Router client components (`\\\"use client\\\"`). They share a consistent dependency surface \u2014 `@/lib/api`, `@/lib/use-api`, `@/components/ui/*`, and `@assureworx/shared-types` \u2014 and are connected to the wider codebase through a small set of well-defined integration points (Entra credential setup, a demo-session store, and a persistent-draft store).\\n\\nThe artifact filenames are generic (`page 2.tsx`, `page 3.tsx`, \u2026), and `page 2.tsx` is duplicated verbatim as `page 2 (1).tsx`. This indicates the directory is a snapshot/recovery copy rather than a live feature tree. Treat modifications here with corresponding caution.\\n\\n## File inventory\\n\\n| File | Exported symbol | Role |\\n|---|---|---|\\n| `page 2.tsx` | `ProjectDetailPage` | Project detail, add-site form, onboarding-request wizard. Duplicate of `page 2 (1).tsx`. |\\n| `page 2 (1).tsx` | `ProjectDetailPage` | Identical copy of `page 2.tsx`. |\\n| `page 3.tsx` | `InvitePage` | Invitation acceptance for the web onboarding journey (`/onboarding/abn`). Uses `invite.module.css`. |\\n| `page 4.tsx` | `VerifyInvitePage` | Invitation acceptance for the mobile verification journey (`/verify/details`). Uses `invited.module.css` (not included in this artifact set). |\\n| `invite.module.css` | \u2014 | Styles consumed by `InvitePage`. |\\n| `requests.module.css` | \u2014 | Orphaned styles for a request-management UI (table rows, share strip, recognition banners). Not imported by any page in this module. |\\n| `Untitled.base` | \u2014 | Call-graph / execution-flow metadata captured alongside the source. |\\n\\nNote that two referenced stylesheets \u2014 `project-detail.module.css` (imported by `page 2.tsx`) and `invited.module.css` (imported by `page 4.tsx`) \u2014 are **not** part of this artifact set.\\n\\n## Shared foundation: the invitation context\\n\\nBoth invitation pages (`InvitePage`, `VerifyInvitePage`) load the same resource:\\n\\n```\\nGET /onboarding/invite/:token   \u2192   OnboardingContext\\n```\\n\\n`OnboardingContext` (from `@assureworx/shared-types`) drives the entire invite-page state machine:\\n\\n| Field | Purpose |\\n|---|---|\\n| `kind` | `\\\"direct_company\\\"` when the invite targets a company. `InvitePage` renders a company-name field only in this mode. |\\n| `status` | `\\\"pending\\\"` / `\\\"accepted\\\"` continue the flow; any other value renders the \\\"invitation has closed\\\" screen. |\\n| `senderOrgName` / `rootOrgName` | Who sent the invite and, when different, the principal contractor it is on behalf of (\\\"Requested by X on behalf of Y\\\"). |\\n| `targetEmail` | The address bound to the invite. Used as the Entra login hint and quoted in consent/legal text. |\\n| `existingProfile` | Present when the invitee already has an Assureworx profile. Carries `name`, `status`, `publicRef`, and `credentialPending`. |\\n\\nTwo mutation endpoints back the flows:\\n\\n- `POST /onboarding/invite/:token/accept` \u2014 creates the party and (on the verify side) the session that later steps record against.\\n- `POST /onboarding/invite/:token/connect` \u2014 links an existing Assureworx profile to the requesting principal, no re-verification.\\n\\n## Invitation flows\\n\\nThe two invite pages implement the same decision tree with different destinations and session handling.\\n\\n```mermaid\\nflowchart TD\\n    A[Load OnboardingContext] --&gt; B{Load error?}\\n    B --&gt;|Error| C[Error / invalid-link screen]\\n    B --&gt;|OK| D{status pending or accepted?}\\n    D --&gt;|No| E[Invitation closed screen]\\n    D --&gt;|Yes| F{existingProfile?}\\n    F --&gt;|No| G[Accept form]\\n    G --&gt; H[POST accept \u2192 session* \u2192 Entra or direct route]\\n    F --&gt;|Yes| I{Profile verified?}\\n    I --&gt;|Yes| J[POST connect \u2192 /profile?connected=1]\\n    I --&gt;|No| K[POST connect \u2192 createSession \u2192 Entra sign-in/up \u2192 /verify/details]\\n```\\n\\n\\\\* `InvitePage` does **not** create a session; `VerifyInvitePage` always does, before any redirect.\\n\\n### `InvitePage` (page 3) \u2014 web onboarding\\n\\nEntry point for the `WizardShell` onboarding journey (`/onboarding/abn` destination).\\n\\n- **Loading / error states.** While the context loads, a `SkeletonRows` placeholder is shown. A 404 produces \\\"This invitation link is no longer valid\u2026\\\"; any other failure produces a connection-error message.\\n- **Portability path.** When `existingProfile` is present, no form is rendered. Instead the page explains that the invitee's compliance profile will be linked to `rootOrgName` without re-uploading documents. `connect()` POSTs an empty body to `/onboarding/invite/:token/connect` and routes to `/profile?connected=1`. The UI makes the consent explicit: `rootOrgName` sees verification status and current requirements only, not the documents themselves.\\n- **New-profile path.** The accept form collects first name, last name, mobile number, and \u2014 only when `context.kind === \\\"direct_company\\\"` \u2014 company name.\\n- **Draft persistence.** Form values are backed by `usePersistentDraft(\\\"awx.draft.onboarding.invite.\\\", EMPTY_INVITE, isInviteDraftEmpty)`. The `DraftStatus` component surfaces `savedAt` / `restored` state and offers a discard action when a draft exists. The draft is cleared on successful accept (`inviteDraft.clearDraft(false)`).\\n- **Accept.** `accept()` POSTs to `/onboarding/invite/:token/accept`. If the response requires Entra (`isRequireEntraSignIn(result)`), it calls `startEntraSignUp({ loginHint: result.email, returnTo: result.returnTo || \\\"/onboarding/abn\\\" })`; otherwise it routes directly to `/onboarding/abn`.\\n\\n### `VerifyInvitePage` (page 4) \u2014 mobile verification\\n\\nInvited entry into the `/verify` journey. The file's own doc comment states why it exists:\\n\\n&gt; Accepting the invitation is what makes the rest of /verify real: it creates the party and the session that the ABN write and the Didit identity check are recorded against. The anonymous /verify landing cannot do this, which is why it never runs an identity check.\\n\\nKey differences from `InvitePage`:\\n\\n- **Accept is minimal.** Only first/last name are collected; there is no company field, no phone field, and no draft persistence.\\n- **Session ordering is critical.** `accept()` calls `createSession(\\\"invited\\\", { fullName, email, inviterName })` **before** any Entra navigation. The inline comment explains the constraint:\\n\\n  &gt; Signing up with Entra navigates this tab away and back, and /verify/details bounces anyone without a session, so creating it afterwards would strand every Entra user on the landing page.\\n\\n  The session email is derived from `context.targetEmail`, falling back to `result.email` when the accept response requires Entra.\\n- **Destinations.** Successful accept routes to `/verify/details`, not `/onboarding/abn`.\\n- **Existing-profile path splits on verification state:**\\n  - `existing.status === \\\"verified\\\"` \u2014 \\\"Connect my profile\\\": POST connect, route to `/profile?connected=1`. No re-verification.\\n  - Status not verified \u2014 `connect(true)` POSTs connect **and** continues into the checks, because \\\"connecting shares a status the principal contractor cannot act on.\\\" It creates a session and then chooses `startEntraSignUp` when `existingProfile.credentialPending` is true, otherwise `startEntraSignIn`, both with `returnTo: \\\"/verify/details\\\"`.\\n\\n## `ProjectDetailPage` (page 2) \u2014 project &amp; site management\\n\\nTwo identical copies of the project-detail page ship in this module. All symbol names below exist in both.\\n\\n### Data loading\\n\\n- `useApi(`/nodes/projects/${id}`)` \u2014 project, site list, and ref code. `refresh()` is used to re-fetch after site creation.\\n- `useApi(\\\"/directory?pageSize=20\\\")` \u2014 the current network directory, used to power the request wizard.\\n- `useSession()` \u2014 `me.active?.role === \\\"tier1_admin\\\"` gates the add-site and request-wizard actions (`canWrite`).\\n\\nThe `places` memo transforms each `ProjectDetail.sites` row into a `MapPlace` for `SiteMapBoardLazy`, deriving the geocode query via `geocodeQueryForSite(site) || formatSiteAddress(site) || \\\"Location not recorded\\\"` and a status detail string (`\\\"N assigned | M ready | K blocked\\\"`).\\n\\n### Add-site form\\n\\n`submit()` POSTs `CreateSiteBody` fields to `/nodes/projects/:id/sites`: `name` (required), plus optional `address`, `locality`, `region`, `postcode`. The region select is fed by the `AU_STATES` constant (`NSW`, `VIC`, `QLD`, `WA`, `SA`, `TAS`, `ACT`, `NT`). Errors from the API are surfaced through `formError` via `ApiClientError.message`.\\n\\n### Onboarding-request wizard\\n\\nA four-step stepper \u2014 *Project context \u2192 Select companies \u2192 Set requirements \u2192 Review &amp; send* \u2014 rendered when the `requesting` flag is toggled:\\n\\n1. **Project context.** Static summary of the project, site focus, and objective.\\n2. **Select companies.** Checkbox list of directory rows filtered to `kind === \\\"trade_company\\\"` (`availableCompanies`), with loading and empty states handled.\\n3. **Set requirements.** Three checkbox groups backed by `companyRequirements`, `tradeRequirements`, and `tradeTypes` state, using the generic `toggleSelection(value, list, setList)` helper.\\n4. **Review &amp; send.** Read-only summary of every selection.\\n\\nNote that `submitRequest()` is a **stub** \u2014 it fires a toast (\\\"Request draft prepared for the selected companies.\\\"), resets the wizard state, and makes no API call. The wizard is a UI prototype, not a working flow.\\n\\n### Duplication\\n\\n`page 2.tsx` and `page 2 (1).tsx` are identical. Both are included in the artifact set verbatim; there is no behavioural difference. If either copy is ever revived, the duplicate should be deleted and the survivor moved to a conventional route path (e.g. `app/projects/[id]/page.tsx`).\\n\\n## Stylesheets\\n\\n### `invite.module.css`\\n\\nStyles the `InvitePage` surfaces:\\n\\n- `.pathCard`, `.pathEyebrow`, `.pathList` \u2014 the \\\"New profile path\\\" / \\\"Existing profile path\\\" explainer cards with a small-caps eyebrow label and bulleted benefit list.\\n- `.inviteCard`, `.inviteFrom`, `.whatList` \u2014 the invitation summary card; `.whatList` renders green checkmark bullets via an inline SVG data URI.\\n- `.timeHint`, `.portable` \u2014 the \\\"takes about 10 minutes\\\" hint and the portable-profile callout shown on the connect path.\\n- `.identity`, `.awxId` \u2014 the recognised-profile `\n` summary (name, Assureworx ID, status) with tabular-numeral styling for the ID.\\n- `.form`, `.nameRow`, `.legal`, `.formError` \u2014 the accept form, two-column name grid, legal/consent text, and error presentation.\\n\\n### `requests.module.css`\\n\\nOrphaned styling that implies a request-management page (table with checkboxes, bulk actions, post-send share strip) that is not present in this artifact set. Notable classes:\\n\\n- `.shareStrip` / `.shareUrl` / `.shareActions` \u2014 a post-send banner where the invite link is the deliverable, with a monospace, ellipsized URL and copy actions.\\n- `.recognised` / `.recognisedWarn` \u2014 pre-invite recognition banners shown when an identity already has an Assureworx profile; the warn variant is muted for unverified identities.\\n- `.rowActions`, `.revoke`, `.checkCol`, `.check`, `.checkTarget`, `.bulkConfirm` \u2014 table row actions with a revoke action and a mobile-friendly tap target (44\u00d744px under 1024px).\\n- `.summaryBanner` / `.summaryMetric` \u2014 a metrics header (eyebrow, title, metric cards) for aggregate request status.\\n\\n## Integration points &amp; execution flows\\n\\nThe module's pages participate in four cross-cutting mechanisms:\\n\\n1. **Performance tracking.** `useApi` wraps network requests with `trackNetworkInstant` (`lib/perf/store.ts`), which pushes into an in-memory store surfaced through `emit` / `isRecording`. Loading the project-detail or invite pages therefore feeds the app's network-instant telemetry automatically.\\n\\n2. **Entra credential setup.** `startEntraSignUp` / `startEntraSignIn` (`@/lib/entra`) delegate to `startCredentialSetup` (`@/lib/credential-setup.ts`), which stashes a credential-setup draft (`stashCredentialSetupDraft`) and validates the `returnTo` URL through `safeReturnTo` / `safeReturnToOrNull` (`@/lib/safe-return-to.ts`). The invite accept flows are, in effect, entry points into this credential-setup pipeline.\\n\\n3. **Demo session store.** `createSession(\\\"invited\\\", \u2026)` (`@/lib/verify-demo`) writes the session that `/verify/details` checks before rendering the verification checks. The mode tag `\\\"invited\\\"` distinguishes invited participants from anonymous landing-page visitors.\\n\\n4. **Draft persistence.** `usePersistentDraft` (`@/lib/use-persistent-draft`) backs the onboarding invite form keyed by invite token, so a partially completed invitation survives reloads without server round-trips.\\n\\nShared UI dependencies used throughout: `Button`, `InputField` / `SelectField`, `Panel`, `EmptyState`, `SkeletonRows`, `StatusChip` / `verifiedChip`, `PartyIdentity`, `DraftStatus`, `WizardShell`, `VerifyShell`, and `SiteMapBoardLazy`.\\n\\n## Maintenance notes\\n\\n- **Duplicated project-detail page.** `page 2.tsx` and `page 2 (1).tsx` are identical; any revival should keep one canonical copy.\\n- **Stubbed request wizard.** `submitRequest` in the project-detail page never persists a request \u2014 it only shows a toast. Do not treat the wizard as a working mutation flow.\\n- **Incomplete artifact set.** `project-detail.module.css` and `invited.module.css` are imported but absent from this module; `requests.module.css` is present but orphaned. A full restoration would require recovering the missing files or re-implementing the styles.\\n- **Session ordering in `VerifyInvitePage`.** The `createSession`-before-Entra ordering is load-bearing; moving the call after `startEntraSignUp` would strand Entra users on the landing page.\",\"notifications-communication\":\"# Notifications &amp; Communication\\n\\n# Notifications &amp; Communication\\n\\n## Overview\\n\\nThe Notifications &amp; Communication module delivers transactional messages across three channels:\\n\\n- **Email** \u2014 branded transactional mail via Azure Communication Services (ACS), with durable delivery tracking and an Event Grid webhook for provider delivery reports.\\n- **SMS** \u2014 short transactional messages via Twilio, built on a durable outbox that guarantees no message is lost when the process crashes between a database commit and a job enqueue.\\n- **In-app notifications** \u2014 tenant-scoped message rows surfaced in the CRM through a REST API and the `ActivityRail` UI component.\\n\\nThe module covers `backend/src/modules/email/webhook.ts`, `backend/src/modules/sms/`, `backend/src/modules/notifications/`, the shared services under `backend/src/shared/services/email*` and `backend/src/shared/services/sms/`, plus the frontend `ActivityRail` component and notification href/tone helpers.\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    Domain[Domain events: invites, payments, identity, verification] --&gt;|queueEmail| Q[(BullMQ)]\\n    Domain --&gt;|stageSms + pumpSms after commit| Q\\n    Q --&gt;|email.send| Email[sendEmail \u2192 ACS]\\n    Q --&gt;|sms.send| Sms[processSmsSend \u2192 Twilio]\\n    Email --&gt;|Event Grid webhook| EW[email webhook \u2192 email_delivery]\\n    Sms --&gt;|status callback| SW[sms webhook \u2192 t_sms]\\n    Domain --&gt;|direct insert| N[notifications API]\\n    N --&gt; UI[ActivityRail]\\n```\\n\\nThe two outbound channels share a common discipline:\\n\\n1. **Stage inside the authoritative transaction.** SMS rows are inserted in the same DB transaction that changes payment/identity/verification state. Email delivery rows are created before the send is enqueued.\\n2. **Pump after commit.** The staged row is enqueued to BullMQ only after the business transaction commits. If the enqueue fails (Redis blip, crash), a sweep timer re-pumps stale rows.\\n3. **Webhooks write delivery status back.** Provider callbacks update only delivery-tracking fields \u2014 they never mutate payment, identity, or verification outcomes.\\n\\n## Email delivery\\n\\n### Sending email: `sendEmail` and `queueEmail`\\n\\n`backend/src/shared/services/email.ts` exposes two entry points:\\n\\n**`sendEmail(input: SendEmailInput): Promise`** \u2014 synchronous send. Used from auth flows (OTP email, email change) that need the outcome inline.\\n\\n- If ACS is not configured, it soft-skips before claiming any idempotency key (`deliverViaAcs` returns `{ sent: false, skippedReason: \\\"ACS not configured\\\" }`).\\n- When an `idemKey` is supplied, it claims idempotency first; a prior successful send returns `{ sent: true, skippedReason: \\\"idempotent_replay\\\" }` with the stored `providerMessageId`.\\n- It creates the delivery row (status `sending`), calls ACS, updates the row to `accepted` or `failed`, and completes or releases the idempotency claim. Soft ACS failures release the claim so BullMQ or the caller can retry; hard exceptions also release it.\\n\\n**`queueEmail(input: SendEmailInput): Promise`** \u2014 fire-and-forget, preferred from request handlers so bursty invites do not hold HTTP workers on ACS latency.\\n\\n- Registers the `email.send` job (see below), creates a delivery row with status `queued`, and enqueues the job.\\n- Returns `{ sent: true, queued: true }` on successful enqueue \u2014 callers treat this as \\\"email accepted\\\" for UX purposes.\\n- Falls back to `sendEmail` when ACS is unset.\\n\\nThe worker payload is the `SendEmailInput` plus a `deliveryId`, so delivery state survives across BullMQ retries and idempotency keys are never reused for resends.\\n\\n`SendEmailInput` fields:\\n\\n| Field | Purpose |\\n|---|---|\\n| `to`, `subject`, `text`, `html?` | Message content. Default HTML wraps the plain text in `\n` |\\n| `idemKey?`, `idemTtlSeconds?` | At-most-once delivery; default TTL seven days. Omit for intentional resends |\\n| `messageKind?` | Operational category stored on the delivery row only \u2014 never message content |\\n| `deliveryId?` | Reuse an existing delivery row (BullMQ retry path) |\\n| `acceptOnly?` | Return after ACS accepts, don't poll to completion. Used by Entra's two-second `OnOtpSend` callback; Event Grid supplies final state |\\n\\n### Delivery tracking\\n\\n`backend/src/shared/services/email-delivery.ts` manages the `email_delivery` table.\\n\\n- `emailRecipientHash(recipient)` \u2014 HMAC-SHA256 of the lowercased/trimmed recipient using `config.sessionSecret`. **Raw addresses are never stored.**\\n- `createEmailDelivery(...)` \u2014 inserts `recipient_hash`, `message_kind` (default `transactional`), and status `queued` or `sending`.\\n- `updateEmailDelivery(id, ...)` \u2014 advances status and sets `provider_message_id`, `failure_reason`, and the relevant timestamp (`accepted_at`, `delivered_at`, `failed_at`).\\n- `applyAcsDeliveryEvent(...)` \u2014 matches a webhook event by `provider_message_id` + `recipient_hash` and applies `delivered`, `failed`, or `bounced`. Returns `false` when no row matches (logged, not fatal).\\n\\nThe status lifecycle is `queued \u2192 sending \u2192 accepted \u2192 delivered | failed | bounced`. Workers treat \\\"accepted by ACS\\\" as success; final disposition arrives via webhook.\\n\\n### Email webhook (ACS Event Grid)\\n\\n`backend/src/modules/email/webhook.ts` receives ACS delivery reports.\\n\\n- **Authentication**: query parameter `code` compared with `config.acsEmailWebhookSecret` using `timingSafeEqual`. The secret must be at least 32 characters. Invalid/missing codes get `401 { error: \\\"unauthorized\\\" }`.\\n- **Rate limiting**: `identityWebhookLimiter` is applied router-wide.\\n- **Subscription validation**: `Microsoft.EventGrid.SubscriptionValidationEvent` is answered with `{ validationResponse:  }`.\\n- **Delivery events**: only `Microsoft.Communication.EmailDeliveryReportReceived` is processed. `deliveryStatus`/`status` maps: `delivered` \u2192 delivered, `bounced` \u2192 bounced, `failed`/`filteredsuppressed`/`suppressed` \u2192 failed. Events without a `messageId`, recipient, or known status are skipped.\\n- Unmatched events are logged with the first 24 characters of the provider message id; the webhook always responds `200 { received: true }` after processing (Event Grid would otherwise retry forever).\\n\\n### Branded HTML and email bodies\\n\\n`backend/src/shared/services/email-brand.ts` provides `brandedEmailHtml(content)` \u2014 the Assureworx HTML chrome with inline styles only (email-client safe), brand tokens from `BRAND`, an optional CTA button + fallback link, preheader, and footer. All dynamic values pass through `escapeHtml` / `escapeAttr`.\\n\\nBody builders in `email.ts`:\\n\\n- `welcomeEmailBodies({ toName, loginUrl })` \u2014 post-credential-creation welcome (not the Microsoft OTP).\\n- `paymentThankYouEmailBodies({ toName, orderNo, amountLabel, billingUrl })` \u2014 fired after Stripe Checkout settlement.\\n- `inviteEmailBodies({ toName, orgName, inviteUrl, kind })` \u2014 four invite variants: `trade`, `seat`, `setup`, `employee`.\\n\\n## SMS delivery\\n\\n### Durable outbox model\\n\\n`backend/src/shared/services/sms/outbox.ts` implements the outbox on the `t_sms` table. Status codes:\\n\\n| Code | Meaning |\\n|---|---|\\n| `SM1` | Queued, waiting to be claimed |\\n| `SM2` | Claimed by a worker, send in progress |\\n| `SM3` | Accepted by Twilio |\\n| `SM4` | Delivered (or read) |\\n| `SM5` | Failed permanently after exhausting `maxAttempts` |\\n| `SM6` | Suppressed (permanent provider error, unknown template, not on allowlist) |\\n\\n### Staging messages\\n\\n`stageSms(tx, input)` inserts a row with status `SM1`. It runs **inside the caller's transaction**, alongside the authoritative state change. It refuses to stage when:\\n\\n- SMS is disabled or Twilio credentials are missing;\\n- the phone number does not normalize to a mobile-capable E.164 value;\\n- the recipient is not on `sms.testRecipientAllowlist` (non-prod refuses all sends unless allowlisted; prod treats an empty allowlist as unrestricted);\\n- the template render fails or the body fails `smsBodyLooksSafe`.\\n\\n`ON CONFLICT (idem_key_txt) DO NOTHING` makes staging idempotent: a duplicate key returns the existing row with `duplicate: true`, so callers can skip re-pumping.\\n\\nDomain-specific staging helpers in `backend/src/shared/services/sms/notify.ts` return the staged SMS id (or `null` when skipped/duplicate):\\n\\n- `stageInviteSms` \u2014 verification request invites; phone resolution prefers the target party's phone, then an explicit invite phone, then a party matched by email (`resolveInvitePhone`).\\n- `stageIdentityOutcomeSms` \u2014 maps check status `H02` (passed, no link), `H03`/`declined` (did not pass), `H04`/`in_review` (needs action), and `session_expired`.\\n- `stagePaymentLinkSms`, `stagePaymentSettledSms`, `stagePaymentFailedSms` \u2014 payment lifecycle messages.\\n- `stageVerificationCompletedSms` \u2014 fires only on transition **into** verified status (`toCd === \\\"S02\\\"`, `fromCd !== \\\"S02\\\"`).\\n\\nIdempotency keys are built by `smsIdemKey(parts)` or `smsInviteIdemKey(requestId, token)` (the latter fingerprints the invite token rather than storing it).\\n\\n### Pump and worker\\n\\n`backend/src/shared/services/sms/queue.ts` connects the outbox to BullMQ:\\n\\n- `registerSmsJob()` (alias `ensureSmsJob`) registers the `sms.send` handler. Like email, it must run before `startJobWorkers()`.\\n- `pumpSms(id)` / `pumpSmsMany(ids)` enqueue staged rows **after the staging transaction commits**. Enqueue failures are logged; the sweep will retry.\\n- `runSmsSweep()` re-pumps stale `SM1` rows (see scheduler).\\n\\n`processSmsSend(smsId)` in `outbox.ts` is the worker:\\n\\n1. Atomically claims `SM1 \u2192 SM2` (`claimSmsForSend` \u2014 at most one concurrent worker wins; attempt counter increments per claim).\\n2. Re-validates template, rendered body safety, and recipient allowlist.\\n3. Calls `smsProvider().send(...)`.\\n4. Success \u2192 `SM3` with the provider SID. Permanent provider categories (`opted_out`, `invalid_number`, `unsupported_destination`, `auth_config`) \u2192 `SM6` suppressed. Transient failures \u2192 back to `SM1`; throws `sms_transient:` so BullMQ backs off. Once `attempt_n` exceeds `maxAttempts`, the row becomes `SM5` terminal.\\n\\n### Twilio status callback webhook\\n\\n`backend/src/modules/sms/webhook.ts` handles `POST /twilio`.\\n\\n- **Signature verification**: `verifyTwilioSignature` uses `twilio.validateRequest`. The URL is reconstructed via `callbackUrl()`, which prefers `config.sms.statusCallbackUrl` (exact match required by Twilio) and falls back to `x-forwarded-proto`/`x-forwarded-host`.\\n- Form-encoded body (limit 32kb) is flattened by `formParams` for signature validation.\\n- `applyTwilioStatusCallback` maps Twilio statuses: `queued/accepted/sending/sent` \u2192 `SM3`, `delivered/read` \u2192 `SM4`, `undelivered/failed` \u2192 `SM5`, `canceled/cancelled` \u2192 `SM6`.\\n- The status update is **monotonic**: `SM4` is terminal, `SM5` is never regressed to `SM3`, and `SM6` is terminal. The row is locked `FOR UPDATE` during the check.\\n- Invalid signatures get `403`; SMS not configured gets `503`. Verified callbacks always respond `200` so Twilio does not retry. This webhook never touches payment or identity state.\\n\\n### Sweep scheduler\\n\\n`backend/src/modules/sms/scheduler.ts` runs `startSmsSweepTimer()` at server startup (and `stopSmsSweepTimer()` on shutdown). It re-pumps `SM1` rows older than 15 seconds that were staged but never enqueued (Redis blip or crash). The interval is `config.sms.sweepIntervalSeconds` with up to 5s of random jitter; the timer is unref'd so it does not hold the process open.\\n\\n### Phone normalization, templates, and provider boundary\\n\\n- `backend/src/shared/services/sms/phone.ts` \u2014 `normalisePhoneToE164` parses with a default region of AU, accepts numbers already carrying `+`, and retries bare AU mobiles (`4xxxxxxxx`) by prepending `0`. `FIXED_LINE`, `TOLL_FREE`, and `PREMIUM_RATE` numbers are rejected; `FIXED_LINE_OR_MOBILE` (common in AU) is accepted. `maskE164` produces log-safe phone strings.\\n- `backend/src/shared/services/sms/templates.ts` \u2014 nine templates (`request.invite`, `identity.*`, `payment.*`, `verification.completed`). Links must resolve to the `publicAppUrl` origin \u2014 off-origin URLs are refused. `smsBodyLooksSafe` rejects bodies containing UUIDs or PAN-like digit runs.\\n- `backend/src/shared/services/sms/provider.ts` \u2014 the only file that imports the Twilio SDK. Business code sees `SmsProvider` with a single `send` method. Permanent error codes are centralized (`PERMANENT_CODES`: invalid to, not a mobile, geo/permission, unsubscribed, auth). `FakeSmsProvider` and `setSmsProviderForTests` support tests; `sanitiseProviderMessage` strips phone numbers and credentials from logged errors.\\n\\n## In-app notifications\\n\\n### Notifications API\\n\\n`backend/src/modules/notifications/index.ts` mounts a tenant-scoped router behind `requireAuth` and `resolveTenantContext` (plus a `/health` probe before the middleware).\\n\\n- `GET /` \u2014 returns up to 30 message rows where `party_id` matches any of `readableParties(req)` (the org id plus the caller's person party id) and `channel_cd = 'Z01'`, newest first. Each row maps to `NotificationRow { id, kind, subject, body, createdAt, readAt }`.\\n- `POST /:id/read` \u2014 marks a message read (`status_cd = 'ZS4'`, `read_at = NOW()`) only if it belongs to a readable party; otherwise `404`.\\n\\nUnlike the delivery tables, these queries run through `withTenant` (tenant-scoped connection) and respond via the standard `ok` envelope.\\n\\n### Activity rail UI\\n\\n`frontend/src/components/shell/ActivityRail.tsx` is the right-hand activity surface in the shell. It consumes `DashboardSummary[\\\"activity\\\"]` as a prop \u2014 there is no second fetch.\\n\\n- **Pinned vs overlay**: when `pinDisabled` is false the rail is pinned and toggleable via `onPinnedChange`. On narrow layouts (`pinDisabled`), it opens as an overlay on click only (never hover), closes on Escape or click-outside.\\n- Shows up to 30 rows with a status dot, summary text, and relative timestamp (`formatRelative`). The collapsed seam shows a pulse and an unread-count badge (capped at `99+`).\\n- Loading state renders `SkeletonRows`; empty state renders `EmptyState`.\\n\\n### Notification routing and tone\\n\\n`frontend/src/lib/notification-href.ts` derives destinations and visual tone from a `NotificationRow`:\\n\\n- `hrefForNotification` \u2014 notification subjects are pipe-delimited (`Label|Entity|\u2026`); the second segment is used as a search term. `artifact.*`/expiry kinds route to `/trades?q=`, `request.*`/invite/onboarding to `/requests`, project/site to `/projects`, any entity-bearing row to `/trades?q=`, and the fallback is `/support` (deliberately not `/dashboard`, which redirect-loops trade users).\\n- `toneForNotification` \u2014 `danger` for expired/suspended, `warning` for expiring/action-required, `success` for accepted/verified/completed, otherwise `info`.\\n\\n## Cross-cutting concerns\\n\\n### Idempotency\\n\\n- **Email**: `sendEmail` claims an idempotency key before sending, completes it with the provider message id on success, and releases it on soft failure so retries can proceed. Replays return `skippedReason: \\\"idempotent_replay\\\"` with the original `providerMessageId`.\\n- **SMS**: idempotency is enforced at the database level via `idem_key_txt` unique constraint during staging. `stage*` helpers return `null` for duplicates so callers don't re-pump already-queued messages.\\n- **BullMQ retries** re-enter both pipelines through the same delivery row / staged SMS row, so attempt counts and provider SIDs accumulate correctly.\\n\\n### Security and privacy\\n\\n- Email webhook auth uses a timing-safe comparison of a query-param secret (minimum 32 characters).\\n- Twilio webhook auth uses the official `twilio.validateRequest` signature check.\\n- Raw email addresses are never persisted \u2014 only an HMAC-SHA256 recipient hash.\\n- SMS bodies refuse UUIDs, PAN-like numbers, and off-origin links; logs mask phone numbers and sanitize provider error messages.\\n- Non-prod SMS sends are blocked unless the recipient is explicitly allowlisted.\\n- Delivery webhooks only write to `email_delivery` / `t_sms` delivery fields \u2014 they cannot alter payment, identity, or verification outcomes.\\n\\n### Configuration reference\\n\\n| Config | Used for |\\n|---|---|\\n| `acsEmailSender`, `acsEndpoint`, `acsConnectionString` | ACS client construction (`DefaultAzureCredential` when an endpoint is set, connection string otherwise) |\\n| `acsEmailReplyTo` | Reply-to address on outbound mail |\\n| `acsEmailWebhookSecret` | Event Grid webhook auth |\\n| `sessionSecret` | HMAC key for recipient hashing |\\n| `sms.enabled`, `sms.accountSid`, `sms.authToken`, `sms.messagingServiceSid` | Twilio send path |\\n| `sms.statusCallbackUrl` | Twilio status callback registration and webhook URL reconstruction |\\n| `sms.sweepIntervalSeconds` | Sweep timer interval (0 disables) |\\n| `sms.maxAttempts` | Permanent failure threshold per SMS row |\\n| `sms.testRecipientAllowlist` | Allowlist enforcement (strict non-prod, optional prod) |\\n| `publicAppUrl`, `deployEnv` | SMS link origin validation and allowlist behavior |\\n\\n## Integration map\\n\\n| Caller | What it does |\\n|---|---|\\n| `registerJobHandlers` | Registers `email.send` via `registerEmailJob` before workers start |\\n| `otp-email.ts`, `email-change.ts` | Synchronous sends via `sendEmail` + `brandedEmailHtml` |\\n| `modules/auth/index.ts` | `queueEmail` + `welcomeEmailBodies` after native credential creation |\\n| `modules/memberships/index.ts` (`emailSeat`) | `queueEmail` + `inviteEmailBodies({ kind: \\\"seat\\\" })` |\\n| `modules/requests/index.ts` (`emailTradeInvite`) | `queueEmail` + `inviteEmailBodies({ kind: \\\"trade\\\" })` |\\n| `modules/payments/thank-you-email.ts` | `queueEmail` + `paymentThankYouEmailBodies` |\\n| `modules/payments/process-event.ts` | `stagePaymentSettledSms` on `settlePaid`, `stagePaymentFailedSms` on `settleFailed` |\\n| `modules/payments/checkout.ts` | `stagePaymentLinkSms` on checkout completion |\\n| `modules/identity/service.ts` | `stageIdentityOutcomeSms` on check result and stale-check sweep |\\n| `shared/services/verified-status.ts` | `stageVerificationCompletedSms` on entry to verified status |\\n| `server.ts` | Starts/stops the SMS sweep timer |\\n| `DashboardClient` \u2192 `ActivityRail` | Renders in-app notification activity from the dashboard summary |\",\"onboarding-invitations-requests-backend-src\":\"# Onboarding, Invitations &amp; Requests \u2014 backend-src\\n\\n# Onboarding, Invitations &amp; Requests\\n\\n## Overview\\n\\nThis module covers the full lifecycle of bringing a trade party into a principal contractor's network \u2014 from the moment a sender composes an invitation to the moment the invitee's onboarding is submitted and the request is marked complete. It is split across two Express routers plus one shared helper:\\n\\n| File | Responsibility |\\n|---|---|\\n| `modules/requests/index.ts` | **Sender side.** Create single or bulk project invitations, manage verification templates, resend / decline / revoke pending requests, list requests. Exports `requestsRouter`. |\\n| `modules/onboarding/index.ts` | **Recipient side.** Public token-keyed routes to inspect, accept, or connect an invitation; authenticated wizard routes for ABN verification, trade types, checklist, and final submit. Exports `onboardingRouter`. |\\n| `shared/services/invite-resend.ts` | Cooldown guard for the resend endpoint (`RESEND_COOLDOWN_MS`, `isInviteResendCoolingDown`). |\\n\\nBoth routers expose a `/health` probe and sit behind the shared response envelope helpers (`ok`, `created`) and the `h` async error wrapper from `shared/http`.\\n\\n---\\n\\n## Request lifecycle and domain model\\n\\nEvery invitation is a row in `t_request`, identified by a 32-byte random hex `token_txt` that acts as a one-time bearer credential. The status codes drive the whole flow:\\n\\n| Code | Status | Meaning |\\n|---|---|---|\\n| `U01` | `pending` | Invite created, email/SMS sent. Token is live. |\\n| `U02` | `in_progress` | Invitee accepted (new profile) or connected (existing profile). Token cleared. Wizard still running. |\\n| `U03` | `completed` | Checklist ready and submitted; or connect path found the profile already compliant. |\\n| `U04` | `expired` | Invite TTL (14 days) reached. Set lazily when the invite link is opened. |\\n| `U05` | `declined` | Sender declined/withdrew the pending request. |\\n\\nRequest kinds (`kind_cd`):\\n\\n| Kind | Code | Description |\\n|---|---|---|\\n| `direct_company` | `Q01` | Invite a company (person + org created on accept). |\\n| `direct_individual` | `Q02` | Invite a sole individual. |\\n| `sub_request` | `Q03` | A trade forwarding/issuing an invite further down the chain. |\\n| *(connection)* | `Q04` | **Not a real kind** \u2014 set at creation time when identity resolution finds the invitee already has an Assureworx profile. The recipient gets a one-step connect instead of onboarding wizard. |\\n\\nRelationship edges (`t_rel`) used in this module:\\n\\n| Code | Edge | Used for |\\n|---|---|---|\\n| `L01` | person \u2192 org | Employment link created on company invite accept. |\\n| `L02` | org \u2192 tier-1 | Network membership edge (company in principal's network). |\\n| `L03` | person \u2192 tier-1 | Direct engagement of an individual. |\\n| `L04` | person \u2192 origin | Subcontract record when a sub-request is accepted/connected. |\\n\\nVerification requirements are stored as `verification_keys` (JSONB) on the request, defaulting from the sender's `VerificationTemplate`. Project invites additionally materialise a `req_set` (\\\"Project onboarding requirements\\\") with `req_item` rows on the target node.\\n\\n---\\n\\n## Sender side: `requestsRouter`\\n\\nAll routes require `requireAuth` + `resolveTenantContext`. Role checks are done per-route.\\n\\n### Listing requests \u2014 `GET /`\\n\\nPagination via `parseListPage` (`page` \u2265 1, `pageSize` 1\u2013200, `offset` derived). Scoping depends on the caller's role:\\n\\n- **`tier1_admin` / `tier1_user`**: requests where the org is `origin_party_id` or `root_party_id`, queried under `withScope(ctx.scopeOrgId)`.\\n- **`trade_company_admin` / `trade_individual`**: requests where the org is `origin_party_id` or `target_party_id`. This uses `withBypass` \u2014 RLS would hide inbound requests because the trade org is neither origin nor root; the repository filter is the enforcement.\\n\\nRows are shaped by `toRow()`, which maps DB columns to the shared `RequestRow` type. Live token visibility is gated by `canSeeInviteToken()`: the token and `inviteUrl` are only returned when the caller is the origin org **and** has a privileged role (`tier1_admin`, `trade_company_admin`, `trade_individual`, `platform_admin`). A `tier1_user` caller (viewer) never sees tokens.\\n\\n### Verification template \u2014 `GET` / `PUT /verification-template`\\n\\nManages the org's default requirement set (`VerificationTemplate`), used when a request doesn't specify keys explicitly.\\n\\n- `canManageVerificationTemplate()` gates both routes to `tier1_admin`, `trade_company_admin`, `platform_admin`.\\n- `GET` returns `getVerificationTemplate(tx, ctx.orgId)` under the org scope.\\n- `PUT` accepts `{ requirementKeys, name? }`, calls `saveVerificationTemplate`, and audits `verification_template.updated`. Unknown requirement keys surface as `400 UNKNOWN_VERIFICATION_REQUIREMENT`.\\n\\n### Single invite \u2014 `POST /`\\n\\nBody (`createSchema`): `kind` (`direct_company` | `direct_individual` | `sub_request`), `email`, optional `name`, `abn`, `note`, `nodeId`, `phone`, `verificationRequirementKeys`.\\n\\n**Permission model:**\\n\\n- `direct_company` / `direct_individual`: `tier1_admin` only.\\n- `sub_request`: `tier1_admin` or `trade_company_admin`.\\n\\n**Sub-request chain resolution.** If the sender is not a `principal_contractor`, the handler resolves the chain with a bypass query *before* opening the scoped transaction: the root tier-1 via the org's `L02` edge, and the parent request (a completed `U03` request targeting the org). A missing `L02` edge yields `400 NO_NETWORK`. The resolved `rootPartyId` becomes the request's `root_party_id`, and `parent_request_id` links the new request.\\n\\n**Portability (ADR-0012).** `resolveIdentity({ email, abn })` is consulted first. If a profile exists:\\n\\n- `kind_cd` becomes `Q04` (connection request), `target_party_id` is stamped, and the invitee will see a connect prompt rather than a wizard.\\n- A live `L02`/`L03` edge to the root is treated as `400 ALREADY_IN_NETWORK`.\\n- The console message and audit record use `\\\"Connection request sent\\\"` rather than `\\\"Onboarding request sent\\\"`.\\n\\n**Duplicate guard.** A pending (`U01`) request from the same `origin_party_id` to the same (case-insensitive) email returns `400 ALREADY_INVITED`.\\n\\n**Verification keys.** Explicit keys are validated with `validateVerificationKeys`; otherwise the org's template is used. For `sub_request`, the keys are a **union** of the principal's template keys (the floor) and the sender's keys \u2014 a sub-request can never drop below the PC's requirements. Unknown keys map to `400 UNKNOWN_VERIFICATION_REQUIREMENT`.\\n\\n**Insert &amp; notify.** The request row is inserted with a fresh `token` and `expires_at = NOW() + 14 days`. A `request.sent` message is written to the sender's party, and an audit event is recorded. After the transaction commits:\\n\\n- `emailTradeInvite` queues email via `queueEmail`, keyed with `emailInviteIdemKey(\\\"request\\\", requestId, token)` so a rotated token produces a fresh email.\\n- `smsTradeInvite` stages and pumps an SMS **best-effort** \u2014 any failure is logged, never propagated.\\n\\n`created(res, toRow(row, { emailSent }))` returns the shaped row including the invite URL (`config.publicAppUrl + /verify/i/:token`).\\n\\n### Project invite composer \u2014 `GET /project-invite-options`\\n\\nRestricted to `tier1_admin` / `tier1_user`. Returns the org's verification catalogue + default keys and the global trade classification list (`loadTradeClassifications`), i.e. everything the bulk composer UI needs.\\n\\n### Project invite \u2014 `POST /project-invite`\\n\\nRestricted to `tier1_admin`. Body (`projectInviteSchema`): `nodeId`, up to 50 `recipients` (email, optional name/abn/partyId/phone), `companyRequirementKeys` (min 1), and optional `tradeBundles` mapping `tagCode` \u2192 `artifactClassCodes`.\\n\\n**Validation passes:**\\n\\n1. `companyRequirementKeys` validated against the verification catalogue.\\n2. All artifact class codes across bundles checked against active `artifact_class` rows \u2192 `400 UNKNOWN_ARTIFACT_CLASS`.\\n3. All tag codes checked against global active tags \u2192 `400 UNKNOWN_TRADE_TYPE`.\\n\\n**Requirement set.** Inside the scoped transaction, the handler upserts one live `req_set` named `\\\"Project onboarding requirements\\\"` for the node. Existing `req_item` rows are soft-deleted, then recreated:\\n\\n- Company-level items (`kind X01`, `block B01`, `applies_tag_cd NULL`) for every `artifact:` key in `companyRequirementKeys`.\\n- Trade-scoped items (`applies_tag_cd = tagCode`) for each bundle.\\n\\nAudit `project_invite.requirements_set` records the `reqSetId`, keys, and bundles.\\n\\n**Per-recipient loop.** Each recipient follows the same creation logic as the single invite (identity resolution \u2192 `Q04` or `direct_company`, `ALREADY_IN_NETWORK` / `ALREADY_INVITED` guards, token, insert, audit, email + SMS). Failures are caught per-recipient and collected in the `results` array \u2014 one bad address doesn't roll back the whole batch. The response is `CreateProjectInviteResult`: `{ nodeId, reqSetId, sent, failed, results }`.\\n\\n### Resend \u2014 `POST /:id/resend`\\n\\n- Loads the request via `loadRequestForSender` (must be origin org, else 404).\\n- Only `U01` can be re-sent (`400 NOT_PENDING` otherwise).\\n- `isInviteResendCoolingDown(existing.sent_at)` blocks rapid double-clicks for 60 seconds (`400 RESEND_COOLDOWN`) **without** minting a new token \u2014 the existing link is still valid.\\n- Otherwise: new `token_txt`, expiry reset to +14 days, `sent_at = NOW()`, audit `request.resent`, then email + SMS re-sent.\\n\\n### Decline \u2014 `POST /:id/decline`\\n\\nSender-side action: pending only (`400 NOT_PENDING`), transitions to `U05`, audits `request.declined`. Note this is distinct from the recipient ignoring the invite \u2014 the recipient has no authenticated route here; their path is accept/connect via the public token routes.\\n\\n### Revoke \u2014 `DELETE /:id`\\n\\nSoft-deletes the request row (`deleted_at = NOW()`) after ownership verification, audits `request.revoked`, returns `{ revoked: true }`.\\n\\n---\\n\\n## Recipient side: `onboardingRouter`\\n\\nThe router has two distinct sections:\\n\\n1. **Public, pre-auth routes** keyed by the invite token (`GET /invite/:token`, `POST /invite/:token/accept`, `POST /invite/:token/connect`) \u2014 the invitee has no account yet. These use `withBypass` and the shared `inviteLimiter`.\\n2. **Authenticated wizard routes**, mounted after `onboardingRouter.use(requireAuth, resolveTenantContext)`: `POST /abn`, `POST /identity`, `GET|PUT /trade-types`, `GET /checklist`, `POST /submit`.\\n\\n### Invite context \u2014 `GET /invite/:token`\\n\\n`loadInvite` selects the pending (`U01`) request by token, joining the sender and root party names. If the token is unknown \u2192 `404`; if expired, the handler first transitions it to `U04` (auditing `request.expired`) and then returns `404` with a re-send hint.\\n\\nThe response is an `OnboardingContext`:\\n\\n- Request metadata (`kind`, `status`, sender/root org names, target email/name, `expiresAt`).\\n- `verificationRequirements`: the verification catalogue filtered to the request's keys.\\n- `existingProfile`: populated via `resolveInviteIdentity` \u2014 the identity resolution ladder (stamped `target_party_id` first, then email). Includes `credentialPending` from `credentialIsPending`, i.e. whether the party has a real (non-`pending|`) actor yet.\\n\\n### Accept (new identity) \u2014 `POST /invite/:token/accept`\\n\\nBody (`acceptSchema`): `firstName`, `lastName`, optional `companyName`, `phone`, `suburb`, `postcode`, `region`. Postcode is validated as 4 digits.\\n\\n**Concurrency:** the request row is selected `FOR UPDATE` inside the bypass transaction, serialising concurrent accepts. Already-actioned \u2192 `409 ALREADY_ACCEPTED`; expired \u2192 `400 EXPIRED` (with lazy `U04` transition). `token_txt` is the one-time secret \u2014 it is `NULL`ed on success.\\n\\n**Known identity guard:** if `resolveInviteIdentity` finds an existing profile, the handler refuses to create a duplicate \u2014 `409 PROFILE_EXISTS` with the existing `publicRef`, directing the client to the connect path.\\n\\n**Party creation, by kind:**\\n\\n- `direct_company`: person party (`K03`) with class `M01`, plus an org party (`K02`/`E01`) from `companyName`; `L01` person\u2192org employment edge; `L02` org\u2192root network edge.\\n- `direct_individual` / `sub_request`: person party class `M02`; `L03` person\u2192root edge; plus `L04` person\u2192origin edge for sub-requests where origin \u2260 root.\\n\\nAll parties get an email identifier (`D03` in `t_party_ident`) and an actor with subject `actorSubjectForEmail(email)` (the `pending|\u2026` form when native auth/Entra flows are in use).\\n\\n**Grants &amp; billing:** the grant is `R04` on the org (when created) or `R05` on the person; `invalidateMembershipCache(actorId)` is called so the new membership is visible immediately. A `t_ledger` row starts the free `PL1` plan (`LS1`, 1 year). The request is moved to `U02` with `target_party_id` set. Three audit events are written: `request.accepted`, `role.granted`, `subscription.started`.\\n\\n**Response split by auth mode:**\\n\\n- Entra enabled \u2192 `created(res, credentialSetupPayload(email, \\\"/onboarding/abn\\\"))` \u2014 the invitee continues in the hosted Entra flow, then lands in the wizard.\\n- Native auth \u2192 an `issueSessionCookie` session is established and `buildMe(actorId, activeOrgId)` is returned directly.\\n\\n### Connect (existing profile) \u2014 `POST /invite/:token/connect`\\n\\nThe portability path (ADR-0012): **no new party, no new actor, no new subscription, no wizard.** Holding the token (emailed to the invited address) plus pressing connect is the consent signal, and it is audited.\\n\\nThe request row is locked and status/expiry checked exactly as in accept. `resolveInviteIdentity` must find a match, else `400 NO_PROFILE` (\\\"Complete onboarding instead\\\").\\n\\n**Connecting edge** \u2014 `resolveConnectSubject` decides *which party* joins the network and via which edge:\\n\\n- Matched identity is a `trade_company` \u2192 the company itself, `L02` to root.\\n- `direct_company` invite but the match is an individual \u2192 their employer (first active `L01`), `L02` to root.\\n- Otherwise \u2192 the person, `L03` to root.\\n\\nThe edge is inserted only if not already live (`RS1`, not deleted). Sub-requests additionally record the `L04` subcontract to the origin party.\\n\\n**Checklist &amp; next path.** `buildChecklist` runs against the invite's `verification_keys`, with `identityPartyId` set only when the match is an individual \u2014 company-target invites leave it null so compliance aggregates the company's `R04` admins. The request moves to `U03` if `readyToSubmit`, otherwise `U02`. `nextPath` is computed from the core check states:\\n\\n1. `abn` required and not passed \u2192 `/verify/details`\\n2. `identity` required and not passed \u2192 `/verify/scan`\\n3. `trade_types` required but no `party_tag` rows \u2192 `/onboarding/trade-types`\\n4. required evidence missing \u2192 `/onboarding/documents`\\n5. otherwise \u2192 `/onboarding/review`\\n\\nA `request.connected` message is inserted for the root party, an audit event records the match (`publicRef`, `matchedOn`, `relKind`, `alreadyInNetwork`, `completed`), and the response carries `publicRef`, `name`, `status`, `completed`, `nextPath`, and `rootOrgName`.\\n\\n### Wizard routes\\n\\nAll authenticated wizard routes call `myPartyId(req)`, which requires `trade_company_admin` or `trade_individual` (403 otherwise) and returns `ctx.orgId` \u2014 the \\\"my onboarding party\\\".\\n\\n#### `POST /abn`\\n\\nVerifies an ABN via `abnVerifier.verify` (shared mock/ABR verifier used by both modules). `ctx.scopeOrgId` is the transaction scope.\\n\\n- **Invalid / ABR miss:** a `C01` check with status `H03` is recorded (result JSON includes `abnStatus`/message), audit `abn.verified` with `outcome: \\\"failed\\\"`, status recomputed via `evaluateAndPersistStatus`. The ABN is deliberately **not** attached to the profile.\\n- **Valid:** an advisory lock (`advisoryXactLock(tx, 'ident:D01:${abn}')`) serialises concurrent claims. A global clash read runs with `app.bypass` on, because the unique index on `(kind_cd, value_txt)` is global while RLS is scoped \u2014 a live holder outside the current scope must not be missed. Clash \u2192 `409 ABN_IN_USE`. Otherwise previous `D01` identifiers are soft-deleted and the new one inserted, with the `ux_party_ident_live` unique-violation caught as defence in depth.\\n- Check status is `H02` when the ABN is active, `H03` otherwise (Cancelled/inactive lookups are still recorded as `D01`, but contribute to a non-compliant status).\\n- If the ABR entity name is known and the org still has a placeholder display name (`''`, `New %`, or `%(pending)%`), it adopts the registered name.\\n- `evaluateAndPersistStatus` runs at the end of both branches and audits `abn.verified` with the outcome.\\n\\n#### `POST /identity` \u2014 removed\\n\\nDeliberately returns `410 MOVED_TO_IDENTITY_MODULE` (ADR-0014). There is no mock identity check \u2014 the comment in the code is explicit: a mock identity pass displayed to a principal contractor as a real check is unacceptable. Point the backend at the Didit sandbox and use `/api/v1/identity`.\\n\\n#### `GET /trade-types`\\n\\nReturns the global trade classification catalogue (`owner_party_id IS NULL`, active tags), each with its `requiredDocuments` from `t_tag_class`, using `TAG_NAMES` and `ARTIFACT_CLASS_NAMES` for labels.\\n\\n#### `PUT /trade-types`\\n\\nBody: `{ tagCodes: string[] }` (max 17, each matching `G\\\\d{2}`). All codes must exist in the global active tag set (`400 UNKNOWN_TAG` otherwise). Under `withTenant`, diffs the party's current tags against the wanted set: soft-deletes removed, inserts new. Returns the accepted `tagCodes`.\\n\\n#### `GET /checklist`\\n\\nLoads the party's active invitation requirement keys via `activeInvitationRequirementKeys` (bypass \u2014 invitations target the party but originate elsewhere), then builds the full `Checklist` under tenant scope with `identityPartyId: ctx.personPartyId`. 404 if no checklist can be built.\\n\\n#### `POST /submit`\\n\\nThe final step. Under bypass:\\n\\n1. Load active invitation requirement keys and build the checklist; if not `readyToSubmit` \u2192 `400 INVITATION_REQUIREMENTS_INCOMPLETE`.\\n2. `evaluateAndPersistStatus` recomputes portable readiness.\\n3. All `U02` requests targeting this party are moved to `U03` with `completed_at = NOW()`, each audited as `request.completed`.\\n4. A `onboarding.completed` message is inserted for each distinct root party.\\n5. Audit `onboarding.submitted`.\\n\\nThe response re-builds the checklist (no requirement keys) under tenant scope so the client gets the final state.\\n\\n---\\n\\n## Lifecycle diagram\\n\\n```mermaid\\nstateDiagram-v2\\n    [*] --&gt; U01_Pending[\\\"U01_Pending:\\\"] invite created, email/SMS sent\\n    U01_Pending --&gt; U02_InProgress[\\\"U02_InProgress:\\\"] accept (new profile) or connect (existing profile)\\n    U01_Pending --&gt; U04_Expired[\\\"U04_Expired:\\\"] 14-day TTL reached\\n    U01_Pending --&gt; U05_Declined[\\\"U05_Declined:\\\"] sender declines\\n    U02_InProgress --&gt; U03_Completed[\\\"U03_Completed:\\\"] checklist ready \u2192 POST /onboarding/submit\\n    U01_Pending --&gt; U03_Completed[\\\"U03_Completed:\\\"] connect with already-complete checklist\\n    U04_Expired --&gt; [*]\\n    U05_Declined --&gt; [*]\\n    U03_Completed --&gt; [*]\\n```\\n\\n---\\n\\n## Integrity, concurrency &amp; security notes\\n\\n- **One-time tokens.** `token_txt` is the bearer secret for the public routes. It is cleared on accept/connect, and only `U01` requests can be actioned, so a captured token cannot be replayed.\\n- **Row locking.** Both accept and connect select the request `FOR UPDATE`, serialising concurrent actions transactionally.\\n- **RLS/bypass discipline.** Public invite routes run `withBypass` because no tenant context exists yet. Specific bypass reads (trade request listing, ABN clash detection, invitation requirement keys, sub-request chain resolution) are deliberate: RLS would hide rows the repository filter needs, and the repository query is the actual enforcement.\\n- **Rate limiting.** `inviteLimiter` guards public invite reads, accept, connect, and single/project invite creation; `abrLookupLimiter` guards `/abn-lookup`; `inviteResendLimiter` guards resend.\\n- **Resend cooldown.** `RESEND_COOLDOWN_MS = 60_000` prevents token-rotation spam; the guard returns `RESEND_COOLDOWN` without invalidating the current link.\\n- **No mock identity.** The removed `/identity` endpoint enforces the build brief's \u00a77 constraint at the API layer itself.\\n- **Idempotent notifications.** Email queuing uses `emailInviteIdemKey(\\\"request\\\", requestId, token)` so retries don't double-send; SMS is strictly best-effort.\\n\\n---\\n\\n## Integration points\\n\\n| Dependency | Usage |\\n|---|---|\\n| `shared/db/pool` | `query`, `withBypass`, `withScope`, `withTenant`, `advisoryXactLock` |\\n| `shared/codes` | `REQUEST_KIND`, `REQUEST_KIND_CD`, `REQUEST_STATUS`, `TAG_NAMES`, `ARTIFACT_CLASS_NAMES` |\\n| `shared/services/identity` | `resolveIdentity` \u2014 the portability ladder behind `Q04` and the connect path |\\n| `shared/services/verification-template` | `getVerificationTemplate`, `saveVerificationTemplate`, `validateVerificationKeys`, `activeInvitationRequirementKeys`, `verificationCatalogue` |\\n| `shared/services/checklist` | `buildChecklist` \u2014 drives `nextPath`, `readyToSubmit`, and submit gating |\\n| `shared/services/verified-status` | `evaluateAndPersistStatus` \u2014 recomputes portable readiness after ABN/trade-type/submit changes |\\n| `shared/services/abn-verifier` | Shared ABN verification for `/abn` and `/abn-lookup` |\\n| `shared/services/email`, `shared/services/sms/*` | `queueEmail`/`inviteEmailBodies`, `stageInviteSms`/`pumpSms`/`normalisePhoneToE164` |\\n| `shared/services/audit` | All state transitions audited with verbs like `request.sent`, `request.accepted`, `request.connected`, `request.completed`, `abn.verified` |\\n| `shared/auth/*` | `actorSubjectForEmail`, `entraEnabled`, `credentialSetupPayload`, `issueSessionCookie`, `buildMe` |\\n| `shared/middleware/*` | `requireAuth`, `resolveTenantContext`, `invalidateMembershipCache`, rate limiters |\\n| `shared/config` | `publicAppUrl` for building `/verify/i/:token` invite URLs |\\n\\nThe invite URL always points at the mobile verification journey (`INVITE_PATH = \\\"/verify/i\\\"`) \u2014 invitations are read on a phone, and the identity check needs a camera. That journey hands off to `/onboarding/trade-types` once identity and ABN are done, so the full evidence set is still collected through the wizard routes documented above.\",\"onboarding-invitations-requests-frontend-src\":\"# Onboarding, Invitations &amp; Requests \u2014 frontend-src\\n\\n# Onboarding, Invitations &amp; Requests \u2014 Frontend Module\\n\\n## Overview\\n\\nThis module implements the two halves of the onboarding lifecycle in the Assureworx frontend:\\n\\n1. **Request management** \u2014 organisation admins send, track, resend, revoke, and bulk-manage onboarding invitations from the Requests register at `/requests`.\\n2. **Invitation &amp; onboarding** \u2014 the invitee lands on a token-scoped invitation page, either connects an existing Assureworx profile in one click or accepts the invite, then completes a multi-step verification wizard: ABN \u2192 Identity \u2192 Trade types \u2192 Documents \u2192 Review.\\n\\nA shared infrastructure layer (`usePersistentDraft`, `useOnboardingDraftOwner`, `describeOnboardingError`) serves both halves with device-local draft persistence and consistent, action-oriented error messages.\\n\\n## Route map\\n\\n| Route | Client component | Purpose |\\n|---|---|---|\\n| `/requests` | `RequestsClient` | Requests register (admin-facing) |\\n| `/onboarding/invite/[token]` | `OnboardingInviteClient` | Token-scoped invitation landing page |\\n| `/onboarding/abn` | `AbnClient` | ABN verification against the ABR |\\n| `/onboarding/identity` | `IdentityClient` | Identity check consent + hand-off to external provider |\\n| `/onboarding/trade-types` | `TradeTypesClient` | Trade classification selection |\\n| `/onboarding/documents` | `DocumentsClient` (referenced; not in this module's source) | Document upload step |\\n| `/onboarding/review` | `ReviewClient` | Review checklist, submit, post-submit routing |\\n| \u2014 (shared chrome) | `WizardShell` | Progress bar, step navigation, sticky footer for all wizard steps |\\n\\n## Architecture\\n\\nThe two audiences \u2014 the admin sending requests and the invitee completing them \u2014 are connected by the invite token. The admin's selected verification checklist becomes the invitee's wizard step list.\\n\\n```mermaid\\nflowchart LR\\n    RC[\\\"RequestsClient(/requests)\\\"] --&gt;|\\\"POST /requests \u2192 invite email with token\\\"| OIC[\\\"OnboardingInviteClient(/onboarding/invite/[token])\\\"]\\n    OIC --&gt;|\\\"existing profile\\\"| CON[\\\"POST /connect \u2192 /profile?connected=1\\\"]\\n    OIC --&gt;|\\\"accept + start\\\"| WS[\\\"WizardShell\\\"]\\n    WS --&gt; ABN[\\\"AbnClient\\\"]\\n    ABN --&gt; ID[\\\"IdentityClient\\\"]\\n    ID --&gt; TT[\\\"TradeTypesClient\\\"]\\n    TT --&gt; DOC[\\\"DocumentsClient\\\"]\\n    DOC --&gt; RV[\\\"ReviewClient\\\"]\\n```\\n\\n## Requests register (`/requests`)\\n\\n### Data loading\\n\\n`page.tsx` is a server component that prefetches `GET /requests` via `loadAppPageOptional` and passes the result to `RequestsClient` as `initialRequests`. The client still runs `useApi(\\\"/requests\\\", { initialData: ... })`, so server data is progressive enhancement, not the source of truth. The client additionally fetches `GET /requests/verification-template` only when the create panel is available.\\n\\n`RequestsContent` is wrapped in `` inside `RequestsClient` because it calls `useSearchParams()` (for the `?new=1` deep link that opens the create form).\\n\\n### Role and permission gating\\n\\n- `canSendRequests(me)` \u2014 admin-level ability to send requests. Without it, the table renders without checkboxes, row actions, or the create button.\\n- `isPrincipal` \u2014 read-only \\\"Outstanding requests\\\" view. The create form is hidden, the status filter defaults to `pending`, and the empty state links to `/projects`.\\n- `isTradeCompanyAdmin` \u2014 the form is fixed to `kind: \\\"sub_request\\\"`; the request-type selector is hidden.\\n- `showCreate = canSend &amp;&amp; !isPrincipal` \u2014 the \\\"New request\\\" button and create panel render only for this group.\\n\\nIf the panel is opened but the session lacks `canSend`, an error strip explains that an admin at the organisation must send the invite.\\n\\n### Create/invite form\\n\\nState is a single `CreateRequestBody`:\\n\\n```ts\\n{ kind: \\\"direct_company\\\" | \\\"direct_individual\\\" | \\\"sub_request\\\", email, name, abn, phone }\\n```\\n\\nTwo lookups fire contextually:\\n\\n- **Directory lookup** (`checkDirectory`) \u2014 on email blur, if the trimmed term is \u2265 3 characters, `GET /directory/lookup?term=`. The result renders a banner with three outcomes:\\n  - `inNetwork` \u2192 already in the sender's network; submit is disabled.\\n  - `requestPending` \u2192 an outstanding request already exists.\\n  - otherwise \u2192 the person already has an Assureworx profile; the submit button becomes **Send connection request** so they connect rather than re-onboard.\\n- **ABN lookup** (`lookupAbn`) \u2014 only for `direct_company` invites, on blur when the input has exactly 11 digits. `POST /requests/abn-lookup` validates against the ABR and pre-fills `form.name` with the registered entity name if the name field is empty.\\n\\n### Verification template\\n\\nThe form renders a checkbox catalogue of verification requirements grouped by category (`profile`, `business`, `safety`, `insurance`, `licences`, `qualifications`) from `GET /requests/verification-template`.\\n\\nKey behaviours:\\n\\n- The fetched default selection is applied once (`templateInitialised` ref) and never clobbers the user's in-progress toggles.\\n- All toggled keys are sent with the request as `verificationRequirementKeys` \u2014 the selection is copied onto the invite at send time.\\n- **Save as organisation default** sends `PUT /requests/verification-template`; it is disabled until `templateDirty` is set by a toggle.\\n\\n### Submitting\\n\\n`submit()` posts to `POST /requests`, normalising fields (ABN digits only, trimmed email/name/phone), then:\\n\\n- toasts with the email outcome (`emailSent` from the response)\\n- stores the created row in `lastSent`, which renders the share strip with the invite URL\\n- resets the form (preserving `kind`), closes the panel, and refreshes the list\\n\\n### Table, sorting, filtering\\n\\n`rows` is derived with `useMemo`:\\n\\n- filtered by `statusFilter` (all / `pending` / `accepted` / `completed` / `expired` / `declined`)\\n- sorted by `recipient` (target name/email localeCompare), `status` (fixed `STATUS_ORDER`), or `sent` (`sentAt` localeCompare), with asc/desc toggle\\n\\n### Selection and bulk revoke\\n\\nOnly pending rows expose checkboxes. The header checkbox selects exactly the pending rows visible under the current filter. Bulk revoke issues `DELETE /requests/:id` for each selected pending ID via `Promise.allSettled`, then reports partial failure as `Revoked X | Y failed` \u2014 one failed revoke does not abort the rest.\\n\\n### Row actions\\n\\nPending rows (for senders) have a **Manage** button that opens an inline detail row:\\n\\n- **Copy link** \u2014 copies `${window.location.origin}/onboarding/invite/${token}` \u2014 the same URL pattern the API emails.\\n- **Re-send** \u2014 `POST /requests/:id/resend`; the server rotates the token, and the client refreshes `lastSent` so the new link is shareable.\\n- **Revoke** \u2014 two-step inline confirm, then `DELETE /requests/:id`.\\n\\nThe share strip after a send uses `navigator.share` when available and falls back to `navigator.clipboard.writeText`.\\n\\n## Invitation acceptance (`/onboarding/invite/[token]`)\\n\\n`OnboardingInviteClient` loads the request context once on mount:\\n\\n```\\nGET /onboarding/invite/:token\\n```\\n\\nThe token is awaited from `params` in the server page (Next.js 15 async params) and passed as a prop.\\n\\nThe component branches on context:\\n\\n- **Load error** \u2014 a 404 renders \\\"This invitation link is no longer valid. Ask the sender to re-send it.\\\" Other failures show a connection message.\\n- **Closed** \u2014 status is not `pending` or `accepted`; the invite is expired/declined/completed and a terminal state renders.\\n- **Existing profile** \u2014 `context.existingProfile` is set. The page shows the profile summary (`PartyIdentity`, `publicRef`, `verifiedChip`) and a single **Connect my profile** action. `POST /onboarding/invite/:token/connect` links the profile to the requesting organisation, then redirects to `/profile?connected=1`. No re-verification or re-upload.\\n- **New user** \u2014 a form collects first/last name (required), company name (only for `kind: \\\"direct_company\\\"`), mobile, suburb, postcode (either suburb or postcode required; postcode must be 4 digits), and state.\\n\\nThe form is backed by a device-local draft keyed by the token:\\n\\n```\\nawx.draft.onboarding.invite.${token}\\n```\\n\\nAcceptance posts to `POST /onboarding/invite/:token/accept`. If the response satisfies `isRequireCredentialSetup(result)`, the client calls `startCredentialSetup(...)` to hand off to the credential-setup flow with the collected identity and `returnTo` of `/onboarding/abn`. Otherwise it routes directly to `/onboarding/abn`. The draft is cleared on success.\\n\\n## Onboarding wizard\\n\\nStep pages are thin server wrappers (`export default function AbnStepPage() { return ; }`); all logic lives in client components under `WizardShell`.\\n\\n### WizardShell\\n\\nProvides the mobile-first onboarding chrome: logo band, step counter, progress bar, scrollable body, and a sticky footer holding the primary CTA and Back button.\\n\\nSteps are defined in `WIZARD_STEPS`:\\n\\n```ts\\n{ key: \\\"abn\\\", label: \\\"Business\\\", href: \\\"/onboarding/abn\\\" },\\n{ key: \\\"identity\\\", label: \\\"Identity\\\", href: \\\"/onboarding/identity\\\" },\\n{ key: \\\"trade-types\\\", label: \\\"Trade types\\\", href: \\\"/onboarding/trade-types\\\" },\\n{ key: \\\"documents\\\", label: \\\"Documents\\\", href: \\\"/onboarding/documents\\\" },\\n{ key: \\\"review\\\", label: \\\"Review\\\", href: \\\"/onboarding/review\\\" },\\n```\\n\\n**Invitation-scoped step filtering.** In invited mode, the session from `loadSession()` carries `verificationRequirementKeys` (the sender's selected checklist). On mount `WizardShell` filters the visible steps:\\n\\n- `abn` only if keys include `abn`\\n- `identity` only if keys include `identity`\\n- `trade-types` only if keys include `trade_types`\\n- `documents` only if any key starts with `artifact:`\\n- `review` always visible\\n\\nThis is how the requesting organisation's checklist becomes the invitee's wizard: the header shows `Step X of Y` where `Y` is the filtered count.\\n\\nNavigation details:\\n\\n- Completed steps render as links; upcoming steps are static.\\n- Back routes to the previous *visible* step.\\n- `confirmLeave` (used by the documents step) shows a native confirm before navigating away when in-memory work is not restorable.\\n- The `h1` is focus-managed on step/title change for screen-reader users.\\n\\n### ABN step (`AbnClient`)\\n\\n`POST /onboarding/abn` with the 11-digit ABN. Results:\\n\\n- valid + active \u2192 success card with entity name, entity type, GST registration\\n- valid + not active \u2192 warning card: the number is kept on the profile, but compliance verification needs an Active ABN\\n- invalid \u2192 inline error via `describeOnboardingError`\\n\\nThe footer CTA is disabled until `result?.valid`. The draft key is `awx.draft.onboarding.abn.${owner}` where owner comes from `useOnboardingDraftOwner()`; it is cleared when verification succeeds.\\n\\n### Identity step (`IdentityClient`)\\n\\nManages the external ID provider hand-off. On mount it loads `identityApi.me()` (`IdentitySummary`) and `identityApi.consentText()` in parallel.\\n\\nThree states:\\n\\n1. **Consent required** \u2014 renders consent text and a checkbox. \\\"Agree and continue\\\" calls `identityApi.grantConsent()`.\\n2. **Consent granted** \u2014 shows the three-step explanation (photo ID, face photo, return), a blocked notice if `summary.blockedReason` is set, and **Start ID check**. `identityApi.start()` returns a `redirectUrl` and the client leaves the SPA via `window.location.assign(redirectUrl)`. Blocked reasons (`consent_required`, `already_current`, `capacity`, `unavailable`) each have tailored copy.\\n3. **Done** \u2014 `summary.status` is `current` or `expiring_soon`; footer becomes **Continue to trade types**.\\n\\nReturning from the external provider is reconciled automatically: on mount, if `status === \\\"in_progress\\\"` and the `returned` ref is unset, `identityApi.reconcile()` runs and updates the summary so the page flips to done without a manual refresh.\\n\\n### Trade types step (`TradeTypesClient`)\\n\\n`GET /onboarding/trade-types` loads the classification list. Tiles toggle `tagCode`s into a `Set`, persisted as `awx.draft.onboarding.trade-types.${owner}`. A live aside aggregates required documents across the selected trades; in invitation-scoped mode it instead shows \\\"Documents in this request\\\" and notes that the sender's selected evidence applies.\\n\\nSave sends `PUT /onboarding/trade-types` with `{ tagCodes }`, then routes via `nextInvitationPath(session, \\\"trade_types\\\")` when the session is `invited` or `account` (which may skip straight to review) or to `/onboarding/documents` otherwise.\\n\\n### Review step (`ReviewClient`)\\n\\n`GET /onboarding/checklist` returns a `RequirementChecklist` with `coreChecks`, `evidence`, and `readyToSubmit`. The page renders core checks (filtered to non-`not_required`) and a document list with evidence status chips. The footer is disabled until `readyToSubmit`.\\n\\nSubmit posts to `POST /onboarding/submit`, then calls `flushPendingStaffInvites()` to send any queued staff invites; the result becomes a note on the success screen. The submitted screen offers **Pay for your verification** (`/billing`) and **Go to Team** (`/team`), each preceded by `clearSession()`.\\n\\n## Shared infrastructure\\n\\n### Draft persistence (`usePersistentDraft`)\\n\\n```ts\\nconst { value, setValue, hydrated, restored, savedAt, hasDraft, clearDraft } =\\n  usePersistentDraft(key, initialValue, isEmpty);\\n```\\n\\nStores incomplete form data in `localStorage` under a versioned envelope `{ version: 1, updatedAt, value }`, debounced 250 ms. Empty values remove the key so `hasDraft` stays false.\\n\\nBehaviours worth knowing:\\n\\n- **Hydration is async.** Writes are suppressed until the storage read completes, so a stale restore never clobbers an in-flight keystroke.\\n- **Key changes.** When the key changes, the current in-memory value is preserved only if the previous key was `null` (e.g. draft owner still resolving from `/auth/me`); otherwise the value resets to `initialValue`.\\n- **`clearDraft(resetValue = true)`** removes the storage key and optionally resets the form. Successful submissions should call `clearDraft(false)` when the component navigates away immediately.\\n- Storage failures are swallowed \u2014 the form remains usable in memory.\\n\\n`useOnboardingDraftOwner()` fetches `GET /auth/me` once and returns `userId`, so wizard drafts are scoped per authenticated user. On failure it returns `null` and the draft key becomes `null` (in-memory only).\\n\\n`DraftStatus` is the UI companion: shows restored state, last-saved time, and a discard action.\\n\\n### Error copy (`describeOnboardingError`)\\n\\n```ts\\ndescribeOnboardingError(err, action) =&gt; string\\n```\\n\\nMaps `ApiClientError` to actionable copy:\\n\\n- network drop / `status 0` / `NETWORK_ERROR` \u2192 offline message\\n- 429 / `RATE_LIMITED` \u2192 wait a minute\\n- 502 / 503 / `SERVICE_UNAVAILABLE` \u2192 service temporarily unavailable\\n- \u2265 500 \u2192 server error + contact support\\n- 401 / 403 \u2192 session expired\\n- otherwise \u2192 `err.message` or generic fallback\\n\\nThe `action` string (\\\"verify your ABN\\\", \\\"load your identity check\\\", \\\"submit your profile\\\") is interpolated into network and 5xx messages so each step gives the user a concrete next step.\\n\\n## API surface\\n\\n| Method &amp; path | Used by | Purpose |\\n|---|---|---|\\n| `GET /requests` | `RequestsClient` | List request rows (also prefetched by the server page) |\\n| `POST /requests` | `RequestsClient` | Create and send an invite / sub-request |\\n| `GET /requests/verification-template` | `RequestsClient` | Verification catalogue + organisation default |\\n| `PUT /requests/verification-template` | `RequestsClient` | Save the organisation's default verification selection |\\n| `POST /requests/abn-lookup` | `RequestsClient` | ABR lookup in the invite form |\\n| `POST /requests/:id/resend` | `RequestsClient` | Rotate token and re-email |\\n| `DELETE /requests/:id` | `RequestsClient` | Revoke a request |\\n| `GET /directory/lookup?term=` | `RequestsClient` | Check whether an email / AWX ID already exists |\\n| `GET /onboarding/invite/:token` | `OnboardingInviteClient` | Load invitation context |\\n| `POST /onboarding/invite/:token/accept` | `OnboardingInviteClient` | Accept invite; create onboarding session |\\n| `POST /onboarding/invite/:token/connect` | `OnboardingInviteClient` | Link an existing profile to the requester |\\n| `POST /onboarding/abn` | `AbnClient` | Verify ABN against the ABR |\\n| `GET /onboarding/trade-types` | `TradeTypesClient` | Trade classification options |\\n| `PUT /onboarding/trade-types` | `TradeTypesClient` | Save trade selection |\\n| `GET /onboarding/checklist` | `ReviewClient` | Review checklist + readiness flag |\\n| `POST /onboarding/submit` | `ReviewClient` | Submit profile for verification |\\n| `GET /auth/me` | `useOnboardingDraftOwner` | Resolve the draft owner ID |\\n| identity API (`me`, `consentText`, `grantConsent`, `start`, `reconcile`) | `IdentityClient` | Identity check lifecycle |\\n\\n## Key invariants\\n\\n- **A request is a token until accepted.** The invite URL is `${origin}/onboarding/invite/${token}`; resend rotates the token, so old links stop working.\\n- **The sender chooses the verification checklist at send time.** The invitee's wizard steps are filtered from the request's `verificationRequirementKeys`, and the review checklist is server-derived from the accepted request.\\n- **Existing profiles never re-onboard.** Both the sender-side directory lookup and the invitee-side context detect existing profiles, collapsing the flow to a one-click connect that shares current compliance status with the requesting organisation.\\n- **Drafts are device-local, versioned, and scoped** \u2014 per user for wizard steps, per token for the invite form. They are never stored server-side or shared across users.\\n- **Session mode drives routing.** Invited/account sessions follow `nextInvitationPath`, which can skip steps the requester did not ask for; organic onboarding walks the full wizard.\",\"onboarding-invitations-requests\":\"# Onboarding, Invitations &amp; Requests\\n\\n# Onboarding, Invitations &amp; Requests\\n\\nThis module covers the full lifecycle of bringing a trade party into a principal contractor's network \u2014 from the moment a sender composes an invitation to the moment the invitee's onboarding is submitted and the request is marked complete. It is implemented as a backend pair of Express routers and a frontend that mirrors the same two halves: an admin-facing Requests register and an invitee-facing invitation wizard.\\n\\n## Sub-modules\\n\\n| Sub-module | Responsibility |\\n|---|---|\\n| [Onboarding, Invitations &amp; Requests \u2014 backend-src](onboarding-invitations-requests-backend-src.md) | Sender-side request management (`requestsRouter`) and recipient-side token-keyed onboarding routes. |\\n| [Onboarding, Invitations &amp; Requests \u2014 frontend-src](onboarding-invitations-requests-frontend-src.md) | Requests register at `/requests` and the invitation &amp; verification wizard at `/invite/[token]`. |\\n\\n## How the sub-modules fit together\\n\\nOn the backend, `modules/requests/index.ts` handles the sender side \u2014 creating single or bulk project invitations, managing verification templates, resending, revoking, declining, and listing pending requests \u2014 with helpers like `loadRequestForSender`, `assertOwnedNode`, and `emailTradeInvite` behind the exported `requestsRouter`. `modules/onboarding/index.ts` handles the recipient side \u2014 public, token-keyed routes to inspect, accept, or connect an invitation (`loadInvite`, `resolveConnectSubject`) plus the authenticated wizard routes the frontend submits to.\\n\\nOn the frontend, `RequestsClient` \u2192 `RequestsContent` at `/requests` gives organisation admins the management surface, with actions for `inviteUrl`, `shareLink`, `resend`, `revoke`, `revokeSelected`, `toggleVerificationRequirement`, and bulk selection. The `invite/[token]` pages serve the invitation landing (`OnboardingInviteClient`) and the `WizardShell` that wraps the ABN \u2192 Identity \u2192 Trade types \u2192 Documents \u2192 Review steps.\\n\\nThe halves connect through shared infrastructure: `usePersistentDraft` and `useOnboardingDraftOwner` persist wizard progress on the device, `describeOnboardingError` unifies error messaging, and both the Requests register and the review step report through `useApi` \u2192 `trackNetworkInstant` into the perf store.\\n\\n## Key workflows\\n\\n1. **Inviting a trade party** \u2014 An admin composes single or bulk invitations from the Requests register, optionally toggles verification requirements, and sends them via `emailTradeInvite` or a copied `inviteUrl`/`shareLink`.\\n2. **Managing pending requests** \u2014 Admins track, resend (subject to `isInviteResendCoolingDown`), revoke, decline, or bulk-manage invitations from the same register.\\n3. **Accepting an invitation** \u2014 The invitee lands on the token-scoped page and either connects an existing Assureworx profile in one click or accepts and starts the wizard.\\n4. **Completing onboarding** \u2014 The invitee works through ABN \u2192 Identity \u2192 Trade types \u2192 Documents \u2192 Review; the persistent draft allows resuming without losing input, and the review step submits through the authenticated onboarding router.\\n5. **Closing the loop** \u2014 When review is submitted, the request transitions to complete on the sender side, so both parties observe the same request state through the shared lifecycle.\\n\\n## Diagram\\n\\n```mermaid\\nflowchart TB\\n    subgraph frontend[Frontend]\\n        RQ[RequestsContent /requests] --&gt;|\\\"inviteUrl, resend, revoke,toggleVerificationRequirement\\\"| API\\n        INV[OnboardingInviteClient invite/[token]] --&gt;|connect| API\\n        WZ[WizardShell] --&gt; ABN[AbnClient]\\n        WZ --&gt; ID[IdentityClient]\\n        WZ --&gt; TT[TradeTypesClient]\\n        WZ --&gt; RV[ReviewClient]\\n        ABN -. usePersistentDraft .-&gt; D[(device draft)]\\n    end\\n    subgraph backend[Backend]\\n        API --&gt; REQ[requestsRouter]\\n        API --&gt; ONB[onboarding router]\\n        REQ --&gt;|loadRequestForSender, assertOwnedNode| DB[(requests)]\\n        ONB --&gt;|loadInvite, resolveConnectSubject| DB\\n        REQ --&gt;|emailTradeInvite| MAIL[(email)]\\n    end\\n    RV -. useApi \u2192 trackNetworkInstant \u2192 emit .-&gt; PERF[(perf store)]\\n    RQ -. useApi \u2192 trackNetworkInstant \u2192 emit .-&gt; PERF\\n```\",\"other-agents-md\":\"# Other \u2014 AGENTS.md\\n\\n# AGENTS.md \u2014 Knowledge Wiki Schema\\n\\n## Overview\\n\\n`AGENTS.md` is the root-level operating schema for the **Assureworx knowledge wiki** \u2014 a persistent, interlinked collection of markdown files maintained by an LLM agent alongside the application code. It is not application code and has no runtime callers or execution flows; it is a governance artifact that defines how the wiki is structured, how it is maintained, and which invariants every maintenance operation must satisfy.\\n\\nThe file implements the \\\"LLM Wiki\\\" pattern (Karpathy): the wiki is a **compounding artifact**. Knowledge accumulates across sessions \u2014 every ingest, query, and lint pass makes the artifact more complete \u2014 rather than being re-derived from raw sources on each request. The human curates inputs and asks questions; the LLM agent owns the summarization, cross-referencing, filing, and bookkeeping.\\n\\nThe file has two halves:\\n\\n- **Wiki schema and workflows** (sections 1\u201310): layer model, directory structure, page conventions, ingest/query/lint workflows, ADR process, invariants, and the data classification catalogue.\\n- **GitNexus integration** (the `gitnexus:start` / `gitnexus:end` block): code-intelligence tooling contracts for safely modifying the indexed application code.\\n\\n## The layer model\\n\\nThe wiki is organized as three layers, with application code acting as a fourth, special layer:\\n\\n1. **Raw sources** (`Raw/`) \u2014 immutable source material: specs, exported docs, meeting notes, transcripts, screenshots, data files. The agent reads from here but never modifies it. This is the source of truth for *inputs*.\\n2. **The wiki** (`wiki/`) \u2014 LLM-generated markdown. The agent owns this layer entirely: summaries, entity pages, concept pages, architecture pages, decision records.\\n3. **The schema** (`AGENTS.md` itself) \u2014 how the wiki is structured and the workflows to follow. Co-evolved by human and agent over time.\\n4. **The code** (the application, once it exists) \u2014 authoritative for *how the system actually behaves*. When code and wiki disagree, code wins for behavioral facts; the agent flags the contradiction and updates the wiki (see Lint).\\n\\n```mermaid\\nflowchart TB\\n    Human[Human curator] --&gt;|drops sources| Raw[Raw/ \u2014 immutable inputs]\\n    Raw --&gt;|read-only| Agent[LLM agent \u2014 maintainer]\\n    Schema[AGENTS.md \u2014 schema &amp; workflows] --&gt;|governs| Agent\\n    Agent --&gt;|creates / updates| Wiki[wiki/ \u2014 generated markdown]\\n    Wiki --&gt; Index[index.md \u2014 content catalog]\\n    Wiki --&gt; Log[log.md \u2014 append-only timeline]\\n    Wiki --&gt; Overview[overview.md \u2014 current-state synthesis]\\n    Code[Application code] -.-&gt;|authoritative for behavior| Wiki\\n```\\n\\n## Repository layout\\n\\nThe schema mandates a specific directory structure. Root-level markdown is restricted to `AGENTS.md` plus the Impeccable contracts (`PRODUCT.md`, `DESIGN.md`); all other product/architecture/runbook knowledge must be filed under `wiki/` or `isms/`.\\n\\n| Path | Role | Ownership |\\n|---|---|---|\\n| `AGENTS.md` | This schema | human + agent co-evolved |\\n| `PRODUCT.md`, `DESIGN.md` | Impeccable design-tooling contracts (not wiki pages) | human |\\n| `Raw/` | Immutable source material | read-only for the agent |\\n| `scripts/` | Launchers (.bat), deploy, migrate, ISMS helpers | \u2014 |\\n| `wiki/` | The generated knowledge base | agent owns entirely |\\n| `isms/` | ISO 27001 / SOC 2 wiki \u2014 separate schema (`isms/AGENTS.md`) | agent |\\n\\nWithin `wiki/`:\\n\\n| Path | Role |\\n|---|---|\\n| `index.md` | Content catalog \u2014 every page with a one-line summary |\\n| `log.md` | Append-only chronological log of ingests/queries/lints |\\n| `overview.md` | Top-level synthesis: what Assureworx is, current state |\\n| `glossary.md` | Domain terms and acronyms (ABN, KYC, HRWL, Tier 1, \u2026) |\\n| `product/` | What the product does (domain knowledge) |\\n| `architecture/` | How it is built (technical knowledge) |\\n| `decisions/` | ADRs + open questions |\\n| `sources/` | One summary page per ingested Raw source |\\n| `runbooks/` | Operational how-tos (deploy, Stripe, rotate secrets, \u2026) |\\n\\n## Page conventions\\n\\nEvery wiki page starts with YAML frontmatter, then a title, then a body:\\n\\n- `type`: `product | architecture | decision | source | concept | overview | index | log`\\n- `status`: `current | draft | superseded`\\n- `updated`: `YYYY-MM-DD`\\n- `sources`: list of source IDs the page draws from\\n- `tags`: free-form topic tags\\n\\nLinking and content rules:\\n\\n- **Relative markdown links only** (`[Data Model](../architecture/data-model.md)`) \u2014 keeps the wiki portable across GitHub, Obsidian, and plain editors.\\n- **No orphan pages.** Every page must have inbound links; creating a page requires linking it from `index.md` and at least one relevant sibling page.\\n- **Cross-reference aggressively** \u2014 any concept with its own page gets linked at mention.\\n- **Cite sources** \u2014 source ID + section (e.g. \\\"spec \u00a75.2\\\") for source claims; file path for code claims.\\n- **Flag contradictions inline** with `&gt; **\u26a0 Contradiction:** spec \u00a75.10 says 14-day grace; code enforces 7 (billing/service.ts).`\\n- **Flag unknowns** with `&gt; **\u2753 Open:** ...`; real unresolved decisions are filed in `decisions/open-questions.md`.\\n- **Prefer short, focused pages** that grow over giant catch-all pages. Split when a page covers two clearly distinct things.\\n- Tables for structured facts; code fences for schemas/routes/snippets. Keep prose tight.\\n\\n## Bookkeeping: index.md and log.md\\n\\nThe wiki's navigability rests on two files that must be updated on every operation:\\n\\n- **`wiki/index.md`** \u2014 the content-oriented catalog. Lists every page grouped by category, each with a relative link and a one-line summary. It is the first thing read when answering a query; it replaces embedding-based RAG at this scale (hundreds of pages). Updated on every ingest or new page.\\n- **`wiki/log.md`** \u2014 the chronological audit trail. Append-only: past entries are never rewritten. Every ingest, notable query filed back to the wiki, and lint pass gets an entry with a greppable prefix:\\n  - `## [YYYY-MM-DD] ingest | `\\n  - `## [YYYY-MM-DD] query  | `\\n  - `## [YYYY-MM-DD] lint   | `\\n  - `## [YYYY-MM-DD] decision | ADR-000N `\\n\\n  `grep \\\"^## \\\\[\\\" wiki/log.md | tail -5` yields the recent timeline. Each entry is 2\u20135 bullets on what changed and which pages were touched.\\n\\nPlus **`wiki/overview.md`** \u2014 the current-state pointer. It always reflects the best current synthesis of the product and build state; it is updated whenever a major fact changes, and read at the start of every deep session alongside `wiki/index.md`.\\n\\n## Workflows\\n\\n### Ingest\\n\\nWhen the human drops a source into `Raw/` and asks for processing:\\n\\n1. Read the source fully; extract substantive text from HTML/mixed content.\\n2. Discuss key takeaways with the human (unless told to batch silently).\\n3. Create/update `wiki/sources/.md` \u2014 summary + key facts + section map, linked back to the raw file.\\n4. Integrate into the wiki \u2014 a single source may touch 5\u201315 pages across `product/`, `architecture/`, `decisions/`, and concept pages. Knowledge is distributed to where it belongs, not dumped into one page.\\n5. Update `index.md` and append to `log.md`.\\n6. Note contradictions with existing pages and any new open questions.\\n\\n### Query\\n\\nWhen the human asks a question:\\n\\n1. Read `index.md`, then the relevant pages. Search the wiki first; only re-read `Raw/` or code if the wiki is insufficient.\\n2. Answer with citations \u2014 page links, source sections, or code paths.\\n3. **File valuable answers back into the wiki.** A comparison, analysis, or discovered connection worth keeping becomes a new page (usually under `product/`, `architecture/`, or as a concept page), then `index.md` and `log.md` are updated. Explorations compound; they do not vanish into chat.\\n\\n### Lint\\n\\nA periodic health check that reports:\\n\\n- Contradictions between pages, or between wiki and code\\n- Stale claims superseded by newer sources or shipped code\\n- Orphan pages (no inbound links) and missing pages (concepts referenced but never documented)\\n- Broken links and missing cross-references\\n- Open questions that have since been answered (promoted to decisions)\\n- Data gaps worth a web search or a question to the human\\n\\nThe lint produces a prioritized to-do list; safe fixes are made directly, the rest are proposed.\\n\\n## Decision records (ADRs)\\n\\nSignificant architectural and product decisions are first-class records. Each gets a numbered file in `wiki/decisions/NNNN-slug.md`:\\n\\n- Frontmatter: `type: decision`, `status: proposed | accepted | superseded`\\n- Body: **Context / Decision / Consequences**\\n- Listed in `wiki/decisions/index.md`\\n- Unresolved decisions tracked in `wiki/decisions/open-questions.md`\\n- When resolved: create the ADR, remove the item from open questions, append to `log.md`\\n\\n**When to create an ADR:** any choice that is expensive to reverse or that future contributors would ask \\\"why did we do it this way?\\\" \u2014 framework/library choices, data-model shapes, tenancy/security boundaries, third-party providers, deployment topology.\\n\\n## Data classification catalogue\\n\\n`wiki/architecture/data-classification-catalogue.md` is a living, element-level inventory of every stored or exposed data point, classified **GP / CIC / C / PII / SPII** (Australian English definitions). Any change that adds or alters a migration column, API route, cookie, blob, email, wallet field, or supplier data flow must update the catalogue **in the same change** and append to `wiki/log.md`. The Cursor rule `.cursor/rules/data-classification-catalogue.mdc` enforces this; the rolled-up ISMS view lives at `isms/data-inventory.md`.\\n\\n## Maintainer invariants\\n\\nThe agent's working style is codified as a set of rules:\\n\\n- Prefer **editing existing pages** over creating new ones \u2014 grow the wiki, don't sprawl it\\n- Make **atomic, well-scoped edits**; keep the wiki internally consistent after every change\\n- Keep the wiki **implementation-honest**: once code exists, cite code for behavior; reserve spec/product pages for intent and domain rules\\n- **Never invent facts** \u2014 unknowns are marked `\u2753 Open`, never guessed\\n- Every ingest and lint ends with `index.md` updated + `log.md` appended. **No exceptions**\\n- The wiki is a git repo of markdown; commit logically (one ingest / one lint = one commit) when asked\\n\\n## Codebase integration: GitNexus\\n\\nThe trailing `gitnexus:start` / `gitnexus:end` block wires the repository to **GitNexus** code-intelligence tooling. This is the section that connects to the application codebase itself: it documents the MCP tools the agent must use before modifying indexed symbols.\\n\\nGitNexus indexes the project as **assureworx_app** (7055 symbols, 17119 relationships, 300 execution flows). The mandatory workflow:\\n\\n- **Before editing any symbol** \u2014 run `impact({target: \\\"symbolName\\\", direction: \\\"upstream\\\"})` and report the blast radius (direct callers, affected processes, risk level) to the user\\n- **Before committing** \u2014 run `detect_changes()` to verify changes affect only expected symbols and flows; for regression review, use `detect_changes({scope: \\\"compare\\\", base_ref: \\\"main\\\"})`\\n- **On HIGH or CRITICAL impact risk** \u2014 warn the user before proceeding with edits\\n- **For exploration** \u2014 `query({search_query: \\\"concept\\\"})` returns execution flows process-grouped by relevance; `context({name: \\\"symbolName\\\"})` gives callers, callees, and participating flows; `explain({target: \\\"fileOrSymbol\\\"})` lists taint findings for security review (requires `analyze --pdg`)\\n- **For refactoring** \u2014 use `rename` (which understands the call graph), never find-and-replace\\n\\nExplicit prohibitions:\\n\\n- Never edit a function, class, or method without first running `impact`\\n- Never ignore HIGH or CRITICAL risk warnings from impact analysis\\n- Never rename symbols with find-and-replace\\n- Never commit without running `detect_changes()`\\n\\nThe block also maps GitNexus resources (`gitnexus://repo/assureworx_app/...`) and CLI skill files (e.g. `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md`) for architecture exploration, blast-radius analysis, debugging, refactoring, and CLI administration.\\n\\n## Connections to the rest of the repository\\n\\n`AGENTS.md` has no code-level callers or execution flows \u2014 it is markdown, not a module with a runtime API \u2014 but it is the organizational hub of the repository:\\n\\n| Connection | Direction | Nature |\\n|---|---|---|\\n| `Raw/` \u2192 `wiki/` | data flow | Sources flow through ingest into wiki pages |\\n| `wiki/` \u2192 `index.md`, `log.md` | bookkeeping | Every change updates the catalog and audit trail |\\n| `AGENTS.md` \u2192 `wiki/`, `isms/`, `scripts/` | governance | Schema defines structure and workflows |\\n| `PRODUCT.md`, `DESIGN.md` | co-located contracts | Impeccable design tooling; kept at root, not in the wiki |\\n| `.cursor/rules/data-classification-catalogue.mdc` | enforcement | Cursor rule enforcing the classification update obligation |\\n| `.gitnexus/` + GitNexus skills | tooling | Code-intelligence workflows for safe edits to application code |\\n| `isms/` | separate schema | Independent ISO 27001 / SOC 2 wiki with its own `isms/AGENTS.md` |\\n\\n## Evolving the schema\\n\\n`AGENTS.md` is explicitly designed to be co-evolved: the human and the agent update it as the wiki's needs change. There is no separate mechanism \u2014 a change to the file *is* a change to the schema. The file itself mandates that every session touching the wiki reads it first (\\\"Read this file at the start of any session that touches the wiki\\\"), which is how schema changes propagate to agent behavior.\",\"other-backend-scripts\":\"# Other \u2014 backend-scripts\\n\\n# backend/scripts \u2014 Operational Probes and Developer Utilities\\n\\n## Purpose\\n\\n`backend/scripts` holds one-off tooling that is deliberately kept out of the application runtime. Nothing here is imported by the app; each script is run explicitly by a developer (or CI) and exits with a non-zero code on failure.\\n\\nThe scripts fall into three categories:\\n\\n- **Probes** \u2014 exercise a real pipeline against realistic inputs and print exactly what came out. They make invisible behaviour visible: what a rule pack extracted, what a cloud tier cost, what a signed Wallet JWT actually contains, what the requirements engine decided and why.\\n- **Smoke checks** \u2014 `verify-identity.ts` and `probe-webhook.mjs` assert specific invariants (`ok` / `FAIL` per assertion) and are safe to run in CI.\\n- **Dev utilities** \u2014 mutate local data for development: minting invitations, hard-purging test identities, and dumping schema-ERD metadata.\\n\\nSeveral probes are the executable form of an ADR: `probe-engagement-requirements.ts` (ADR-0018), `probe-extraction.ts` (ADR-0019), and `probe-cloud-read.ts` (ADR-0020). When those ADRs change, the corresponding probe changes with them.\\n\\n## Script inventory\\n\\n| Script | What it does | Run with |\\n|---|---|---|\\n| `probe-extraction.ts` | Runs synthetic PDFs through the extraction pipeline. Offline, no DB. | `npm run probe:extraction --workspace @assureworx/backend` |\\n| `probe-cloud-read.ts` | Sends photographed (rasterised) certificates to Azure `prebuilt-read` through the real ladder. Billed. | `npm run probe:cloud-read --workspace @assureworx/backend` |\\n| `verify-identity.ts` | Smoke checks for the identity module: status derivation, webhook signatures, spend cap, consent lifecycle. | `npm run -w backend verify:identity` |\\n| `probe-webhook.mjs` | Drives the running API's webhook route with real signatures; checks persistence, idempotency, field discarding. | `node scripts/probe-webhook.mjs` (needs a live API) |\\n| `probe-engagement-requirements.ts` | Tom Delaney scenario: verifies per-placement requirements block and clear without touching the portable profile. Rolls back. | `npx tsx --env-file=.env scripts/probe-engagement-requirements.ts` |\\n| `ensure-google-wallet-class.mts` | Idempotently creates/updates the Google Wallet generic class with the card row layout. | `npx tsx scripts/ensure-google-wallet-class.mts` |\\n| `probe-google-wallet.mts` | Signs a `savetowallet` JWT and writes a clickable HTML probe page to `.tmp/`. | `npm run -w backend probe:google-wallet` |\\n| `mint-invite.mjs` | Inserts a pending Q02 onboarding invitation directly, bypassing the API. Prints both entry-point links. | `npm run -w backend invite -- someone@example.com \\\"Their Name\\\"` |\\n| `purge-party.mjs` | Hard-deletes a party and everything hanging off it. Dev only; refuses organisations. | `npm run -w backend purge -- someone@example.com [--all]` |\\n| `probe-schema-erd.ts` | Prints a JSON summary of the schema ERD (tables, edges, domains, latest migration). | `npx tsx scripts/probe-schema-erd.ts` |\\n\\n## Shared conventions\\n\\n**RLS bypass.** Tables are protected by row-level security (migration 009). Scripts that write either run `SELECT set_config('app.bypass', 'on', false)` on a raw `pg` client (`mint-invite.mjs`, `purge-party.mjs`, `probe-webhook.mjs`) or use `withBypass(tx =&gt; \u2026)` from `src/shared/db/pool.ts` (`probe-engagement-requirements.ts`, `verify-identity.ts`, `probe-schema-erd.ts`). `withBypass` also supplies a transaction, which the engagement probe relies on for rollback.\\n\\n**Connection strings.** The default is the local dev database via `DATABASE_URL` (`postgres://app_login:app_dev_password@localhost:5433/assureworx`). `purge-party.mjs` is the exception: it uses `ADMIN_DATABASE_URL` (owner role) because the runtime role `app_rt` deliberately has no `DELETE` on append-only tables like `t_result`, `t_ledger`, and `t_consent`.\\n\\n**Seed dependency.** `verify-identity.ts`, `probe-engagement-requirements.ts`, `probe-webhook.mjs`, and `mint-invite.mjs` assume the seed data has been loaded (`npm run -w backend seed`). They look for a principal contractor (K01), a verified trade (S02), or a person party (K03), and skip or fail with a clear message when absent.\\n\\n**Configuration.** Wallet scripts call `loadWalletConfig()` from `src/modules/wallet/config.ts` and fail fast if Google Wallet is not configured. `probe-cloud-read.ts` reads limits from `config.docIntel` in `src/shared/config.ts`.\\n\\n**Exit codes.** Smoke checks and probes exit `1` on failure so they can gate CI. Pure information scripts (`probe-schema-erd.ts`) always exit `0`.\\n\\n```mermaid\\ngraph LR\\n    subgraph scripts[\\\"backend/scripts \u2014 families\\\"]\\n        EX[\\\"Extraction probesprobe-extraction \u00b7 probe-cloud-read\\\"]\\n        ID[\\\"Identity checksverify-identity \u00b7 probe-webhook\\\"]\\n        WAL[\\\"Wallet scriptsensure-google-wallet-class \u00b7 probe-google-wallet\\\"]\\n        REQ[\\\"Engagement probeprobe-engagement-requirements\\\"]\\n        DBU[\\\"Dev utilitiesmint-invite \u00b7 purge-party \u00b7 probe-schema-erd\\\"]\\n    end\\n    EX --&gt; PIPE[\\\"Extraction pipelinesrc/shared/services/extraction\\\"]\\n    ID --&gt; IDM[\\\"Identity modulesrc/modules/identity\\\"]\\n    WAL --&gt; WCFG[\\\"Wallet configsrc/modules/wallet\\\"]\\n    REQ --&gt; REQENG[\\\"Requirements enginesrc/shared/services/requirements\\\"]\\n    DBU --&gt; DB[(\\\"Postgrescore schema\\\")]\\n```\\n\\n## Document extraction probes\\n\\n### `probe-extraction.ts` \u2014 offline rule-pack probe\\n\\nBuilds synthetic PDFs that mimic the layouts Australian insurers, registries, and RTOs actually produce, runs them through the real extraction pipeline, and prints what came out. No database and no network, so it runs anywhere. The header comment sets the bar: *\\\"a pack that guesses is worse than one that stays quiet\\\"* \u2014 the output is meant to be read for both what was extracted and what was not.\\n\\nThe script contains a minimal single-page PDF writer (`makePdf` / `escapePdf`) so there is no dependency on a PDF library. It defines specimen documents and runs eight cases:\\n\\n| Case | Class code | What it exercises |\\n|---|---|---|\\n| Public liability COC (QBE) | `A004` | Correct type, text-layer PDF |\\n| Workers comp COC (icare NSW) | `A005` | Correct type |\\n| Electrical contractor licence (NSW Fair Trading) | `A101` | Correct type |\\n| White card statement of attainment (RTO 6032) | `A001` | Correct type |\\n| High-risk work licence (SafeWork NSW) | `A105` | Correct type |\\n| Workers comp uploaded as PL | `A004` | Wrong class code |\\n| Expired + under-insured PL (agency certificate, $5M) | `A004` | Validity and limit checks |\\n| Scanned PDF with no text layer | `A004` | Tier fallback behaviour |\\n\\nEach case calls `extractDocument(bytes, \\\"application/pdf\\\", { classCode, partyName, partyAbn, today })` with `today` fixed at `2026-07-28`, then `toPreview(result)` and prints status, verdict, confidence, attempted tiers, duration, page count, prefilled fields, every read field, and every finding (level + code).\\n\\n### `probe-cloud-read.ts` \u2014 billed Azure tier 3 probe\\n\\nSame pipeline as `probe-extraction.ts`, but for the tier that cannot run offline. It rasterises a synthetic certificate to PNG with `sharp` so there is no text layer \u2014 pixels are the only thing tier 1 cannot read \u2014 then pushes the image through the real ladder to Azure AI Document Intelligence `prebuilt-read`.\\n\\nThree cases, all the \\\"photographed\\\" scenario:\\n\\n1. Public liability COC as `A004`\\n2. Electrical contractor licence as `A101`\\n3. Public liability below the $10M minimum (Allianz, `$5M`) as `A004`\\n\\nFor each it prints status, source tier, confidence, attempted tiers, page count, latency, reason (if any), the `toPreview` output, and findings.\\n\\nIt also exercises the **monthly page meter** in `core.t_meter`. `cloudReadStatus()` prints the endpoint, model, circuit-breaker state (`closed` vs `OPEN \u2014 tier is paused`), monthly pages used/cap, and the per-document / per-party / budget limits from `config.docIntel`. After the run it reads the meter again and reports the new page count.\\n\\nRequirements and cost, from the header:\\n\\n- `DOC_INTEL_ENDPOINT` set, `az login` with Cognitive Services User, `DATABASE_URL` reachable.\\n- Cost: US$1.50 per 1,000 pages. Three cases is under a cent, but it is a real charge \u2014 *\\\"do not loop it.\\\"*\\n\\nNote the deliberate omission of `partyId` in the extraction options: the per-party daily cap is not the subject of this probe, and a synthetic party would pollute the audit log.\\n\\n## Identity verification\\n\\n### `verify-identity.ts` \u2014 identity smoke checks\\n\\nFive sections, each printing `ok` / `FAIL` lines and a final `ALL PASS` / `N FAILURE(S)` with a matching exit code. `DIDIT_*` values are dummies \u2014 nothing contacts the provider. Needs a local Postgres with migrations applied; writes to and cleans up after the first seeded person party.\\n\\n1. **`deriveIdentityStatus`** \u2014 the outcome-vs-currency derivation, asserted across the table: no check \u2192 `not_checked`; `H01` \u2192 `in_progress`; `H02` with a far expiry \u2192 `current`; `H02` with 10 days left \u2192 `expiring_soon`; `H02` past expiry \u2192 `expired`; a `superseded` reason outranks a valid expiry; `H03` \u2192 `did_not_pass`; unknown `H99` \u2192 `could_not_complete` (never a pass).\\n\\n2. **Webhook signature verification** via `DiditVerifier` \u2014 a valid canonical-JSON + HMAC signature is accepted; only `documentExpiresOn` survives out of the provider payload; key order does not break verification (the signature is over canonically sorted JSON); bad, stale, missing, and tampered signatures are rejected; an in-flight status yields no outcome.\\n\\n3. **Monthly spend cap** \u2014 `reserveCall` / `releaseCall` / `readMeter` against `core.t_meter`: the first three reservations win, the 4th and 5th are refused at the cap, the counter stops at the cap, and `releaseCall` frees exactly one slot.\\n\\n4. **Concurrent reservations** \u2014 25 racers against a cap of 10; exactly 10 win and the counter never exceeds the cap.\\n\\n5. **Consent + check lifecycle** \u2014 grant and withdraw consent on a seeded person party, asserting the append-only trail (`granted` then `withdrawn`, consent expiring ~24 months out). Then the real path: `recordStarted` leaves a pending check whose resume URL is readable while in flight; `applyResult` settles it and drops the resume URL; a replayed webhook is a no-op; a settled check cannot be downgraded to `unverified`; the stored row is `H02` with the document expiry unshifted and `result` JSONB empty; `identitySummary` reports the same date and blocks a redundant check (`blockedReason === \\\"already_current\\\"`); `markSuperseded` preserves the outcome with reason `superseded`; `destroyIdentityChecks` removes check rows but keeps the consent trail.\\n\\n### `probe-webhook.mjs` \u2014 live webhook route probe\\n\\nWhere `verify-identity.ts` tests the verifier in isolation, this probe drives the running API's actual route at `${PROBE_BASE}/api/v1/identity/webhook` (default `http://127.0.0.1:4099`). Its job is to prove the raw-body mounting survives Express's JSON parser and the CSRF guard, and that the whole discard-and-persist path works end to end.\\n\\nIt builds signed payloads with the same canonical-sort + HMAC-SHA256 scheme (`X-Timestamp`, `X-Signature-V2`) and asserts:\\n\\n- the health endpoint reports `status === \\\"wired\\\"`;\\n- unsigned delivery \u2192 401, stale timestamp \u2192 401;\\n- signed delivery \u2192 200;\\n- the check persists as `H02` with the document expiry stored and `result` JSONB `{}`;\\n- no discarded provider field leaks into `t_check` or the audit-trail `t_event` (the payload's `document_number`, `first_name`, and `portrait_image` are set to `MUST_NOT_PERSIST`, then searched for);\\n- a replayed delivery is still 200 and creates no duplicate row;\\n- an unknown vendor reference returns 200 (acknowledged) but writes nothing.\\n\\nRequires a running API, a seeded person party, and Postgres. Cleans up its own rows.\\n\\n## Engagement requirements probe\\n\\n### `probe-engagement-requirements.ts` \u2014 Tom Delaney scenario (ADR-0018)\\n\\nThe scenario: a trade who is Assureworx Verified on their portable profile is placed on a site that demands a classification they hold no evidence for. The placement must go **blocked with exactly one gap**, and must clear the moment the gap is filled \u2014 *without touching the portable profile*.\\n\\nThe probe:\\n\\n1. Finds the first principal contractor (K01) and a verified trade (S02) who has a live portable relationship (`L02`/`L03`, status `RS1`) to that principal, and who does not already hold an `A101` artifact or a `G01` tag.\\n2. Inserts a probe project (`N01`) and site (`N02`), then a link (`J02`) placing the trade on the site. With no engagement rules, readiness resolves with zero gaps.\\n3. The principal adds a requirement set on the site: kind `X01` (class evidence), class `A101`, applies-tag `G01`, block `B01`. Because the trade is not engaged for electrical work (no `G01` link tag), the placement is *still* ready \u2014 a trade not doing electrical work is unaffected.\\n4. The principal engages the trade for `G01` by inserting a `t_link_tag`. Readiness now resolves to blocked with exactly **one gap**: the missing `A101` evidence.\\n5. `evaluateLinks` persists the state onto `t_link` (`state_cd`, `gap_hard_n`, `gap_soft_n`, `evaluated_at`).\\n6. The portable profile is untouched \u2014 the trade's party status is still `S02` (Verified).\\n7. A verified `A101` artifact (status `F02`) is inserted for the trade. The same placement, with no re-onboarding, resolves ready again.\\n\\nThe whole run happens inside `withBypass` and rolls back by throwing `PROBE_ROLLBACK`; the catch block recognises it and prints \\\"Rolled back cleanly.\\\" Nothing is left in the database.\\n\\n## Google Wallet scripts\\n\\n### `ensure-google-wallet-class.mts` \u2014 idempotent class provisioning\\n\\nCreates or refreshes the Google Wallet generic class via REST. The card row layout lives on the **class**, not in the save JWT, so the JWT stays lean.\\n\\nThe script reads the service-account key from `backend/certs/google-wallet-sa.json`, exchanges a self-signed JWT assertion (RS256, scope `wallet_object.issuer`) for an OAuth token at `https://oauth2.googleapis.com/token`, then:\\n\\n- GETs `\u2026/genericClass/${classId}`;\\n- if it exists (200), PUTs the new class body;\\n- otherwise POSTs to create it.\\n\\n`classId` is `${issuerId}.${classSuffix}` from `loadWalletConfig()`. The class body sets `classTemplateInfo.cardTemplateOverride.cardRowTemplateInfos` with two `twoItems` rows \u2014 `(status, expires)` and `(licence, abn)` \u2014 each field path pointing into `object.textModulesData['\u2026']`. Exits 1 on OAuth failure or a non-OK class call.\\n\\n### `probe-google-wallet.mts` \u2014 save-URL probe\\n\\nThere is no offline Wallet simulator \u2014 Google still has to accept the JWT. This probe builds a branded generic object (state `ACTIVE`, hex background `#3E6AE1`, QR barcode `awx.verify.v1|AWX-PROBE|53004085616|AU|1`, one-year validity, and the standard text modules: status, licence, abn, expires, gate), signs a `savetowallet` JWT (`typ: \\\"savetowallet\\\"`, `aud: \\\"google\\\"`, `origins` from config), and writes two files to `.tmp/`:\\n\\n- `google-wallet-probe.html` \u2014 a page with the \\\"Add probe pass to Google Wallet\\\" link plus a summary of issuer, origins, class/object IDs, and troubleshooting tips (demo tester account, service account as a user on the issuer, logo/hero needing public HTTPS, not localhost);\\n- `google-wallet-probe.url.txt` \u2014 the raw `https://pay.google.com/gp/v/save/${token}` URL.\\n\\nLogo and hero images are attached only when `cfg.google.logoUri` / `heroUri` are set (derived from `WALLET_ASSET_BASE_URL`).\\n\\n## Developer utilities\\n\\n### `mint-invite.mjs` \u2014 dev invitation minting\\n\\nGiven an email and optional name, mints a pending `Q02` (`direct_individual`, the sole-trader invite) directly into `core.t_request`, skipping the authorisation that `POST /api/v1/requests` enforces. It:\\n\\n- sets `app.bypass`;\\n- takes the first principal contractor (K01) as the sender;\\n- supersedes any earlier pending (`U01`) invite for the same address to `U05`, so two live tokens never point at one person;\\n- generates a 32-byte random hex token with a 14-day expiry;\\n- prints both entry points: the verify journey `${PUBLIC_APP_URL}/verify/i/${token}` and the desktop wizard `${PUBLIC_APP_URL}/onboarding/invite/${token}`.\\n\\n`PUBLIC_APP_URL` defaults to `http://localhost:3000`. The header comment notes that `Q01` would additionally ask for a company name on the first screen; `Q02` is the correct kind for a sole trader.\\n\\n### `purge-party.mjs` \u2014 hard party deletion\\n\\nThe application never hard-deletes a party; it sets `deleted_at` so audit history and network edges survive. This script is the deliberate exception for clearing test identities so a journey can be walked from an empty slate. It **refuses to touch organisations** (K01/K02) because purging one would strip every trade connected to it.\\n\\nIt runs as the database owner (`ADMIN_DATABASE_URL`), not the app role \u2014 `app_rt` has no `DELETE` on append-only tables, which is the point of those grants. It matches a party by actor email *or* by subject `pending|`, so a half-finished Entra sign-up whose `email_txt` was never set is still found. If one email maps to multiple parties it refuses unless `--all` is passed.\\n\\n`purgeBody` then follows the foreign keys inward:\\n\\n1. **NULLs references to the party's actors** on rows that belong to somebody else and merely record who touched them (`created_by`, `checked_by`, `granted_by`, `actor_id`, \u2026).\\n2. **Deletes rows with a NOT NULL actor column** that cannot outlive the actor (`t_sr_note`, `t_email_change`, `t_grant` as actor, `t_sr.requester_actor_id`).\\n3. **Deletes rows keyed on the party itself** across ~25 table/column pairs \u2014 from `t_result`, `t_ledger`, `t_consent`, `t_check`, and `t_artifact` through `t_link`, `t_rel`, `t_case`, `t_sr`, `t_seat`, `t_node`, `t_tag`, `t_task`, and `t_course`.\\n4. **Nulls `target_party_id` on invitations sent to this person** \u2014 the sending org's record that it asked is theirs, not ours to erase \u2014 then deletes requests the party originated.\\n5. Deletes the actors, then the party.\\n\\nAnything missed surfaces as an FK error rather than silent corruption. Prints per-table deletion counts.\\n\\n### `probe-schema-erd.ts` \u2014 schema metadata dump\\n\\nRuns `buildSchemaErd(tx)` from `src/shared/services/schema-erd.ts` inside `withBypass` and prints a JSON summary: number of tables and edges in the ERD, domains, the latest migration, the logical name resolved for `t_pass_dl`, and whether `t_req_set` exists. A quick way to verify that schema tooling sees what you expect after a migration.\\n\\n## Relationship to the rest of the codebase\\n\\n| Script | Key imports from `src/` |\\n|---|---|\\n| `probe-extraction.ts` | `extractDocument` (extraction pipeline), `toPreview` (presentation) |\\n| `probe-cloud-read.ts` | `extractDocument`, `toPreview`, `cloudReadStatus`, `config`, `pool` |\\n| `verify-identity.ts` | `DiditVerifier`, meter / consent / identity service modules, `deriveIdentityStatus`, `latestIdentityCheck`, `withBypass` |\\n| `probe-webhook.mjs` | none \u2014 direct HTTP against the running API + raw `pg` |\\n| `probe-engagement-requirements.ts` | `evaluateLinks`, `resolveEngagementReadinessForLink`, schema constants `T`, `withBypass` |\\n| `ensure-google-wallet-class.mts` | `loadWalletConfig` |\\n| `probe-google-wallet.mts` | `loadWalletConfig` |\\n| `mint-invite.mjs` | none \u2014 direct `pg` into `core.t_request` |\\n| `purge-party.mjs` | none \u2014 direct `pg` with owner credentials |\\n| `probe-schema-erd.ts` | `buildSchemaErd`, `withBypass` |\\n\\nThe probes are written to be read as much as run: each prints a labelled, structured trace of what the pipeline decided, so a change to a rule pack, a requirement set, or a wallet class layout can be reviewed in the diff of the script and confirmed in the output.\",\"other-backend-src\":\"# Other \u2014 backend-src\\n\\n# Other \u2014 backend-src\\n\\n## Overview\\n\\n`backend/src` is the TypeScript backend for the Assureworx verification platform. It is organized as a **modular monolith**: each folder under `modules/` is a bounded context with its own HTTP surface, business logic, and persistence, while shared infrastructure lives in `shared/`. The platform's modules cover assurance (continuous re-verification sweeps), identity (consent and the Didit document-verification vendor boundary), memberships (employer\u2013employee verification scoping), and payments (checkout, settlement, and refunds against Stripe).\\n\\nThe module layout is defined by `modules/_module.template.md` and is consistent across bounded contexts:\\n\\n```text\\nmodules//\\n  routes.ts        # Express router only \u2014 no business logic\\n  controller.ts    # HTTP in/out \u2192 calls service\\n  validation.ts    # Zod schemas\\n  service.ts       # business logic + audit events\\n  repository.ts    # SQL only \u2014 enforce tenant scope here\\n  types.ts         # module types\\n  index.ts         # export router + public types\\n```\\n\\nRouters are mounted in `src/app.ts` under `/api/v1/`. A context is deployable as part of the Express process today and extractable as its own service later without changing its internal structure.\\n\\n## Architecture: bounded contexts\\n\\n```mermaid\\nflowchart LR\\n    HTTP[HTTP request] --&gt; R[routes.ts]\\n    R --&gt; C[controller.ts]\\n    C --&gt; V[validation.ts]\\n    C --&gt; S[service.ts]\\n    S --&gt; REP[repository.ts]\\n    REP --&gt; DB[(PostgreSQL)]\\n    S --&gt; EV[audit events]\\n```\\n\\nDependencies point inward: modules depend on `shared/`, not on each other. Cross-cutting services referenced across the suite include:\\n\\n- `shared/config.ts` \u2014 environment-driven configuration, read once at import; throws on invalid configuration (which is why boot tests run in child processes).\\n- `shared/db/pool.ts` \u2014 the PostgreSQL pool, the `withBypass` helper, and the `Queryable` transaction type used by database tests.\\n- `shared/http/errors.ts` \u2014 `HttpError`, carrying both an HTTP status and a stable machine-readable `code` (e.g. `NOT_ELIGIBLE_FOR_PAYMENT`, `ALREADY_FUNDED`, `ORDER_NOT_CANCELLABLE`).\\n- `shared/services/requirements.ts` \u2014 `deriveReadiness`, the readiness matrix used by the assurance module.\\n- `shared/services/verified-status.ts` \u2014 `evaluateAndPersistStatus`, the continuous-assurance recompute that keeps party status current.\\n- `shared/services/verification-template.ts` \u2014 employer verification templates (`getVerificationTemplate`, `saveVerificationTemplate`, `employmentRequirementKeys`, `DEFAULT_EMPLOYEE_KEYS`).\\n\\nRelative imports use the `.js` extension per NodeNext ESM convention; the source files are `.ts`.\\n\\n## Test strategy\\n\\nThe backend treats tests as executable contracts. Three test styles appear across the suite:\\n\\n- **Unit tests** (`*.test.ts`) \u2014 pure logic with no database or network. Examples: `pricing.test.ts`, `consent-version.test.ts`, `provider/provider.test.ts`.\\n- **Database tests** (`*.dbtest.ts`) \u2014 run against a real PostgreSQL (`npm run test:db -w backend`, requires `DATABASE_URL`). They exercise the real orchestration \u2014 the same `startSelfCheckout`, the same inbox, the same `processPayEvent` \u2014 with only the payment provider substituted. These tests commit rather than roll back, because crash recovery across transactions is part of what is being tested. Fixtures are unique per run and left in place; CI starts from a fresh database.\\n- **Boot tests** (`boot.test.ts`) \u2014 spawn child processes via `tsx` because `config.ts` reads the environment once at import and throws there. A payment misconfiguration must stop the process at startup, not surface as a 500 on the first sale.\\n\\nBoundary tests deserve particular attention: `didit-boundary.test.ts` and `provider/provider.test.ts` feed realistic vendor payloads to the adapters and assert on what comes out, rather than reading adapter code and hoping. This is how the suite proves PII never crosses the vendor boundary and that unknown provider fields default to being discarded.\\n\\n## Assurance\\n\\nFiles: `modules/assurance/sweep.ts`, `modules/assurance/sweep.test.ts`.\\n\\nThe assurance sweep is a background job that re-evaluates party readiness on an interval. The test suite pins down:\\n\\n- **Configuration.** The sweep defaults to a 60-minute interval and a batch limit between 1 and 1000, and can be disabled via `ASSURANCE_SWEEP_INTERVAL_MIN`.\\n- **Readiness matrix.** `deriveReadiness(statusCd, hardGaps, softGaps)` blocks on hard gaps even when the portable profile is verified (`blocked`), stays `ready` when verified with no gaps, surfaces soft gaps as `action_required`, and keeps suspended parties `blocked`.\\n- **Continuous-assurance recompute.** A regression guard asserts that `evaluateAndPersistStatus` does **not** early-return when the portable status is unchanged \u2014 it must still call `evaluatePartyEngagements`, or site boards go stale when a site-scoped document expires.\\n- **Candidate query.** `sweep.ts` selects candidates with expired evidence (`expires_on &lt; CURRENT_DATE`), expired identity documents (`doc_expires_on &lt; CURRENT_DATE`), or lapsed consent (`purpose_cd`), and processes them through `evaluateAndPersistStatus` within `withBypass`.\\n\\n## Identity\\n\\nFiles: `modules/identity/consent.ts`, `modules/identity/didit.ts`, plus their tests.\\n\\n### Consent\\n\\n`consentAllowsNewCheck(record)` gates new identity checks on the current consent wording (`CONSENT_VERSION`) and an unexpired grant. A check whose consent has lapsed reads as not current \u2014 a pass on its own is not enough to make anyone eligible to pay (ADR-0015 \u00a73).\\n\\n### Didit vendor boundary\\n\\n`DiditVerifier` parses and verifies webhooks from the Didit identity provider. The boundary is tested from the outside with a realistic decision payload carrying the document number, personal number, full MRZ line, names, date of birth, place of birth, gender, nationality, and presigned URLs to document images and capture videos. The adapter must keep only:\\n\\n- the outcome: `result.state` (`verified` | `unverified` | `check_failed`), `reason`, `documentExpiresOn`, `sessionExpiresAt`;\\n- step diagnostics the UI needs to name the step that needs redoing: `diagnostics.steps` (`document`, `liveness`, `face_match`), capture-quality scores, `diagnostics.document` (type and issuing country only), and warning **codes** \u2014 prose-like warning values are dropped.\\n\\nIt must drop every piece of personal information, including the provider-derived age. Unknown statuses become `check_failed` / `unknown_status` \u2014 never a pass. An `In Progress` delivery records no outcome (`result` is `null`).\\n\\nSignature verification uses HMAC-SHA256 over a canonical form (sorted keys, whole-valued floats truncated) and accepts any registered destination secret \u2014 useful mid-rotation or when one backend serves two destinations. A delivery signed with an unregistered secret, or any delivery when no secret is configured, is rejected.\\n\\n## Memberships\\n\\nFile: `modules/memberships/employee-checks.dbtest.ts`.\\n\\nThe employer's employee-check selection is tested against a real database because two properties are not provable in a unit test:\\n\\n- **An ABN can never be asked of somebody on wages, however the request arrives.** The employee-onboarding template starts at the platform baseline (`DEFAULT_EMPLOYEE_KEYS`), the catalogue never offers `abn`, and `saveVerificationTemplate` rejects a hand-crafted request containing `abn` with `Unknown verification requirement: abn`.\\n- **Editing the selection is forward-only.** A later edit cannot reach somebody already mid-onboarding \u2014 the employee's employment edge keeps the `verificationKeys` snapshot they were given, while the next person hired gets the new selection. This is the money-adjacent property: an employer who could retroactively add a document requirement to staff they had already paid to verify would be changing what was bought after the charge (ADR-0030 \u00a74).\\n\\nThe trade-invitation template is kept separate from the employee one, and an employment edge with no snapshot reports `null` (falling back to full platform rules) rather than an empty selection.\\n\\n## Payments\\n\\nFiles: `modules/payments/*` \u2014 the largest and most heavily specified context. It covers boot configuration, pricing, the provider boundary, self-checkout, sponsored (bulk) checkout, and refunds.\\n\\n### Boot configuration\\n\\n`paymentsStatus()` from `modules/payments/provider/index.ts` reports:\\n\\n| Code | State | canSell | Condition |\\n|---|---|---|---|\\n| `PAYMENTS_NOT_CONFIGURED` | unavailable | false | No `STRIPE_SECRET_KEY`; detail names the missing key and states that nothing is simulated |\\n| `PAYMENTS_WEBHOOK_NOT_CONFIGURED` | degraded | true | Test key present, webhook secret missing (reconciliation is still a legitimate confirmation path) |\\n| `PAYMENTS_READY` | ready | true | Test key + webhook secret |\\n\\nThe guard refuses to boot on:\\n\\n- a **live key outside the prod tier** (`local`, `dev`, `test`) \u2014 a live key would take real money from real cards during a click-through, and nothing inside the payment module could tell the difference;\\n- a **publishable key** (`pk_`) or a malformed key \u2014 rather than failing at the first sale.\\n\\nRestricted keys (`rk_`) are accepted \u2014 the setup guide recommends them. The status never returns a key or secret, and every tier sells at the same pinned price: $55.00 AUD including GST, version `2026-08-01`.\\n\\n### Pricing\\n\\nAll money amounts come out of `splitTax` and `quote` in `modules/payments/pricing.ts`; a one-cent error here is a one-cent error on a tax invoice.\\n\\n- `splitTax(amountC, mode, rateBp)` extracts GST from a GST-inclusive amount and adds it to a GST-exclusive amount. Inclusive never charges more than displayed; a zero rate or `not_applicable` mode is tax-free; `net + tax === gross` at every amount; half-cents round rather than truncate; non-integer or negative amounts throw.\\n- `quote(price, qty)` prices orders as exact multiples of the unit \u2014 bulk rounding must happen on the unit, not the order, or a receipt fails to add up in front of the customer. It carries the price version and product code forward for the order snapshot and rejects empty or fractional orders.\\n- `activePrice()` always returns a sellable price, so payments never report `PAYMENTS_PRICE_NOT_SET`.\\n\\n### Provider boundary\\n\\nTwo things are proved in `provider/provider.test.ts`: the fake provider honours the contract the payment services are written against, and the Stripe adapter's two pure paths are exercised without a network.\\n\\n**Fake provider (`FakePaymentProvider`).** The idempotency contract is the core: a retried create on the same idempotency key returns the original session rather than selling twice, and a session created just before a timeout is recovered by retry. `failNext({kind})` distinguishes retryable failures (`unavailable`) from non-retryable refusals (`refused`, with a code like `amount_too_small`). Sessions open unpaid, complete paid (or complete-but-unpaid, for delayed payment methods), expire unpaid, and return `null` when unknown. Refunds settle asynchronously and are idempotent under key retry.\\n\\n**Stripe adapter (`StripePaymentProvider`).** `verifyWebhook` accepts only a correctly signed delivery within the tolerance window; it rejects tampered bodies (a valid signature lifted onto a different payload), wrong secrets, replays from outside the window, missing signature headers, and every delivery when no secret is configured. The error message is always exactly `Signature verification failed.` \u2014 it never leaks why.\\n\\nThe payload **allowlist** (`STRIPE_INTERNALS_FOR_TEST.sanitise`) decides what a webhook may leave behind in the database. It strips `customer_details`, `customer`, `receipt_email`, `payment_method_types`, and any unknown field or nested object \u2014 an allowlist, not a blocklist. It keeps only what a replay actually needs: the session id, `amount_total`, `currency`, `payment_status`, `payment_intent`, `client_reference_id`, and `metadata` restricted to string/number values. The dispute evidence deadline is lifted out of `evidence_details` into `evidence_due_by`. The Stripe API version is pinned to `2026-07-29.dahlia`; a `stripe` upgrade that breaks the pin is the review happening.\\n\\n### Checkout and settlement\\n\\nThe payment pipeline, exercised end-to-end against a real database with only the provider substituted:\\n\\n```mermaid\\nflowchart LR\\n    A[startSelfCheckout / startSponsoredCheckout] --&gt; B[createCheckoutSession]\\n    B --&gt; P[(Provider)]\\n    P --&gt;|signed webhook| W[verifyWebhook]\\n    W --&gt; I[insertInboxEvent]\\n    I --&gt; E[processPayEvent]\\n    E --&gt; O[orders.ts / cases.ts]\\n    O --&gt; L[(t_fin_txn ledger)]\\n    O --&gt; C[(t_cycle funded)]\\n```\\n\\n**Eligibility.** `evaluateEligibility(tx, partyId)` decides whether anything can be sold. It refuses a person who has supplied nothing (`NOT_ELIGIBLE_FOR_PAYMENT`, and no order is created), refuses payment while a required document is expired (`action_required` / `evidence_expired`), refuses a suspended profile (`ineligible` / `party_suspended`), and \u2014 when eligible \u2014 snapshots `requirementKeys` so a later catalogue change cannot rewrite what was bought.\\n\\n**Opening Checkout.** `startSelfCheckout` creates one order, one reserved item, and one attempt at $55.00. A double-click gets the same order and the same Checkout URL (one provider session). A provider timeout after create leaves a draft order holding the reservation; retry replays the same idempotency key and adopts the orphaned session rather than selling a second one. An outright provider refusal returns `PAYMENT_PROVIDER_REJECTED` and frees the reservation.\\n\\n**Sponsored checkout.** `startSponsoredCheckout` lets an employer pay for a crew: one order, N reserved cycles, one provider session, N \u00d7 $55.00. The set is all-or-nothing:\\n\\n- anyone in the set who is not a live employee of the paying organisation \u2014 a stranger, another organisation's employee, someone whose employment has ended, or an individual trying to pay through the employer path \u2014 fails with 403 and **creates nothing at all**; no order survives and nothing is left reserved;\\n- an empty set fails with `NO_BENEFICIARIES`; more than `MAX_SEATS_PER_ORDER` fails with `TOO_MANY_BENEFICIARIES`;\\n- a person named twice collapses into one seat;\\n- the prepay exception: an employer may pay for someone who has supplied nothing (the person themselves is still refused), and the eligibility position is recorded on the case with reasons even though it did not block;\\n- double-clicking the same set (order-insensitive) returns the original order; an overlapping set that would charge twice for one person fails with `ALREADY_FUNDED`; an employee whose employer pays on Team cannot self-checkout (`EMPLOYER_PAYS`).\\n\\n**Settlement.** Only a verified provider event marks anything paid \u2014 a redirect to the success URL proves nothing, because no route consumes it. `checkout.session.completed` (and a `payment_intent.succeeded` arriving first, out of order) posts exactly one `FT1` ledger line per order and marks every case `funded`, with `reservedOrderId` cleared. Replayed deliveries are idempotent: the same event, or a differently-keyed event describing the same payment, posts once.\\n\\n**Releasing the person.** `cancelOrder` releases the reservation and makes the case buyable again; a settled order is not cancellable (`ORDER_NOT_CANCELLABLE`), and one payer cannot cancel another's order (403). `sweepExpiredCheckouts` expires abandoned Checkouts and frees the verification; an order the provider says was paid at the last second is held as `processing` for reconciliation, not thrown away.\\n\\n### Beneficiary views\\n\\n`fundingViewFor(tx, partyId)` and `workforceFundingFor(tx, partyIds)` back the UI's \\\"who pays for me\\\" state. Labels progress: `not_ready` \u2192 `payment_required` \u2192 `payment_processing` \u2192 `paid_by_you` (self) / `paid_by_your_organisation` (employer) \u2192 `refund_under_review`. The view never carries an amount, a payer person, or a provider reference \u2014 only the order number, which is what support asks for.\\n\\nFunding is preserved when the evidence behind it lapses: payment buys the work, but it does not make anyone Verified \u2014 withdrawing consent drops the party status while the money stays accounted for.\\n\\n### Refunds\\n\\nThe refund suite proves money can be returned exactly once, and that returning it unwinds everything it bought.\\n\\n- **Requesting.** `requestRefund` opens a request with no amount \u2014 the payer does not choose one; the amount is derived from the order at approval. A request changes nothing about the money or the verification until it settles. A second open request for the same scope fails (`REFUND_ALREADY_OPEN`); an unpaid order is not refundable (`ORDER_NOT_REFUNDABLE`); a disputed charge refuses (`ORDER_DISPUTED`), so the bank and the platform cannot both pay it back; and one payer cannot refund another payer's order (403). The payer can withdraw before a decision, which releases the scope for a new request.\\n- **Approving.** `decideRefund` derives the amount, submits on the persisted idempotency key `aw:refund:{id}`, and records the provider reference immediately \u2014 but while the provider says `pending`, nothing is recorded: no `FT2` ledger line, order stays `paid`, case stays funded. A decline touches nothing. An approved-but-unsent refund is retried on its original key, never re-created.\\n- **Settling.** `reconcileRefunds` settles submitted refunds; the ledger shows one `FT2` line however many times settlement is driven. A settled refund unfunds that seat \u2014 `fundedAt` cleared, the cycle back to `open` and buyable again \u2014 and the rest of a bulk order is untouched (`partially_refunded`). The verification badge goes with the funding: a refunded verification is no longer `S02`.\\n\\n## Running the tests\\n\\n- Unit tests: standard `node --test` (e.g. `npm test -w backend`).\\n- Database tests: `npm run test:db -w backend` \u2014 requires `DATABASE_URL`. The `refund` and `bulk` suites park refunds left behind by earlier runs (scheduling `RF4` rows for tomorrow) so reconciliation settles only its own.\\n- Boot tests: run automatically as part of the unit suite; they spawn child processes via `tsx`, deliberately ignoring the developer's `.env` (clean slate with payment keys cleared) and test the clean-boot behavior.\",\"other-backend\":\"# Other \u2014 backend\\n\\n# @assureworx/backend \u2014 Assureworx API\\n\\n## Overview\\n\\n`@assureworx/backend` is the Assureworx HTTP API, built on **Express 5** and **Node.js 22** (ESM). It is a private workspace package inside the Assureworx monorepo and currently deploys as a **single process** (`dist/server.js`). The codebase is deliberately organised into bounded modules so that individual domains can be extracted into separate Azure Container Apps later without a rewrite.\\n\\nThe service handles the API surface plus background work (BullMQ jobs), file processing, wallet-pass generation, and integrations with Azure, Stripe, Twilio, PostgreSQL, and Redis.\\n\\n```\\nnpm run dev -w @assureworx/backend\\n# \u2192 http://localhost:4000/health\\n# \u2192 http://localhost:4000/api/v1/auth/health\\n```\\n\\n## Repository layout\\n\\nThe build context is the **monorepo root**, not `backend/`. The package depends on one sibling workspace package:\\n\\n| Path | Purpose |\\n|---|---|\\n| `backend/` | This package: Express app, source in `src/`, ops scripts in `scripts/` |\\n| `packages/shared-types/` | Shared TypeScript types **and runtime constants** (e.g. `AU_REGIONS`) consumed by the backend |\\n| `db/migrations/` | SQL migrations \u2014 **not** embedded in the Docker image; applied out-of-band |\\n\\n&gt; The `shared-types` package is not type-only: the backend imports runtime values from it. The Docker build therefore compiles `shared-types` to JavaScript before building the backend (see [Docker image](#docker-image)).\\n\\n## Runtime entrypoint\\n\\nThe production entry point is `src/server.ts`, compiled to `dist/server.js` by `tsc`. The Docker image runs it directly:\\n\\n```json\\n\\\"main\\\": \\\"dist/server.js\\\",\\n\\\"start\\\": \\\"node dist/server.js\\\"\\n```\\n\\nKey runtime characteristics:\\n\\n- **ESM** (`\\\"type\\\": \\\"module\\\"`) with `NodeNext` module resolution.\\n- **Express 5** with `helmet`, `cors`, and `cookie-parser` middleware.\\n- **Rate limiting** via `express-rate-limit` backed by Redis (`rate-limit-redis`).\\n- **Redis** (`ioredis`) used both for rate limiting and as the transport for **BullMQ** job queues.\\n- **PostgreSQL** (`pg`) for persistence.\\n- **JWT** handling via both `jose` (modern, universal) and `jsonwebtoken` (Node-native).\\n- **Request validation** with `zod`.\\n\\n### Health endpoints\\n\\n- `GET /health` \u2014 process/liveness health.\\n- `GET /api/v1/auth/health` \u2014 auth module health.\\n\\n## Docker image\\n\\nThe `backend/Dockerfile` is a three-stage build. Build args `DEPLOY_COMMIT`, `DEPLOY_BUILT_AT`, and `DEPLOY_DIRTY` are recorded as OCI labels.\\n\\n```mermaid\\ngraph LR\\n    A[\\\"deps stagenpm ci (workspace)\\\"] --&gt; B[\\\"build stagetsc shared-typestsc backend\\\"]\\n    B --&gt; C[\\\"runner stagenode dist/server.js\\\"]\\n    C --&gt; D[(\\\"PostgreSQLmigrations from db/\\\")]\\n    C --&gt; E[(Redis)]\\n    C --&gt; F[\\\"AzureEmail \u00b7 Blob \u00b7 Monitor\\\"]\\n    C --&gt; G[\\\"Stripe\\\"]\\n    C --&gt; H[\\\"Twilio\\\"]\\n    C --&gt; I[\\\"Wallet passespasskit-generator\\\"]\\n```\\n\\n### Stage 1 \u2014 deps\\n\\nInstalls all workspace dependencies with `npm ci -w @assureworx/backend --include-workspace-root`, using only the three `package.json` files (root, backend, shared-types) to maximise layer caching.\\n\\n### Stage 2 \u2014 build\\n\\nCopies the full source for `backend/` and `packages/shared-types/`, then:\\n\\n1. Compiles `shared-types` with `tsc -p packages/shared-types/tsconfig.json` \u2014 required because the backend imports compiled runtime values.\\n2. Builds the backend with `npm run build -w @assureworx/backend` (`tsc -p tsconfig.json`).\\n\\n### Stage 3 \u2014 runner\\n\\n- Base: `node:22-alpine`.\\n- Adds a non-root user `awx`; the container runs as `USER awx`.\\n- Sets `NODE_ENV=production`, `PORT=4000`, and `EXPOSE 4000`.\\n- Copies compiled output only: `backend/dist`, `backend/package.json`, `shared-types/dist`, `shared-types/package.json`, and the root `package.json`.\\n- **Patches** `packages/shared-types/package.json` at build time so that `main`, `types`, and `exports` point at `./dist/index.js` instead of the TypeScript sources used by `tsx` in local dev.\\n- `CMD [\\\"node\\\", \\\"dist/server.js\\\"]` with working directory `/app/backend`.\\n\\nSQL migrations are intentionally **not** copied into the image. They are applied from `db/migrations/` outside the container lifecycle.\\n\\n## Scripts\\n\\nAll scripts are run with `npm run  -w @assureworx/backend` from the repo root.\\n\\n### Development &amp; build\\n\\n| Script | Command | Purpose |\\n|---|---|---|\\n| `dev` | `tsx watch --env-file=.env src/server.ts` | Hot-reload dev server; loads `.env` |\\n| `build` | `tsc -p tsconfig.json` | Compile to `dist/` |\\n| `start` | `node dist/server.js` | Run compiled output |\\n| `typecheck` | `tsc -p tsconfig.json --noEmit` | Type-check without emitting |\\n\\n### Testing\\n\\nTests use the Node.js built-in test runner (`node --test`) via `tsx` as the loader. Tests are colocated with source.\\n\\n| Script | Command | Scope |\\n|---|---|---|\\n| `test` | `node --import tsx --test \\\"src/**/*.test.ts\\\"` | Unit/integration tests (no external services) |\\n| `test:watch` | same with `--watch` | Watch mode |\\n| `test:db` | `node --env-file-if-exists=.env --import tsx --test \\\"src/**/*.dbtest.ts\\\"` | Tests that require a real database (`*.dbtest.ts`); loads `.env` only if present |\\n\\n### Operations &amp; utilities\\n\\nThese are developer/ops tools, not part of the runtime API:\\n\\n| Script | File | Purpose |\\n|---|---|---|\\n| `seed` | `src/seed.ts` | Populate initial data |\\n| `invite` | `scripts/mint-invite.mjs` | Mint an invite token |\\n| `purge` | `scripts/purge-party.mjs` | Remove/clean up a party record |\\n| `verify:identity` | `scripts/verify-identity.ts` | Run identity verification flow against a record |\\n| `probe:webhook` | `scripts/probe-webhook.mjs` | Smoke-test webhook delivery |\\n| `probe:requirements` | `scripts/probe-engagement-requirements.ts` | Inspect engagement requirement data |\\n| `probe:extraction` | `scripts/probe-extraction.ts` | Test document extraction pipeline |\\n| `probe:cloud-read` | `scripts/probe-cloud-read.ts` | Verify Azure Blob read access |\\n| `probe:google-wallet` | `scripts/probe-google-wallet.mts` | Test Google Wallet pass generation |\\n\\nMost scripts expect a `.env` file (or environment variables) for credentials.\\n\\n## Integration surface\\n\\nThe dependency set defines the external services the backend talks to:\\n\\n| Area | Dependencies |\\n|---|---|\\n| HTTP / middleware | `express@5`, `helmet`, `cors`, `cookie-parser`, `express-rate-limit`, `multer` |\\n| Persistence | `pg` (PostgreSQL) |\\n| Queues / cache | `ioredis`, `bullmq`, `rate-limit-redis` |\\n| Auth / tokens | `jose`, `jsonwebtoken`, `zod` |\\n| Azure | `@azure/communication-email`, `@azure/identity`, `@azure/storage-blob`, `@azure/monitor-opentelemetry` |\\n| Payments | `stripe` |\\n| Messaging | `twilio` (SMS/voice) |\\n| Documents &amp; media | `sharp` (image processing), `pdf-lib` (PDF create/edit), `unpdf` (PDF extraction) |\\n| Wallet passes | `passkit-generator` (Apple Wallet), Google Wallet probe |\\n| Phones | `libphonenumber-js` |\\n| Shared code | `@assureworx/shared-types` |\\n\\nThis surface supports capabilities including: document upload/extraction, identity verification, engagement requirement probing, wallet pass issuance, email/SMS notifications, payment processing, and background jobs \u2014 all exposed through the modular `src/` structure and exercised by the probe scripts.\\n\\n## TypeScript configuration\\n\\n`backend/tsconfig.json` targets `ES2022` with `NodeNext` module resolution, `strict` mode, declaration emit, and source maps. Only `src/**/*` is compiled; `dist/` is git-ignored build output.\\n\\n## Code quality\\n\\n- **Linting**: ESLint flat config (`eslint.config.mjs`) with `@eslint/js` recommended + `typescript-eslint` recommended. `dist/` and `node_modules/` are ignored. For `.mjs` files, Node globals (`Buffer`, `console`, `fetch`, `process`) are declared explicitly. For `.ts` files, unused variables are a warning with `argsIgnorePattern: \\\"^_\\\"` so underscore-prefixed parameters are allowed.\\n- **Type-checking**: `npm run typecheck`.\\n- **Formatting**: not enforced in this package (no Prettier config present).\\n\\n## Deployment notes\\n\\n- The image listens on `PORT=4000` as non-root user `awx`.\\n- `NODE_ENV=production` is set in the image; local dev uses `.env` via `tsx --env-file`.\\n- Build metadata (commit, timestamp, dirty flag) is injected via `ARG` and exposed as OCI labels for traceability.\\n- Migrations live in `../../db/migrations/` and are applied outside the container (not embedded, not run at boot).\\n- Physical\u2194logical schema mapping is documented in `wiki/architecture/schema-mapping.md`.\\n- Phase 1 runs everything in one process; module boundaries in `src/` are the seams for future extraction into separate Container Apps.\",\"other-claude-md\":\"# Other \u2014 CLAUDE.md\\n\\n# CLAUDE.md \u2014 GitNexus Agent Governance Module\\n\\n## Purpose\\n\\n`CLAUDE.md` is a root-level agent instruction file consumed by Claude Code at session start. Its role in the **assureworx_app** repository is to mandate how the agent interacts with the codebase through the **GitNexus** code-intelligence index.\\n\\nIt is not executable code \u2014 it has no call graph, no execution flows, and no runtime entry points. Its \\\"integration surface\\\" is behavioral: it constrains every edit, exploration, and commit the agent performs by requiring the agent to consult the GitNexus index first.\\n\\nThe file is wrapped in an explicit module boundary:\\n\\n```markdown\\n\\n...\\n\\n```\\n\\nThis marker pair delimits the GitNexus-managed region, letting tooling (and the agent) distinguish generated/contractual content from free-form instructions.\\n\\n## The Index Contract\\n\\nThe file opens by asserting the repository's index state:\\n\\n| Property | Value |\\n|----------|-------|\\n| Indexed project | `assureworx_app` |\\n| Symbols | 7055 |\\n| Relationships | 17119 |\\n| Execution flows | 300 |\\n\\nThis establishes the scale of the dependency graph the agent is expected to navigate. The file also defines the remediation path if the index is stale:\\n\\n- `node .gitnexus/run.cjs analyze` \u2014 run from the project root; auto-selects an available runner\\n- `npx gitnexus analyze` \u2014 fallback when the local runner is absent\\n- `npm i -g gitnexus` \u2014 global install path (documented workaround for npm 11 crash, issue #1939)\\n\\n## Behavioral Directives\\n\\nThe core of the module is a pair of mandated/forbidden behavior lists. These are hard constraints on the agent's edit workflow, not suggestions.\\n\\n### Always Do\\n\\n| Directive | Required tool call | Gate |\\n|-----------|-------------------|------|\\n| Analyze blast radius **before** touching any symbol | `impact({target: \\\"symbolName\\\", direction: \\\"upstream\\\"})` | Report direct callers, affected processes, and risk level to the user **before** editing |\\n| Verify change scope **before** committing | `detect_changes()` | Confirms only expected symbols/flows were affected. Use `detect_changes({scope: \\\"compare\\\", base_ref: \\\"main\\\"})` for regression review against the default branch |\\n| Warn on elevated risk | \u2014 | If `impact` returns **HIGH** or **CRITICAL**, the agent must warn the user before proceeding with edits |\\n| Explore unfamiliar code via flows, not grepping | `query({search_query: \\\"concept\\\"})` | Returns process-grouped results ranked by relevance |\\n| Get full symbol context | `context({name: \\\"symbolName\\\"})` | Callers, callees, and participating execution flows for a single symbol |\\n| Security review | `explain({target: \\\"fileOrSymbol\\\"})` | Lists taint findings (source\u2192sink flows; requires `analyze --pdg`) |\\n\\n### Never Do\\n\\n- Never edit a function, class, or method without first running `impact` on it.\\n- Never ignore HIGH or CRITICAL risk warnings from impact analysis.\\n- Never rename symbols with find-and-replace \u2014 `rename` understands the call graph.\\n- Never commit without running `detect_changes()` to check affected scope.\\n\\n## Enforced Edit Workflow\\n\\nThe directives compose into a single mandated workflow that gates every code modification:\\n\\n```mermaid\\nflowchart TD\\n    A[Agent receives edit task] --&gt; B[Run impact on target symbol]\\n    B --&gt; C{Risk HIGH or CRITICAL?}\\n    C --&gt;|Yes| D[Warn user before proceeding]\\n    D --&gt; E[Edit symbol; use rename for renames]\\n    C --&gt;|No| E\\n    E --&gt; F[Run detect_changes before commit]\\n    F --&gt; G{Affected scope as expected?}\\n    G --&gt;|Yes| H[Commit]\\n    G --&gt;|No| I[Re-evaluate / adjust changes]\\n    I --&gt; E\\n```\\n\\nThis is the module's operational core: the index is consulted at both ends of every edit \u2014 before, to size the blast radius, and after, to verify containment.\\n\\n## Resources Exposed to the Agent\\n\\nThe module registers GitNexus resources that the agent can read directly:\\n\\n| Resource | Content |\\n|----------|---------|\\n| `gitnexus://repo/assureworx_app/context` | Codebase overview; index freshness check |\\n| `gitnexus://repo/assureworx_app/clusters` | All functional areas |\\n| `gitnexus://repo/assureworx_app/processes` | All execution flows |\\n| `gitnexus://repo/assureworx_app/process/{name}` | Step-by-step execution trace for a named flow |\\n\\n## Skill File Routing\\n\\nFor task-specific depth, the module routes to skill files under `.claude/skills/gitnexus/`:\\n\\n| Task | Skill file |\\n|------|-----------|\\n| Architecture understanding (\\\"How does X work?\\\") | `gitnexus-exploring/SKILL.md` |\\n| Blast radius (\\\"What breaks if I change X?\\\") | `gitnexus-impact-analysis/SKILL.md` |\\n| Bug tracing (\\\"Why is X failing?\\\") | `gitnexus-debugging/SKILL.md` |\\n| Rename / extract / split / refactor | `gitnexus-refactoring/SKILL.md` |\\n| Tools, resources, schema reference | `gitnexus-guide/SKILL.md` |\\n| Index, status, clean, wiki CLI commands | `gitnexus-cli/SKILL.md` |\\n\\n## Relationship to the Codebase\\n\\nBecause `CLAUDE.md` is a directive file, its connections to the rest of the repository are indirect but operationally significant:\\n\\n- **Edit gatekeeper** \u2014 every symbol mutation in `assureworx_app` is supposed to pass through the workflow defined here.\\n- **MCP tool layer** \u2014 the named functions (`impact`, `detect_changes`, `query`, `context`, `explain`, `rename`) are the agent-facing surface of the GitNexus MCP server, backed by the index of 7055 symbols and 300 execution flows.\\n- **CLI tooling** \u2014 the `.gitnexus/run.cjs` runner referenced here is the local entry point for index maintenance.\\n- **Skill library** \u2014 the `.claude/skills/gitnexus/` directory contains the detailed procedural knowledge this file routes to.\\n\\nIn short: this module binds the agent to the code-intelligence index. It converts the repository's dependency graph into a mandatory pre- and post-edit check, ensuring no change is made blind to its blast radius and no commit lands without verified scope containment.\",\"other-db-migrations\":\"# Other \u2014 db-migrations\\n\\n# db-migrations\\n\\n## Overview\\n\\n`db-migrations` is the ordered PostgreSQL migration suite that owns the entire Assureworx database schema (`assureworx`). It is not application code that runs in the API process \u2014 it is applied at deployment time by a migration runner, and the application connects afterwards as a least-privilege runtime role.\\n\\nThe suite evolved through two distinct schema generations:\\n\\n1. **Migration 001** \u2014 an initial domain-named schema (`tier1_organisations`, `trade_organisations`, `individuals`, `projects`, `sites`, `engagements`). All tables were empty and were dropped by migration 002.\\n2. **Migrations 002+** \u2014 the current enterprise schema: a deliberately generic **party model** (FINEOS-style) with two schemas:\\n   - `core` \u2014 application data, with obfuscated physical names (`t_party`, `t_link`, `t_artifact`, \u2026)\\n   - `meta` \u2014 a restricted in-database dictionary (`map_dict`, `code_dict`) that gives every obfuscated table, column, and code value its logical meaning\\n\\nThe physical naming is a deliberate architectural decision (ADR-0007): **do not add domain-revealing names** (trade/tier1/compliance/verified/licence/\u2026) to schema `core`. The logical model lives in `meta` and in `wiki/architecture/schema-mapping.md`. If you add a table, column, or code value, you are expected to add the corresponding `meta` entries in the same migration.\\n\\nShared conventions across all tables:\\n\\n- UUID primary keys (`id`), `gen_random_uuid()` default\\n- Soft delete via nullable `deleted_at`; indexes filter on `deleted_at IS NULL`\\n- `TIMESTAMPTZ` for all timestamps\\n- Suffix conventions: `*_cd` = code, `*_on` = date, `*_at` = timestamp\\n- `attrs JSONB` as an extension bag on most tables\\n- Code values are constrained by `CHECK` constraints in DDL and their meanings live in `meta.code_dict`\\n\\n## Migration Map\\n\\n| Migration | Theme | Content |\\n|---|---|---|\\n| `001_core_orgs_and_people.sql` | Original domain schema | `schema_migrations`, `tier1_organisations`, `trade_organisations`, `individuals`, `projects`, `sites`, `engagements`. Replaced entirely by 002. |\\n| `002_enterprise_core.sql` | Party-model foundation | Drops all 001 tables. Creates `core` + `meta` schemas, `app_rt` runtime role, trigger functions, 22 core tables, touch triggers, owner-scoped RLS, meta dictionary seeds, artifact/tag catalogs. |\\n| `003_hardening.sql` | Concurrency + wallet store | `ux_artifact_live_class` (one live artifact per party+class); `t_pass_dl` ephemeral one-shot Apple Wallet download store. |\\n| `004_account_invites.sql` | Account invites | `t_seat` \u2014 PC bootstrap, PC colleague seats, trade employee invites. |\\n| `005_support_requests.sql` | Support desk | `t_sr`, `t_sr_note` with SLA clocks; `seq_sr_ref`; actor-scoped RLS. |\\n| `006_identity_portability.sql` | Identity &amp; portability | `t_party.public_ref` (AWX-XXXXX-XXXXX); identifier kind `D04` (KYC subject); request kind `Q04` (connection request). |\\n| `007_email_change.sql` | Email change | `t_email_change` \u2014 dual-verify old then new inbox. |\\n| `008_security_hardening.sql` | Session security | `t_actor.session_ver` for JWT revocation; `FORCE ROW LEVEL SECURITY` on scoped tables. |\\n| `009_rls_identity_evidence.sql` | RLS defence-in-depth | Helpers `fn_bypass`/`fn_scope`/`fn_actor`/`fn_in_network`; RLS on `t_grant`, `t_party`, `t_rel`, `t_artifact`, `t_party_ident`. |\\n| `010_sync_link_readiness.sql` | Readiness backfill | One-time sync of `t_link.state_cd` from `t_party.status_cd`. |\\n| `011_identity_consent_and_meter.sql` | Consent + metering | `t_consent` append-only consent events; `t_check.doc_expires_on`/`reason_cd`; `t_meter` monthly provider spend caps. |\\n| `012_check_sequence.sql` | Deterministic ordering | `t_check.seq_n` BIGSERIAL tiebreaker for \u201clatest check\u201d reads. |\\n| `013_check_contact_email.sql` | Check reconciliation | `t_check.contact_email_txt` \u2014 sign-in email at check start. |\\n| `014_rls_remaining_tables.sql` | RLS defence-in-depth | RLS on `t_check`, `t_seat`, `t_msg`, `t_ledger`, `t_party_tag`, `t_pass_dl`. |\\n| `015_trade_engagement_visibility.sql` | Trade-side visibility | `fn_employs`/`fn_node_engages`; trade read arms on `t_link`/`t_node`; reverse network arm on `p_party`. |\\n| `016_audit_enrichment.sql` | Audit | `t_event.corr_id`/`ua_txt`; timeline indexes; permissive insert policy re-documented. |\\n| `017_engagement_requirements.sql` | Engagement requirements | `t_req_set`/`t_req_item`/`t_link_tag`; `t_link.gap_hard_n`/`gap_soft_n`/`evaluated_at`; request kind `Q05` (top-up); identifier `D05` (trade licence); `t_party.pass_ver`. |\\n| `018_meta_pass_dl.sql` | Dictionary gap | `meta.map_dict` entry for `t_pass_dl`. |\\n| `019_meta_read_via_definer.sql` | Dictionary access | `fn_map_dict()`, `fn_schema_migrations()` \u2014 SECURITY DEFINER projections for the platform console. |\\n| `020_perf_indexes.sql` | Performance | Indexes for auth login, Didit webhooks, review queues, project/site lists, requests, idempotency/expiry sweeps. |\\n| `021_party_locality_and_seat_attrs.sql` | Locality | `t_party.locality_txt`/`postal_cd`/`region_cd`; `t_seat.attrs` for deferred placement. |\\n| `022_invitation_verification_templates.sql` | Invitation templates | `t_vfy_tpl` reusable default; `t_request.verification_keys` immutable per-invitation snapshot. |\\n| `023_verification_cases.sql` | Paid verification | `t_cycle` \u2014 per-party verification cycles with eligibility, funding, and reservation slots. |\\n\\n## Schema Architecture\\n\\n### `core` schema \u2014 the party model\\n\\nAll application data lives in `core`. The model is a hub-and-spoke design around `t_party`:\\n\\n```mermaid\\ngraph LR\\n    P[\\\"t_partyorgs &amp; people\\\"] --&gt; I[\\\"t_party_identABN, ACN, email, KYC, licence\\\"]\\n    P --&gt; A[\\\"t_actorlogin principals\\\"]\\n    A --&gt; G[\\\"t_grantroles / membership\\\"]\\n    P --&gt; R[\\\"t_reltyped edges\\\"]\\n    P --&gt; N[\\\"t_nodeprojects / sites\\\"]\\n    N --&gt; L[\\\"t_linkengagements\\\"]\\n    P --&gt; AR[\\\"t_artifactevidence\\\"]\\n    AR --&gt; C[\\\"t_checkverification events\\\"]\\n    P --&gt; CY[\\\"t_cyclepaid verification\\\"]\\n```\\n\\nThe table groups:\\n\\n| Cluster | Tables | Purpose |\\n|---|---|---|\\n| Party &amp; identity | `t_party`, `t_party_ident` | Every organisation and person; external identifiers (`D01` ABN, `D02` ACN, `D03` login email, `D04` KYC subject `provider:subject`, `D05` trade licence `jurisdiction:number`) |\\n| Access control | `t_actor`, `t_grant` | Login principals keyed on IdP `sub`; RBAC membership of an actor within an org party |\\n| Relationships | `t_rel` | Typed edges between parties: `L01` employment, `L02` network membership, `L03` direct engagement, `L04` subcontract |\\n| Hierarchy | `t_node` | Owner-scoped project/site tree: `N01` project (no parent), `N02` site (parent = project) |\\n| Engagement | `t_link`, `t_link_tag` | A party\u2019s participation under an owner, optionally at a node; `state_cd` readiness (`J01`\u2013`J04`); classifications engaged for on that placement |\\n| Evidence | `t_artifact`, `t_artifact_class`, `t_check` | Uploaded compliance documents; the document-type catalog; verification events (ABR, KYC, manual, licence API) |\\n| Verification | `t_consent`, `t_cycle`, `t_meter` | Append-only consent trail; paid verification cycles; monthly provider spend caps |\\n| Catalogs | `t_tag`, `t_tag_class`, `t_party_tag` | Trade classifications; required document types per classification; a party\u2019s self-selected classifications |\\n| Training | `t_course`, `t_task`, `t_result` | Owner-authored content; assignment scopes (`W01`\u2013`W04`); immutable completion records |\\n| Requirements | `t_req_set`, `t_req_item` | Engagement-scoped requirement layers 4\u20135 (ADR-0005): organisation-wide, project/site, or single-placement |\\n| Operations | `t_case`, `t_case_file`, `t_sr`, `t_sr_note` | Owner-private incident records + attachments; in-app support tickets with SLA clocks |\\n| Messaging | `t_msg` | In-app (`Z01`) and email (`Z02`) notifications |\\n| Commercial | `t_ledger` | Subscription standing (`PL1` paid trade plan, `PL2` complimentary) |\\n| Requests &amp; invites | `t_request`, `t_seat`, `t_email_change`, `t_vfy_tpl` | Onboarding/connection/top-up wizard chain; account invites; dual-verify email changes; reusable verification templates |\\n| Infrastructure | `t_event`, `t_idem`, `t_pass_dl` | Append-only audit log; idempotency keys; ephemeral wallet download payloads |\\n\\n### `meta` schema \u2014 the dictionary\\n\\n`meta` is restricted: `REVOKE ALL ON SCHEMA meta FROM PUBLIC`, and `app_rt` has no read access. It contains two tables:\\n\\n- `meta.map_dict` \u2014 one row per physical table: `logical_name`, `meaning_txt`, and a `columns` JSONB map explaining each column. This is the authoritative bridge between obfuscated physical names and the logical domain model.\\n- `meta.code_dict` \u2014 one row per code value, keyed by `(domain_cd, code)`. Examples: `PARTY_KIND` K01/K02/K03, `LINK_STATE` J01\u2013J04, `ARTIFACT_STATUS` F01\u2013F04, `TAG` G01\u2013G17, `REQUEST_KIND` Q01\u2013Q05.\\n\\nThe platform console reads the dictionary through `core.fn_map_dict()` and `core.fn_schema_migrations()` \u2014 SECURITY DEFINER projections added in migration 019 \u2014 so the ERD stays factual without opening `meta` to the runtime role.\\n\\n### `schema_migrations`\\n\\nTracked in an unqualified `schema_migrations` table (`version` TEXT PK, `applied_at`), created in 001 and re-created in 002. Each migration inserts its own version with `ON CONFLICT (version) DO NOTHING`.\\n\\n## Naming &amp; Code Conventions\\n\\nCode values are constrained twice: the DDL `CHECK` list defines the valid set, and `meta.code_dict` defines the meaning. When a migration extends a code domain (e.g., adding `D04`/`D05` identifiers or `Q04`/`Q05` request kinds), it drops and re-adds the `CHECK` constraint and inserts the new meanings into `code_dict` in the same script.\\n\\nKey code domains:\\n\\n| Domain | Values | Meaning |\\n|---|---|---|\\n| `PARTY_KIND` | `K01` / `K02` / `K03` | Principal Contractor org / Trade org / Individual |\\n| `PARTY_STATUS` | `S01`\u2013`S04` | Pending / Assureworx Verified / Action required / Suspended |\\n| `LINK_STATE` | `J01`\u2013`J04` | Engagement readiness: Pending / Ready / Blocked / Action required |\\n| `REQUEST_KIND` | `Q01`\u2013`Q05` | Direct company / Direct individual / Sub-request / Connection to existing party / Top-up against existing placement |\\n| `ARTIFACT_STATUS` | `F01`\u2013`F04` | Pending verification / Verified (manual) / Rejected / API verified |\\n| `CHECK_KIND` | `C01`\u2013`C04` | Business registry / Identity KYC / Manual document review / Licence authority API |\\n| `TAG` | `G01`\u2013`G17` | Trade classifications (e.g., G01 = Electrician \u2014 meaning only in `meta.code_dict`) |\\n\\nSeed data in 002 establishes the platform catalogs: universal document types `A001`\u2013`A005` (White Card, Photo ID, Business Registration, Public Liability \u2265 $10M, Workers Comp), trade-specific types `A101`\u2013`A116`, the 17 trade classifications `G01`\u2013`G17`, and the `t_tag_class` mapping of required document types per classification.\\n\\n## Security Model\\n\\n### `app_rt` runtime role\\n\\nThe application never connects as the table owner. Migration 002 creates `app_rt` (NOLOGIN) and grants `SELECT, INSERT, UPDATE, DELETE` on all current and future `core` tables. The runtime role has no DDL rights and no access to `meta`.\\n\\nImmutability is enforced by both privilege and trigger:\\n\\n- `REVOKE UPDATE, DELETE ON core.t_event FROM app_rt` and likewise on `t_result`\\n- `core.tg_no_touch()` raises on `UPDATE`/`DELETE` for `t_event` and `t_result`\\n- `t_consent` grants only `SELECT, INSERT` \u2014 withdrawal is a new row, never an update\\n\\n### Session GUC contract\\n\\nRLS policies are driven by three session-local settings that the API layer sets per request/transaction:\\n\\n- `SET LOCAL app.scope = ''` \u2014 tenant scope\\n- `SET LOCAL app.actor = ''` \u2014 the calling login principal\\n- `SET LOCAL app.bypass = 'on'` \u2014 platform-admin paths only, always audited\\n\\nThese are set by the repository-layer helpers `withScope` / `withActor` / `withBypass` / `withTenant` (referenced in migration comments; primary access enforcement lives in the repositories per ADR-0003, with RLS as defence-in-depth).\\n\\n### Row-level security\\n\\nRLS is enabled and then **forced** (`ALTER TABLE ... FORCE ROW LEVEL SECURITY`) so that even a table-owner connection cannot bypass policies. Tables under RLS:\\n\\n- Owner-scoped (002): `t_node`, `t_link`, `t_request`, `t_course`, `t_task`, `t_case`, `t_case_file`\\n- Actor-scoped (005): `t_sr`, `t_sr_note`\\n- Identity/evidence (009): `t_party`, `t_party_ident`, `t_actor`-adjacent `t_grant`, `t_rel`, `t_artifact`\\n- Remaining sensitive tables (014): `t_check`, `t_seat`, `t_msg`, `t_ledger`, `t_party_tag`, `t_pass_dl`\\n- Requirements (017): `t_req_set`, `t_req_item`, `t_link_tag`\\n- Templates (022): `t_vfy_tpl`\\n- `t_event`: insert is permissive, select is scope-bound (see below)\\n\\nPolicies follow a consistent shape: a bypass arm, cheap equality on indexed scope columns, then SECURITY DEFINER network checks. Migration 015 adds the trade-side read arms: a party can read the `t_link` rows it is a party to (or, for a trade company, its employees\u2019 links), and the `t_node` rows it is engaged at \u2014 while writes remain owner-only.\\n\\n`t_event` is intentionally special: `p_event_ins` is permissive because a trade mutation often writes an event scoped to the principal contractor (so it appears on the PC timeline) while `app.scope` is the trade. Read isolation stays on `p_event_sel`; immutability is enforced by the `REVOKE` + trigger.\\n\\n### SECURITY DEFINER helper functions\\n\\nRLS policies need to query `t_rel` and `t_link`, which are themselves RLS-protected \u2014 that would recurse. The migrations add a set of `SECURITY DEFINER` helpers (`SET search_path = core`) that run with definer privileges and are callable from policies:\\n\\n- `core.fn_bypass()` \u2014 true when `app.bypass = 'on'`\\n- `core.fn_scope()`, `core.fn_actor()` \u2014 typed readers for the GUCs\\n- `core.fn_in_network(p_party, p_scope)` \u2014 true when a party is in a Tier 1\u2019s network (direct `L02`/`L03`, or employed via `L01` under an `L02` company)\\n- `core.fn_employs(p_org, p_person)` \u2014 true when the person has a live `L01` edge to the trade org\\n- `core.fn_node_engages(p_node, p_scope)` \u2014 true when the scope (or its employee) is linked at the node or any site beneath it; soft-deleted links still count so trades can see completed jobs\\n\\nAll are revoked from `PUBLIC` and granted only to `app_rt`.\\n\\n## Data Integrity Patterns\\n\\n### Soft delete and live uniqueness\\n\\nSoft delete plus partial unique indexes (`WHERE deleted_at IS NULL`) is the standard pattern for \u201cone live row per X\u201d:\\n\\n- one live identifier per `(kind_cd, value_txt)` \u2014 `t_party_ident`\\n- one live actor per IdP subject \u2014 `t_actor`\\n- one live grant per `(actor_id, party_id, role_cd)` \u2014 `t_grant`\\n- one live link per `(owner_party_id, party_id, node_id)` \u2014 `t_link`\\n- one live artifact per `(party_id, class_cd)` \u2014 `t_artifact` (003, prevents concurrent upload duplicates)\\n- one live ledger row per `(party_id, plan_cd)` \u2014 `t_ledger`\\n- one live cycle per party \u2014 `t_cycle` (status `VC1`\u2013`VC3`), which stops a second case being opened to dodge the \u201calready funded\u201d check\\n\\nThe one exception is `t_party.public_ref`: its unique index is **unconditional** \u2014 a public Assureworx ID is never reused, even after soft delete.\\n\\n### Deterministic ordering\\n\\n`NOW()` is the transaction start time, so two rows written in the same transaction share an identical `created_at` and `ORDER BY created_at DESC` can resolve arbitrarily. Migration 011 (for `t_consent`) and 012 (for `t_check`) add a `BIGSERIAL seq_n` column as an insertion-order tiebreaker; read paths order by `(created_at DESC, seq_n DESC)`. The migration comments document this defect explicitly \u2014 do not \u201csimplify\u201d by removing `seq_n`.\\n\\n### Append-only tables\\n\\n- `t_event` and `t_result` \u2014 `tg_no_touch` trigger + `REVOKE UPDATE, DELETE`\\n- `t_consent` \u2014 no `DELETE` grant; withdrawal is a new `Y02` row; the retained trail is the evidence that withdrawal was handled properly\\n\\n### Optimistic concurrency\\n\\n`t_cycle.ver_n` is an optimistic-concurrency counter \u2014 every service write asserts the version it read.\\n\\n### Provider spend caps\\n\\n`t_meter` enforces the monthly cap on billable third-party (e.g., Didit) calls with a conditional increment: `UPDATE ... WHERE used_n &lt; cap_n`. The cap is a race-free hard stop, not an advisory check, and is set below the vendor free tier.\\n\\n### Snapshots and projection counters\\n\\nTwo patterns keep derived state from going stale:\\n\\n- **Immutable snapshots**: `t_request.verification_keys` copies the verification template at send time (022); `t_cycle.requirement_keys` snapshots what a verification cycle was priced against (023). Editing a reusable default never mutates an invitation already sent.\\n- **Projection counters**: `t_link.gap_hard_n` / `gap_soft_n` / `evaluated_at` let a site board render without re-resolving every placement, while the gap detail itself is always recomputed on read (017). `t_party.pass_ver` is a wallet-pass generation counter \u2014 a pass carrying an older value is known superseded, so re-issuing the card is a recompute keyed on `public_ref`, never a new identifier.\\n\\n## Relationship to Application Code\\n\\n- **Schema lifecycle**: migrations are applied out-of-band before deployment; the API and services connect as `app_rt`. Migration 019 exposes `core.fn_schema_migrations()` so the platform console can list applied versions without reading the migrations table directly.\\n- **Access control**: repositories remain the primary enforcement point (ADR-0003); RLS is defence-in-depth. Repository helpers `withScope` / `withActor` / `withBypass` / `withTenant` set the GUCs that every policy depends on.\\n- **Status computation**: `t_party.status_cd` is written only by `evaluateAndPersistStatus`; `t_link.state_cd` is maintained by `syncLinkReadiness` (the ongoing counterpart to the 010 backfill). Migration 017 replaced the old `READINESS_BY_STATUS` mirror in `shared/services/verified-status.ts` with computed gap counts against requirement sets.\\n- **Identity verification**: Didit webhook handling looks up `t_check` rows by `provider_ref_txt` + `provider_cd` (indexed in 020); `contact_email_txt` (013) reconciles a KYC outcome to a profile when the session cookie is not the join key; `doc_expires_on` is the primary re-verification trigger (ADR-0015). `t_meter` guards the billable call volume.\\n- **Wallet passes**: a pass is a projection of `t_party.public_ref` + `pass_ver`; the `.pkpass` payload is served once through a `t_pass_dl` token (bypass-scoped, ~15-minute TTL).\\n- **Invitations**: the wizard drives `t_request` kinds `Q01`\u2013`Q05`; `Q05` top-up requests are one-per-placement (`ux_request_topup_live`) and target an existing `t_link`. The invited checks are read from `t_request.verification_keys`, set at send time from the organisation\u2019s `t_vfy_tpl`.\\n- **Paid verification**: `t_cycle` is the chargeable unit. It carries its own eligibility position (server-computed, never browser-supplied), a funding snapshot (`funder_name_txt` is all the beneficiary sees), and a reservation slot. `funding_item_id` and `reserved_order_id` reference order tables introduced in migration 024, which is not part of this set \u2014 no FKs are created here. Closing a cycle and opening `cycle_n + 1` is a commercial act; nothing in `t_cycle` writes `t_party.status_cd`.\\n- **Data dictionary**: any service or console that needs the logical meaning of a table reads `core.fn_map_dict()` (SECURITY DEFINER). When extending the schema, keep `meta.map_dict` / `meta.code_dict` updated in the same migration \u2014 the physical schema is only legible through the dictionary.\",\"other-design-md\":\"# Other \u2014 DESIGN.md\\n\\n# DESIGN.md \u2014 Assureworx Design System\\n\\n## Purpose\\n\\n`DESIGN.md` is the canonical design specification for the Assureworx frontend. It defines the visual language, component contracts, and interaction rules that all UI work in `frontend/` must follow. It is a reference document, not an executable module \u2014 it does not export functions, consume APIs, or participate in runtime execution.\\n\\nThe document serves three audiences:\\n\\n- **Frontend developers** implementing components under `frontend/src/components/`\\n- **Reviewers** checking PRs against design rules\\n- **Designers** maintaining consistency across Principal Contractor and Trade surfaces\\n\\n## Design Philosophy: \\\"The Evidence Desk\\\"\\n\\nThe product presents itself as a calm, credible operations console \u2014 the creative north star is literally **\\\"The Evidence Desk\\\"**. The UI is restrained: roughly 60% white/Ash, Carbon-structure text, and Electric Blue limited to \u226410% of any screen. It explicitly rejects:\\n\\n- Neon/candy SaaS aesthetics and glassmorphism\\n- AI-sparkle decoration\\n- Dense enterprise grey\\n- Hero-metric card grids (the dashboard uses a status table instead)\\n\\nStatus is never communicated by colour alone. Every status indicator pairs a colour, an icon, and a text label.\\n\\n## Token Architecture\\n\\nThe source of truth for all design tokens is `frontend/src/styles/tokens.css`. `DESIGN.md` mirrors those tokens in its YAML frontmatter for documentation and design tooling, but `tokens.css` is authoritative. All colour and typography in the app must reference these tokens \u2014 hard-coded values are not permitted.\\n\\n```mermaid\\nflowchart LR\\n    A[tokens.csssource of truth] --&gt; B[CSS Modulescomponent styles]\\n    B --&gt; C[frontend/src/componentsStatusChip, InputField, SelectField, nav]\\n    C --&gt; D[App shellsidebar + top bar + workspace]\\n    D --&gt; E[Role-aware chromePrincipal Contractor / Trade]\\n```\\n\\n### Color Tokens\\n\\n| Group | Tokens | Usage |\\n|---|---|---|\\n| Primary | `electric-blue` (#3E6AE1), `electric-dark` (#2D4FA3) | Primary actions, active nav, links; dark = hover/pressed |\\n| Accent | `cyan`, `violet`, `deep-violet` | Network accents only; deep violet doubles as portable-status foreground (AA on its background) |\\n| Text ramp | `carbon`, `graphite`, `pewter` | Primary, secondary, and placeholder text respectively |\\n| Chrome | `silver` | Borders and icons only \u2014 never readable text |\\n| Surfaces | `cloud`, `ash`, `canvas` | Borders, workspace background, panel/card background |\\n| Status pairs | `status-verified-*`, `status-warning-*`, `status-error-*`, `status-info-*`, `status-portable-*` | Each semantic state has a foreground/background pair |\\n\\n### Typography Tokens\\n\\nThe UI uses a single typeface family \u2014 Segoe UI Variable / Segoe UI \u2014 with hierarchy driven by **size and weight, not colour**. Cascadia Code / Fira Code is reserved for ABNs and IDs, with a 12px floor.\\n\\n| Role | Size / Weight | Usage |\\n|---|---|---|\\n| `page` | 18px / 600 | Page `h1` |\\n| `display` | 20px / 600 | Numeric emphasis only (`--text-display`) |\\n| `title` | 13px / 500\u2013600 | Panel headers, sidebar |\\n| `body` | 13.5px / 400\u2013500 | Row primary text |\\n| `meta` | 12px / 400 | IDs, dates, secondary info |\\n\\nThe only exception to the fixed rem scale is the `/auth/login` brand pane, which may use a fluid clamp up to ~2.75rem for the promise line. The app shell stays on the fixed scale.\\n\\n### Spacing &amp; Radius\\n\\nSpacing scales from `xs` (4px) to `2xl` (32px). Radii are deliberately small: `sm` 5px, `md` 6px, `lg` 8px, `pill` 999px. No drop-shadow chrome \u2014 elevation is communicated with Cloud borders and flat surfaces.\\n\\n## Core Rules\\n\\nFive named rules recur throughout the document and are the fastest way to review a design:\\n\\n1. **The Restrained Accent Rule.** Electric Blue \u226410% of any screen.\\n2. **The Status Trinity Rule.** Never communicate status by colour alone \u2014 always colour + icon + label.\\n3. **The Pewter Secondary Rule.** Secondary text and placeholders use Pewter (\u22654.5:1 contrast on Canvas/Ash). Silver is non-text chrome only.\\n4. **The Flat-By-Default Rule.** No glassmorphism, no soft card shadows. `--shadow-rest`, `--shadow-card`, and `--shadow-card-hover` are all `none`.\\n5. **The One Family Rule.** No second UI typeface. Prefer weight 500; reserve 600 for page titles and primary emphasis.\\n\\n## Component Specifications\\n\\nComponents live under `frontend/src/components/` and consume tokens via CSS modules.\\n\\n### Buttons\\n\\n- **Primary** \u2014 Electric Blue background, white text, 6px radius, `10px 18px` padding; hover uses `electric-dark`\\n- **Secondary** \u2014 Canvas background with Cloud border\\n- Minimum interactive chrome is 44px; `size=\\\"sm\\\"` may drop to 36px for dense desktop tooling\\n\\n### Status Chips\\n\\n`StatusChip` renders a semantic background/foreground pair, an icon, and a text label. This is the mandatory pattern for all status display.\\n\\n### Cards &amp; Containers\\n\\nCanvas surface on Ash background, 6\u20138px radius, Cloud border, no shadow. The `card` token defines `16px 20px` padding.\\n\\n### Inputs\\n\\nCanvas fill, Graphite placeholder text, Electric Blue focus ring. `InputField` and `SelectField` share invalid-state and `aria-*` wiring.\\n\\n### Navigation Shell\\n\\nThe app shell comprises a left sidebar, sticky top bar, and Ash workspace. Layout tokens `shell-sidebar` and `shell-workspace` define surface and padding.\\n\\n- Sidebar becomes a **drawer at \u2264960px** with `inert` when closed, Escape-to-close, focus trap when open, and `aria-expanded`\\n- Top-bar search is inline at \u22651100px and collapses to an icon expand below that breakpoint\\n- Account menu button and notifications (when wired) are part of the top bar\\n\\n### Dashboard Status Panel\\n\\nThe `Key indicators` panel is a dense **status table** \u2014 indicator \u00b7 current \u00b7 context \u00b7 action \u2014 not a four-up metric card grid.\\n\\n## Accessibility &amp; Motion\\n\\n- **Touch targets** \u2014 primary chrome controls are \u226544px\\n- **Focus** \u2014 2px Electric Blue ring with canvas offset (`--focus-ring`)\\n- **Reduced motion** \u2014 all motion is state-only, 120\u2013160ms ease-out, with a `prefers-reduced-motion` kill-switch\\n- **Drawer** \u2014 non-focusable when closed (`inert`), focus-trapped when open\\n- **Skip link** \u2014 provided for keyboard users\\n\\n## Codebase Integration\\n\\n`DESIGN.md` is a specification, so its \\\"connections\\\" are conventions enforced across the frontend:\\n\\n| Artifact | Relationship |\\n|---|---|\\n| `frontend/src/styles/tokens.css` | Authoritative token source; `DESIGN.md` mirrors it |\\n| `frontend/src/components/` | Implementation home for `StatusChip`, `InputField`, `SelectField`, buttons, navigation, dashboard panels |\\n| App shell | Sidebar + top bar + workspace, shared by Principal Contractor and Trade; role and membership determine navigation |\\n| `/auth/login` | Sole exception to the fixed type scale (fluid clamp for the brand promise line) |\\n\\nBecause the module has no runtime calls or execution flows, the design rules in this document are enforced through code review and token usage rather than programmatic checks.\\n\\n## Do's and Don'ts\\n\\n**Do:**\\n\\n- Use tokens from `tokens.css` for all colour and type\\n- Pair status colour with icon + label\\n- Keep the drawer non-focusable when closed (`inert` + focus trap when open)\\n- Use Pewter for secondary text and placeholders\\n- Honour `prefers-reduced-motion`\\n\\n**Don't:**\\n\\n- Use neon/candy SaaS aesthetics or glassmorphism\\n- Use Silver for readable text\\n- Ship hero-metric identical card grids for status\\n- Use tiny section kickers above every heading\\n- Leave dead interactive controls enabled\\n- Misspell Assureworx or describe it as an AI platform\",\"other-docs-legal\":\"# Other \u2014 docs-legal\\n\\n# docs-legal Module Documentation\\n\\n## Overview\\n\\nThe `docs/legal` module contains the Assureworx \\\"director pack\\\" legal documents \u2014 the Privacy Statement and Terms of Use \u2014 and the tooling required to regenerate them as print-ready A4 PDFs.\\n\\nThe module is self-contained. It has no imports from or exports to application code, no runtime callers, and no build-system integration. It is invoked manually, on demand, to produce the versioned PDFs distributed to the Assureworx board of directors.\\n\\nThe documents themselves note that they replicate the text published on the live Service pages at `assureworx.com.au`. This module is therefore not the only location of this content \u2014 anyone changing the legal text must keep this module and the live Service pages in sync.\\n\\n## Module Layout\\n\\n| File | Role |\\n|---|---|\\n| `generate-director-pdfs.mjs` | Build script. Embeds the full legal content, rewrites the HTML documents, and renders them to PDF via headless Chrome/Edge. |\\n| `Assureworx-Privacy-Statement-2026-08-04.html` | Generated Privacy Statement document. |\\n| `Assureworx-Terms-of-Use-2026-08-04.html` | Generated Terms of Use document. |\\n| `print-styles.css` | Print stylesheet shared by both HTML documents. Controls A4 page geometry, typography, tables, and print pagination. |\\n\\nThe `generate-director-pdfs.mjs` script produces two artifacts per job:\\n\\n- `Assureworx-Privacy-Statement-2026-08-04.pdf`\\n- `Assureworx-Terms-of-Use-2026-08-04.pdf`\\n\\nBoth PDFs are written into `docs/legal/`, alongside the HTML files.\\n\\n## Legal Documents\\n\\nBoth documents are effective **4 August 2026**, version **2026-08-04.1**, published by Assureworx Labs Pty Ltd (ACN 699 745 876 \u00b7 ABN 71 699 745 876).\\n\\n### Privacy Statement\\n\\nThirteen sections covering:\\n\\n- **Data roles** \u2014 Assureworx acts as controller for platform administration, billing, and identity-consent records; it acts as processor/service provider for workforce and project data controlled by Principal Contractors and trade companies.\\n- **Data categories** \u2014 account/profile data, compliance evidence, identity-verification outcomes, project/site readiness data, payment ledgers, digital wallet pass data, and security/audit logs.\\n- **Third-party processors** \u2014 Microsoft Entra External ID (authentication), Didit (identity verification), Stripe (payments), Apple/Google Wallet (pass delivery), and OpenStreetMap Nominatim (geocoding).\\n- **Data minimisation commitments** \u2014 Assureworx does not store identity document images, biometric templates, or payment-card data. Identity checks are performed by Didit, with only the check outcome and limited diagnostic fields returned.\\n- **Cookies** \u2014 a table of strictly necessary cookies (`awx_session`, `awx_entra_*`, `awx_native_*`) with durations. No advertising or analytics cookies are set.\\n- **Retention periods** \u2014 an explicit retention table covering accounts (90 days after closure), compliance evidence (7 years after last link), identity outcomes (24 months), billing records (7 years), audit events (24 months rolling), and soft-deleted files (30 days).\\n- **Erasure flow** \u2014 users request erasure via *Settings \u2192 Erase my personal details*; sign-in stops immediately, most personal fields are removed, and a 90-day grace period precedes clearing of name/email from the profile record.\\n- **Complaints path** \u2014 OAIC escalation, access/correction requests via `info@assureworx.com.au`.\\n\\n### Terms of Use\\n\\nSeventeen sections covering:\\n\\n- **Eligibility** \u2014 users must be 18+, provide accurate details, and keep credentials confidential.\\n- **Network roles** \u2014 Principal Contractors, trade companies, individual tradies, and invited workers; organisations remain responsible for their own site-access and hiring decisions.\\n- **User obligations** \u2014 includes a **14-day** obligation to report when a compliance document is replaced, renewed, cancelled, or otherwise invalidated.\\n- **Identity verification** \u2014 performed by Didit under separate express consent; withdrawal deletes the stored check outcome.\\n- **Fees and GST** \u2014 prices in AUD, GST-inclusive; Stripe processes payments; fees are non-refundable except as required by Australian Consumer Law.\\n- **Liability cap** \u2014 aggregate liability limited to fees paid in any 12-month period, or AUD $100 if nothing was paid.\\n- **Governing law** \u2014 New South Wales, Australia; non-exclusive jurisdiction.\\n- **Relationship to the Privacy Statement** \u2014 both documents together form the entire agreement.\\n\\n## PDF Generation Pipeline\\n\\nThe entire pipeline lives in `generate-director-pdfs.mjs`. It is an ESM script that uses only Node.js built-ins (`node:child_process`, `node:fs`, `node:path`, `node:url`).\\n\\n```mermaid\\nflowchart LR\\n    A[\\\"generate-director-pdfs.mjs\\\"] --&gt; B[\\\"wrap(title, body)\\\"]\\n    B --&gt; C[\\\"HTML files\\\"]\\n    A --&gt; D[\\\"print-styles.css\\\"]\\n    C --&gt; E[\\\"Headless Chrome/Edge--print-to-pdf\\\"]\\n    D --&gt; E\\n    E --&gt; F[\\\"A4 PDF files\\\"]\\n```\\n\\nThe script is synchronous and blocking: it writes both HTML files, then shells out to a headless browser for each PDF in turn.\\n\\n### Script Constants\\n\\nAt the top of the script, two constants define the legal version and effective date:\\n\\n- `EFFECTIVE = \\\"4 August 2026\\\"`\\n- `VERSION = \\\"2026-08-04.1\\\"`\\n\\nThese are interpolated into the document header, the meta line, the director-pack notice, and the footer by `wrap()`.\\n\\n### Browser Discovery\\n\\nThe script locates a Chromium-based browser by testing `chromeCandidates`, an ordered list of Windows paths:\\n\\n1. Google Chrome (Program Files)\\n2. Google Chrome (Program Files x86)\\n3. Google Chrome (under `%LOCALAPPDATA%`)\\n4. Microsoft Edge (Program Files)\\n5. Microsoft Edge (Program Files x86)\\n\\nThe first path that exists is used. If none exist, the script prints `Chrome or Edge not found. Install one, then re-run.` and exits with status 1.\\n\\nNote that the candidate list is **Windows-only** \u2014 there are no macOS or Linux browser paths. The script is written to run on a Windows machine.\\n\\n### Document Assembly: `wrap()`\\n\\n`wrap(title, bodyHtml)` constructs a complete HTML document from fragments:\\n\\n- A `\n` with the brand (`ASSUREWORX`) and company entity line\\n- A `.notice` paragraph \u2014 *\\\"Director pack copy. Same text as the live Service pages at assureworx.com.au. Effective \u2026 Version \u2026\\\"*\\n- An `\n` title and `.meta` line with the effective date, version, and publisher\\n- The body HTML\\n- A `.footer` with the registered office address and version stamp\\n\\nThe function returns a full `` document that references `print-styles.css`.\\n\\n### Embedded Content\\n\\nThe legal content lives in two template-literal constants inside the script:\\n\\n- `privacyBody` \u2014 the full body HTML of the Privacy Statement\\n- `termsBody` \u2014 the full body HTML of the Terms of Use\\n\\nThese strings are the **authoritative source** of the document text for this module.\\n\\n### Build Loop\\n\\nThe `jobs` array defines the two documents:\\n\\n```js\\n{\\n  htmlName: \\\"Assureworx-Privacy-Statement-2026-08-04.html\\\",\\n  pdfName: \\\"Assureworx-Privacy-Statement-2026-08-04.pdf\\\",\\n  title: \\\"Privacy Statement\\\",\\n  body: privacyBody,\\n}\\n```\\n\\n...and the equivalent for the Terms of Use.\\n\\nFor each job, the script:\\n\\n1. Writes the wrapped HTML to `docs/legal/` via `writeFileSync`.\\n2. Converts the path to a `file:///` URL (backslashes replaced with forward slashes).\\n3. Invokes the browser:\\n\\n```js\\nspawnSync(\\n  browser,\\n  [\\n    \\\"--headless=new\\\",\\n    \\\"--disable-gpu\\\",\\n    \\\"--no-pdf-header-footer\\\",\\n    `--print-to-pdf=${pdfPath}`,\\n    \\\"--print-to-pdf-no-header\\\",\\n    fileUrl,\\n  ],\\n  { encoding: \\\"utf8\\\" }\\n);\\n```\\n\\n4. Verifies the browser exited with status 0 **and** the PDF file now exists. Either check failing prints the captured stderr/stdout and exits with status 1.\\n5. Logs `Wrote `.\\n\\n### Print Stylesheet\\n\\n`print-styles.css` is the only stylesheet referenced by the generated HTML. Key properties:\\n\\n- `@page` rule: A4, margins `18mm 16mm 20mm 16mm`\\n- Base font: 10.5pt Segoe UI/Calibri with 1.45 line height\\n- Brand colour `#0b3d91` for headers, headings, and links\\n- `page-break-after: avoid` on `h2` and `h3` so headings stay with their following content\\n- Table styling for the cookie and retention tables in the Privacy Statement\\n- A `.notice` callout box and `.footer` block for print\\n\\n## Updating the Legal Documents\\n\\nTo change the legal text and regenerate the director-pack PDFs:\\n\\n1. **Edit the content** in `privacyBody` or `termsBody` inside `generate-director-pdfs.mjs`. Do not edit the HTML files directly \u2014 they are overwritten on every run.\\n2. **Bump the version** \u2014 update `VERSION` (and `EFFECTIVE` if the effective date changes).\\n3. **Update the filenames** \u2014 the date embedded in `htmlName`/`pdfName` in the `jobs` array is hardcoded independently of `VERSION`. If the document date changes, update both the job names and the existing committed HTML files' names to match.\\n4. **Run the script** from anywhere in the repo:\\n\\n   ```\\n   node docs/legal/generate-director-pdfs.mjs\\n   ```\\n\\n   The script derives its own directory via `import.meta.url`, so it does not depend on the current working directory.\\n5. **Commit the regenerated HTML files and the new PDFs.** Also update the live Service pages at assureworx.com.au to match, since the director-pack notice asserts the text is identical.\\n\\n## Dependencies and Environment\\n\\n| Dependency | Requirement |\\n|---|---|\\n| Node.js | ESM support (the script is `.mjs`) |\\n| Google Chrome or Microsoft Edge | Installed on Windows, any of the paths listed in `chromeCandidates` |\\n| Operating system | Windows (browser path discovery is Windows-only) |\\n\\nNo npm packages are required. All imports are Node.js built-ins.\\n\\n## Maintenance Notes and Gotchas\\n\\n- **The HTML files are generated artifacts.** Running the script rewrites `Assureworx-Privacy-Statement-2026-08-04.html` and `Assureworx-Terms-of-Use-2026-08-04.html` from the embedded template strings. Hand edits to the HTML will be silently overwritten. The source of truth is `privacyBody` / `termsBody`.\\n- **Version and filenames are decoupled.** `VERSION` controls the text inside the documents; the date in the filenames comes from the `jobs` array. A mismatch is easy to introduce when a new legal version ships \u2014 update both.\\n- **Browser discovery is Windows-only.** Developers on macOS or Linux cannot run the script without modifying `chromeCandidates` or installing a browser at one of the listed paths.\\n- **Synchronous execution.** `spawnSync` blocks until each PDF is rendered. This is fine for a two-document manual build, but the script should not be wired into a concurrent build system as-is.\\n- **Content is duplicated with the live Service pages.** The module does not fetch or diff against the running application. Keeping the two in sync is a manual process.\\n\\n## Integration with the Rest of the Codebase\\n\\nThis module has no code-level integration:\\n\\n- No other module imports from `docs/legal`.\\n- The script exports nothing and is never called at runtime.\\n- The module is not referenced by any build system, CI job, or deployment pipeline (no execution flows detected).\\n\\nIts only integration is **content-level**: the `.notice` block in both documents states that the text matches the live Service pages at assureworx.com.au, and the Terms of Use and Privacy Statement cross-reference each other as separate director-pack PDFs. The application's live Terms/Privacy pages and this module must therefore be treated as a single logical document set maintained in two places.\",\"other-e2e-helpers\":\"# Other \u2014 e2e-helpers\\n\\n# e2e-helpers\\n\\n## Overview\\n\\nThe `e2e/helpers` directory contains the instrumentation and flow layer shared by the Playwright E2E suites (`e2e/tests/stability.spec.ts` and `e2e/tests/deep-diagnostics.spec.ts`). It splits into two files:\\n\\n- **`instrument.ts`** \u2014 `PageInstrument`, a page-level diagnostics harness that passively records console errors, page errors, network failures/hangs, long tasks, layout overflow, navigation timing, and heap usage, and can capture screenshots and Chromium CDP traces.\\n- **`flows.ts`** \u2014 higher-level Playwright flows that drive a real user journey through the app: dev-persona login, settled navigation, per-route auditing, and context-level tracing.\\n\\nThe design intent is separation of concerns: `flows.ts` decides *what to do* (navigate, click, wait), while `instrument.ts` decides *what to observe* (issues, metrics, evidence). Both produce artifacts into a per-run output directory (`runDir`) for later triage and regression comparison.\\n\\n## Architecture\\n\\n```mermaid\\ngraph TD\\n    S[\\\"stability.spec.ts\\\"] --&gt;|\\\"loginAsDevPersona, walkRoutes\\\"| F[\\\"flows.ts\\\"]\\n    D[\\\"deep-diagnostics.spec.ts\\\"] --&gt;|\\\"loginAsDevPersona, gotoAndSettle\\\"| F\\n    S --&gt;|\\\"PageInstrument, attach, collectMetrics, writeReport\\\"| I[\\\"instrument.ts\\\"]\\n    D --&gt;|\\\"PageInstrument, attach, startTrace, stopTrace\\\"| I\\n    F --&gt;|\\\"screenshot, collectMetrics, startTrace/stopTrace\\\"| I\\n```\\n\\nBoth spec files create a `PageInstrument` for a page, call `attach()`, and then drive the helpers. The flow layer never talks to the browser directly for diagnostics \u2014 it delegates all observation to `PageInstrument`, keeping the issue-recording logic in one place.\\n\\n## PageInstrument\\n\\n`PageInstrument` is the core observer. Construct it with a Playwright `Page` and an output directory:\\n\\n```ts\\nconst instrument = new PageInstrument(page, runDir);\\nawait instrument.attach();\\n```\\n\\n`attach()` is idempotent and installs the following page listeners:\\n\\n| Event | Records |\\n|---|---|\\n| `console` | `ConsoleIssue` entries for messages of type `error` or `warning`, including source URL |\\n| `pageerror` | `ConsoleIssue` entries of type `pageerror` from uncaught exceptions |\\n| `request` | a pending-request entry with start time, plus a 15s hang timer |\\n| `requestfailed` | `NetworkIssue` with `kind: \\\"failed\\\"`, failure text, and duration |\\n| `response` | `NetworkIssue` with `kind: \\\"http_error\\\"` for status &gt;= 400 |\\n| `addInitScript` | a `PerformanceObserver` collecting `longtask` entries onto `window.__awxLongTasks` |\\n\\nHang detection is timer-based: any request that has neither failed nor received a response within `HANG_MS` (15s) is recorded with `kind: \\\"hang\\\"`. The timer is cleared on response or failure via `clearHang()`.\\n\\nKey methods:\\n\\n- **`collectMetrics(label)`** \u2014 evaluates `performance.getEntriesByType(\\\"navigation\\\")`, accumulated long tasks, overflow detection (`document.documentElement.scrollWidth &gt; clientWidth + 2`), and `performance.memory.usedJSHeapSize` when available. Filters long tasks to those &gt;= `LONG_TASK_MS` (100ms), persists a JSON metrics file, and returns a `PageMetrics` object.\\n- **`screenshot(label)`** \u2014 full-page screenshot saved as `.png`; returns the file path.\\n- **`startTrace(label)` / `stopTrace(handle)`** \u2014 Chromium-only CDP tracing for main-thread investigation. `startTrace` opens a CDP session and starts `Tracing.start` with the `devtools.timeline` and `v8.execute` categories; it returns `null` on browsers without CDP support. `stopTrace` collects `Tracing.dataCollected` chunks, writes `-trace.json`, and detaches the session.\\n- **`snapshotIssues()`** \u2014 returns all collected console errors/warnings, network issues, and long tasks.\\n- **`writeReport(extra = {})`** \u2014 persists `issues-snapshot.json` containing the merged `extra` data, the full issue snapshot, and the thresholds used.\\n\\n## Flow helpers (flows.ts)\\n\\n### gotoAndSettle\\n\\n```ts\\nconst { response, elapsedMs } = await gotoAndSettle(page, \\\"/dashboard\\\");\\n```\\n\\nNavigates with `waitUntil: \\\"domcontentloaded\\\"`, then best-effort waits for `networkidle` (20s, errors swallowed), followed by a fixed 500ms delay to let client effects settle. Returns the response and measured elapsed time. The `timeout` parameter defaults to 60s.\\n\\n### loginAsDevPersona\\n\\n```ts\\nconst { steps, stillOnLogin, loginPageMs } =\\n  await loginAsDevPersona(page, instrument, \\\"alex@meridian.dev\\\");\\n```\\n\\nPerforms development-persona authentication:\\n\\n1. Navigates to `/auth/login` and records screenshot + metrics as `01-login-page`.\\n2. Looks up the persona's display name in `PERSONA_NAMES`; if a matching persona button is visible on the login list, clicks it.\\n3. Otherwise, falls back to a passwordless API login (`POST /api/v1/auth/login`) and navigates to `/dashboard`. If the API login fails and an email field is present, fills the email and clicks *Continue* as a last resort.\\n4. Waits up to 45s to leave `/auth/*`, then up to 15s for a `navigation` or `banner` landmark, followed by a 1200ms wait for the login-reveal curtain animation.\\n5. Records a second screenshot + metrics as `02-after-login`.\\n\\nReturns a human-readable step log (`steps`), a `stillOnLogin` flag for detecting auth failures, and the login page load time.\\n\\n### walkRoutes\\n\\n```ts\\nawait walkRoutes(page, instrument, PRINCIPAL_ROUTES, \\\"principal\\\", findings,\\n  browserName, viewportName);\\n```\\n\\nThe main route-audit loop. For each route it labels the step (`-NN-`), snapshots the current issue counts, navigates via `gotoAndSettle`, and runs a double-`requestAnimationFrame` probe to measure main-thread responsiveness. It then emits typed `Finding` objects for the following conditions:\\n\\n| Finding ID prefix | Trigger | Severity |\\n|---|---|---|\\n| `slow-or-jank-*` | rAF gap &gt; 2000ms (jank) or load &gt; `SLOW_PAGE_MS` | `high` / `medium` |\\n| `overflow-*` | horizontal layout overflow | `medium` |\\n| `http-*` | navigation response status &gt;= 400 | `high` (&gt;= 500) / `medium` |\\n| `console-*` | new uncaught console/page error since step start | `high` |\\n| `net-*` | new hang, HTTP &gt;= 500, or request failure | `high` / `medium` |\\n| `longtask-*` | max long task during step &gt;= 200ms | `high` (&gt;= 500ms) / `medium` |\\n| `nav-fail-*` | navigation error, hang, or timeout | `critical` |\\n\\nEvidence attached to findings includes screenshots, CDP trace files, and metrics JSON paths. Noise is filtered: cancelled/aborted failures are skipped, and favicon/sourcemap failures are excluded from network findings. The console and network loops stop after the first finding per step to avoid duplicate screenshot spam.\\n\\n### withTracing\\n\\n```ts\\nconst tracing = await withTracing(context, runDir, \\\"deep-diagnostics\\\");\\n// ... test body ...\\nawait tracing.stop(success);\\n```\\n\\nStarts Playwright context tracing (`screenshots`, `snapshots`, `sources`) and returns a handle whose `stop()` writes `-pw-trace.zip` into `runDir`. The `success` argument is accepted by the signature but does not currently affect the output.\\n\\n## Shared data model\\n\\nThe two files share the following types:\\n\\n- **`Finding`** (flows.ts) \u2014 `{ id, severity, title, repro, likelyCause, evidence, browser?, viewport?, url? }`, where `severity` is `\\\"critical\\\" | \\\"high\\\" | \\\"medium\\\" | \\\"low\\\"`. IDs are namespaced by condition and step label (e.g. `overflow-principal-03_projects`).\\n- **`PageMetrics`** (instrument.ts) \u2014 navigation timing, long tasks (`maxLongTaskMs`), console errors, network issues, overflow flag + scroll/client widths, and `heapUsedMB`.\\n- **`ConsoleIssue`** \u2014 type (`error` | `warning` | `pageerror`), text, source location, timestamp.\\n- **`NetworkIssue`** \u2014 `kind: \\\"failed\\\" | \\\"http_error\\\" | \\\"hang\\\"`, URL, method, status, failure text, duration, timestamp.\\n- **`LongTask`** \u2014 `{ duration, startTime, name }`.\\n\\n## Constants and configuration\\n\\n| Constant | Value | Purpose |\\n|---|---|---|\\n| `E2E_EMAIL` env / `PRINCIPAL_EMAIL` | `\\\"alex@meridian.dev\\\"` default | default persona email used in login and repro steps |\\n| `PRINCIPAL_ROUTES` | `/dashboard`, `/trades`, `/projects`, `/requests`, `/support`, `/settings` | principal-side route list for `walkRoutes` |\\n| `TRADE_ROUTES` | `/profile`, `/profile/documents`, `/jobs`, `/requests`, `/billing`, `/support` | trade-side route list |\\n| `VIEWPORTS` | mobile 390\u00d7844, tablet 768\u00d71024, desktop 1440\u00d7900 | viewport matrix for route audits |\\n| `HANG_MS` | 15,000 | request hang threshold |\\n| `SLOW_PAGE_MS` | 8,000 | slow-page threshold |\\n| `LONG_TASK_MS` | 100 | long-task recording threshold in `collectMetrics` |\\n| `PERSONA_NAMES` | email \u2192 display name | maps dev login email addresses to visible persona buttons |\\n\\n`findingsPath(runDir)` returns `join(runDir, \\\"findings.json\\\")` \u2014 the aggregation point where specs write the collected `Finding[]`.\\n\\n## Artifacts\\n\\nEvery artifact is written under the run directory:\\n\\n| Artifact | Producer |\\n|---|---|\\n| `.png` | `screenshot(label)` |\\n| `-metrics.json` | `collectMetrics(label)` |\\n| `-trace.json` | `stopTrace(handle)` (CDP) |\\n| `-pw-trace.zip` | `withTracing(...).stop(...)` (Playwright) |\\n| `issues-snapshot.json` | `writeReport(extra)` |\\n| `findings.json` | spec via `findingsPath(runDir)` |\\n\\nAll labels pass through `sanitize()`, which replaces characters outside `[A-Za-z0-9._-]` with underscores and truncates to 80 characters.\\n\\n## Relationship to the test suites\\n\\n- **`stability.spec.ts`** \u2014 the broad regression sweep. It constructs a `PageInstrument`, calls `attach()`, logs in via `loginAsDevPersona`, then runs `walkRoutes` across `PRINCIPAL_ROUTES` and `TRADE_ROUTES` for each viewport. It finishes by writing `findings.json` via `findingsPath` and a final report with `writeReport()`.\\n- **`deep-diagnostics.spec.ts`** \u2014 focused investigation of specific routes or symptoms. It uses `PageInstrument` more directly: `attach()`, targeted `gotoAndSettle` navigation, `startTrace`/`stopTrace` around a suspect interaction, `screenshot`, `snapshotIssues`, and `writeReport()`.\\n\\n## Tuning notes\\n\\n- The long-task finding threshold in `walkRoutes` (200ms) is deliberately higher than the recording threshold in `collectMetrics` (100ms) to avoid noisy findings.\\n- The 2s rAF probe in `walkRoutes` measures main-thread responsiveness independently of load time: a page can load fast (under `SLOW_PAGE_MS`) but still jank, and vice versa.\\n- CDP tracing is Chromium-only and degrades gracefully: `startTrace` returns `null` elsewhere, and `stopTrace` guards on that.\\n- The 500ms settle delay in `gotoAndSettle` and the 1200ms post-login wait exist to get past client-side effects and the login-reveal curtain before screenshots/metrics are captured; adjust these if the app's animations change.\",\"other-e2e-tests\":\"# Other \u2014 e2e-tests\\n\\n# e2e-tests \u2014 Stability &amp; Deep Diagnostics\\n\\n## Overview\\n\\nThe `e2e/tests` module is a Playwright-based test layer that probes the Assureworx frontend for stability regressions: slow loads, console errors, layout overflow, memory growth, long tasks, network failures, and map tile issues. It is split into two suites:\\n\\n- **`stability.spec.ts`** \u2014 cross-browser smoke and stability tests for public auth routes and authenticated principal flows.\\n- **`deep-diagnostics.spec.ts`** \u2014 a Chromium-only probe that uses CDP sessions to measure heap usage, long tasks, layout overflow, and map tile failures across principal routes.\\n\\nBoth suites share the same instrumentation helpers (`PageInstrument` in `helpers/instrument.ts`, flow utilities in `helpers/flows.ts`) and write findings into per-run artifact directories plus a global `all-findings.json` rollup.\\n\\n## Module Layout\\n\\n```\\ne2e/\\n\u251c\u2500\u2500 tests/\\n\u2502   \u251c\u2500\u2500 stability.spec.ts          # 5 cross-browser tests\\n\u2502   \u2514\u2500\u2500 deep-diagnostics.spec.ts   # 1 Chromium-only test\\n\u251c\u2500\u2500 helpers/\\n\u2502   \u251c\u2500\u2500 instrument.ts              # PageInstrument, SLOW_PAGE_MS\\n\u2502   \u2514\u2500\u2500 flows.ts                   # loginAsDevPersona, gotoAndSettle, walkRoutes, VIEWPORTS, etc.\\n\u2514\u2500\u2500 artifacts/                     # created at runtime\\n    \u251c\u2500\u2500 ///...\\n    \u2514\u2500\u2500 all-findings.json          # merged rollup of every finding\\n```\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    subgraph specs[\\\"Playwright specs\\\"]\\n        S[\\\"stability.spec.ts5 tests\\\"]\\n        D[\\\"deep-diagnostics.spec.ts1 CDP-only probe\\\"]\\n    end\\n    subgraph helpers[\\\"Shared helpers\\\"]\\n        PI[\\\"PageInstrumentattach / screenshot / collectMetrics / traces / writeReport / snapshotIssues\\\"]\\n        F[\\\"flows.tsloginAsDevPersona / gotoAndSettle / walkRoutes / findingsPath\\\"]\\n    end\\n    subgraph out[\\\"Output\\\"]\\n        DIR[\\\"artifacts////\\\"]\\n        ROLLUP[\\\"artifacts/all-findings.json\\\"]\\n    end\\n    S --&gt; PI\\n    S --&gt; F\\n    D --&gt; PI\\n    D --&gt; F\\n    PI --&gt;|screenshots, traces, reports| DIR\\n    F --&gt;|findings| DIR\\n    DIR --&gt;|merged| ROLLUP\\n```\\n\\nEach test follows the same pattern:\\n\\n1. Create a timestamped run directory under `artifacts///`.\\n2. Construct a `PageInstrument` bound to the Playwright `page` and run directory.\\n3. Attach the instrument so it records console errors, page errors, and network issues.\\n4. Run the probe (login, navigate, scroll, measure).\\n5. Collect `Finding` objects, write them to the run directory via `findingsPath(runDir)`, and merge them into `artifacts/all-findings.json`.\\n6. Write a JSON report via `instrument.writeReport(...)`.\\n\\n## Test Suites\\n\\n### `stability.spec.ts`\\n\\nThe main stability suite runs in every configured browser and covers public and authenticated flows.\\n\\n#### `public auth + verify landing loads without fatal errors`\\n\\nIterates `/auth/login`, `/auth/register`, `/verify`, and `/`. For each route:\\n\\n- Measures load time via `gotoAndSettle(page, route)`; flags as `slow-public-*` if `elapsedMs &gt; SLOW_PAGE_MS`.\\n- Captures a screenshot and runs `instrument.collectMetrics(label)`; flags horizontal overflow (`metrics.overflowX`) as `overflow-public-*`.\\n- Inspects console output since the start of the route; any `error` or `pageerror` entry produces a `console-public-*` finding.\\n- Asserts the root route responds with a redirect or `200`.\\n\\n#### `principal login + main nav flows with screenshots`\\n\\nLogs in as `PRINCIPAL_EMAIL` using `loginAsDevPersona`. If login fails, a `critical` `login-failed` finding is written and the test fails. If the login page itself was slow, a `slow-login-page` finding is recorded.\\n\\nOn success:\\n\\n- Walks `PRINCIPAL_ROUTES` with `walkRoutes(page, instrument, PRINCIPAL_ROUTES, \\\"principal\\\", findings, browserName, \\\"desktop\\\")`.\\n- If a project detail link exists, drills into the first `/projects/` route.\\n- Asserts no `critical` findings were produced.\\n\\n#### `responsive layout matrix (mobile / tablet / desktop)`\\n\\nLogs in once at the desktop viewport, then re-runs `walkRoutes` over a sample route set (`/dashboard`, `/projects`, `/trades`, `/requests`, `/settings`) at every viewport in `VIEWPORTS`. Findings are labeled per viewport (`vp-${vp.name}`).\\n\\n#### `memory leak probe \u2014 repeated principal navigation`\\n\\nSkipped on Firefox (heap metrics unreliable). After login, loops through `/dashboard \u2192 /projects \u2192 /trades \u2192 /requests \u2192 /support` eight times. After each full loop it:\\n\\n- Collects `heapUsedMB` via `instrument.collectMetrics`.\\n- Optionally forces GC through a temporary CDP session.\\n\\nIf average heap at the end exceeds the average at the start by more than 30 MB **and** 40%, a `memory-leak-nav-loop` finding is written with the heap series as repro evidence.\\n\\n#### `interaction freeze detection with performance trace`\\n\\nChromium-only. For `/dashboard`, `/projects`, and `/trades`:\\n\\n- Starts a CDP trace via `instrument.startTrace(...)`.\\n- Navigates, then simulates mouse movement, Tab presses, and a click on the first visible button/link.\\n- Measures the maximum `requestAnimationFrame` gap inside the page.\\n- If the max gap exceeds 2000 ms or the whole interaction takes longer than 10 s, a `freeze-` finding is written, attaching the screenshot and trace file.\\n\\n### `deep-diagnostics.spec.ts`\\n\\nA single Chromium-only test (`CDP-only` skip guard) that performs deeper probes than the stability suite.\\n\\n#### `CDP heap + longtask + layout scan across principal routes`\\n\\nThe test is skipped unless the browser is Chromium. The flow:\\n\\n1. **Login**: `loginAsDevPersona` as `PRINCIPAL_EMAIL`. If login fails, a `deep-login-failed` critical finding is written and the test exits early.\\n2. **CDP setup**: Opens a CDP session with `page.context().newCDPSession(page)`, enabling `Performance` and `HeapProfiler`.\\n3. **Route scan**: For each of `/dashboard`, `/trades`, `/projects`, `/requests`, `/support`, `/settings`:\\n   - Navigates via `gotoAndSettle`.\\n   - Ensures a `PerformanceObserver` for `longtask` entries is installed on the page (`window.__awxLT`).\\n   - Performs scroll activity to force layout work.\\n   - Reads heap usage before/after a forced GC.\\n   - Executes an in-page layout scan: collects elements whose `scrollWidth &gt; clientWidth + 4` (ignoring hidden overflow), documents horizontal overflow, long task entries, failed images, and Leaflet tile/map presence.\\n   - Screenshots the route.\\n   - Records findings:\\n     - `layout-overflow-` (medium) \u2014 document or element horizontal overflow.\\n     - `longtask-` (medium \u2265 200 ms, high \u2265 500 ms) \u2014 includes a captured CDP trace if a trace was started.\\n     - `dashboard-map-no-tiles` (medium) \u2014 Leaflet container exists but zero tiles render.\\n4. **Memory growth loop**: Without GC, loops `/dashboard \u2192 /trades \u2192 /projects` six times, recording heap after each loop. If growth exceeds 25 MB from first to last sample, a `cdp-heap-growth` high finding is written.\\n5. **Mobile truncation check**: Resizes to `VIEWPORTS.mobile` (390\u00d7844), opens `/trades`, and inspects the search input placeholder. If the placeholder is clipped (`scrollWidth &gt; clientWidth + 2`) or too long for the input, a low-severity finding is written (`mobile-trades-search-truncate` or `mobile-trades-search-placeholder-long`).\\n6. **Network issue classification**: Uses `instrument.snapshotIssues()` to inspect recorded network failures and emits:\\n   - `carto-tile-aborts` (medium) \u2014 at least 3 failures matching `cartocdn|basemap`.\\n   - `http5xx-*` (high) \u2014 network `http_error` entries with status \u2265 500.\\n   - `hang-*` (high) \u2014 requests that hung beyond the instrument's threshold (15 s).\\n7. **Output**: Writes `deep-series.json` (per-route metrics, heap series, truncation result, network issues), the instrument report, the findings file, and merges findings into the global rollup.\\n\\n## Shared Infrastructure\\n\\n### `PageInstrument` (`helpers/instrument.ts`)\\n\\nBound to a test's `page` and run directory. Key members used by these specs:\\n\\n| Member | Purpose |\\n|---|---|\\n| `new PageInstrument(page, runDir)` | Create instrument for a page and artifact output dir. |\\n| `attach()` | Start collecting console errors, page errors, and network issues. |\\n| `screenshot(label)` | Save a screenshot into the run directory; returns the file path. |\\n| `collectMetrics(label)` | Collect heap/overflow/performance metrics into the report; returns an object including `heapUsedMB` and `overflowX`. |\\n| `startTrace(name)` / `stopTrace(trace)` | Start/stop a CDP trace; returns the trace file path if one was produced. |\\n| `writeReport(meta)` | Write run metadata and issue snapshots (e.g., `issues-snapshot.json`) into the run directory. |\\n| `snapshotIssues()` | Return collected network issues (with `kind`, `url`, `method`, `status`). |\\n| `consoleIssues` | Array of collected console errors/page errors (`type`, `text`). |\\n| `SLOW_PAGE_MS` | Exported threshold for slow-load findings. |\\n\\n### Flow helpers (`helpers/flows.ts`)\\n\\n| Member | Purpose |\\n|---|---|\\n| `loginAsDevPersona(page, instrument, email)` | Passwordless dev login; returns `{ stillOnLogin, steps, loginPageMs }`. |\\n| `gotoAndSettle(page, route)` | Navigate and wait for network idle; returns `{ elapsedMs, response }`. |\\n| `walkRoutes(page, instrument, routes, label, findings, browserName, viewportName)` | Standard route walk: navigate, screenshot, collect metrics, check overflow/console, add findings. |\\n| `findingsPath(runDir)` | Path of the `findings.json` file inside a run directory. |\\n| `VIEWPORTS` | Named viewport definitions (`desktop`, `tablet`, `mobile`; mobile is 390\u00d7844). |\\n| `PRINCIPAL_EMAIL` / `PRINCIPAL_ROUTES` | Default authenticated email and principal route list. |\\n| `Finding` | The finding object shape shared by both suites (`id`, `severity`, `title`, `repro`, `likelyCause`, `evidence`, `browser`, plus optional `url`/`viewport`). |\\n\\n## Findings &amp; Artifact Output\\n\\nEvery run produces:\\n\\n```\\nartifacts////\\n\u251c\u2500\u2500 findings.json              # findings for this run\\n\u251c\u2500\u2500 issues-snapshot.json       # network/console issue snapshot (from writeReport)\\n\u251c\u2500\u2500 -report.json        # instrument metrics report\\n\u251c\u2500\u2500 *.png                      # screenshots\\n\u251c\u2500\u2500 *.trace.json               # CDP traces (when triggered)\\n\u2514\u2500\u2500 deep-series.json           # deep-diagnostics only: per-route metrics\\n```\\n\\nAdditionally, every finding is merged into `artifacts/all-findings.json`, a global rollup that survives across runs and is the primary signal for CI triage. Both suites append rather than overwrite, so the rollup accumulates findings until cleaned.\\n\\n## Severity Levels\\n\\n- **critical** \u2014 login failure that blocks the entire suite (e.g., `login-failed`, `deep-login-failed`).\\n- **high** \u2014 console errors, memory leaks, long freezes, HTTP 5xx, network hangs, long tasks \u2265 500 ms.\\n- **medium** \u2014 slow page loads, horizontal overflow, long tasks 200\u2013500 ms, map tile aborts, CartoCDN failures.\\n- **low** \u2014 mobile placeholder truncation / overly long placeholder copy.\\n\\n## Extending the Probes\\n\\nTo add a new check:\\n\\n1. Add a `Finding` in the same shape used elsewhere \u2014 include `id`, `severity`, `title`, `repro`, `likelyCause`, `evidence`, and `browser`.\\n2. Collect evidence through `PageInstrument` (screenshots, traces, `collectMetrics`) so it lands in the run directory automatically.\\n3. Write the findings file via `findingsPath(runDir)` and merge into `all-findings.json` (either with `writeFindings` from `stability.spec.ts` or the inline merge block in `deep-diagnostics.spec.ts`).\\n4. If the check is browser-specific, guard with `test.skip(({ browserName }) =&gt; ...)` \u2014 heap probing is Chromium-only, and CDP tracing is skipped in Firefox.\\n\\n## Notes on Test Stability\\n\\n- Login failures are treated as blocking: the specs write the failure finding, then assert `stillOnLogin === false` so the suite fails loudly rather than producing misleading downstream findings.\\n- GC and heap metrics are best-effort \u2014 every CDP send is wrapped with `.catch(() =&gt; {})` so an unsupported command does not fail the run.\\n- The deep-diagnostics test deliberately avoids GC between routes in the memory-growth loop to expose retained state; the stability memory test does the opposite (forces GC) to isolate genuine leaks from normal allocation churn.\",\"other-e2e\":\"# Other \u2014 e2e\\n\\n# E2E \u2014 Stability and Rendering Test Suite\\n\\n## Overview\\n\\nThe `e2e` module (package `assureworx-e2e-stability`) is a standalone Playwright test suite that exercises the AssureWorx application as a black box. Its stated purpose, per its package metadata, is to serve as a **stability / rendering suite**, isolated from monorepo-wide tooling overrides.\\n\\nThe module is a deliberate leaf in the repository graph:\\n\\n- **Incoming calls**: none \u2014 no other module imports or depends on this package.\\n- **Outgoing calls**: none \u2014 the suite imports no internal code. Its only dependencies are `@playwright/test` and `typescript`.\\n- **Runtime coupling**: the suite communicates with the application exclusively over HTTP via the `E2E_BASE_URL` environment variable (default `http://127.0.0.1:3000`).\\n\\nThe suite's mandate is to verify that the application renders correctly and remains stable across all three major browser engines: Chromium, Firefox, and WebKit.\\n\\n## Module layout\\n\\n```\\ne2e/\\n\u251c\u2500\u2500 package.json\\n\u251c\u2500\u2500 playwright.config.ts\\n\u251c\u2500\u2500 tsconfig.json\\n\u251c\u2500\u2500 tests/                  # spec discovery root (testDir)\\n\u2514\u2500\u2500 artifacts/              # generated at runtime \u2014 reports, traces, video, screenshots\\n```\\n\\n`artifacts/` is created by Playwright during a run and should stay untracked in version control. The `tests/` directory is the contact surface for contributors: any Playwright spec placed there is discovered and executed.\\n\\n## How the suite runs\\n\\nThe entire module is driven by the Playwright CLI:\\n\\n```mermaid\\nflowchart LR\\n    A[\\\"npm test\\\"] --&gt; B[\\\"playwright.config.ts\\\"]\\n    B --&gt; C[\\\"testDir: tests/\\\"]\\n    B --&gt; D[\\\"3 browser projects:chromium \u00b7 firefox \u00b7 webkit\\\"]\\n    C --&gt; D\\n    D --&gt; E[\\\"app under test at E2E_BASE_URL\\\"]\\n    E --&gt; F[\\\"artifacts/ (reports, traces, video)\\\"]\\n```\\n\\n1. `npm test` invokes `playwright test`.\\n2. The CLI loads `playwright.config.ts`, which declares the test discovery root, the three browser projects, timeouts, and artifact capture settings.\\n3. Every spec under `tests/` is executed in each of the three browser projects against the configured `baseURL`.\\n4. Results and failure artifacts are written under `artifacts/`.\\n\\nBecause `workers: 1` and `fullyParallel: false`, the run is sequential: all Chromium tests first, then Firefox, then WebKit. There is no retry pass \u2014 `retries: 0`.\\n\\n## Key components\\n\\n### `package.json`\\n\\n| Field | Value |\\n|---|---|\\n| `name` | `assureworx-e2e-stability` |\\n| `private` | `true` \u2014 never published |\\n| Script `test` | `playwright test` |\\n| Script `test:report` | `playwright show-report` |\\n| devDependencies | `@playwright/test ^1.54.2`, `typescript ^5.8.0` |\\n\\nThese two scripts are the entire interaction surface for the module: run the suite, or open the last HTML report after a run.\\n\\n### `playwright.config.ts`\\n\\nThe module's core. It exports a Playwright configuration via `defineConfig`.\\n\\n#### Target selection\\n\\n```ts\\nconst BASE_URL = process.env.E2E_BASE_URL ?? \\\"http://127.0.0.1:3000\\\";\\n```\\n\\nThe application under test is chosen at runtime. Any environment that can serve the app \u2014 local dev server, CI preview, staging \u2014 can be the target without touching the config.\\n\\n#### Execution model\\n\\n| Option | Value | Effect |\\n|---|---|---|\\n| `testDir` | `./tests` | Spec discovery root |\\n| `fullyParallel` | `false` | Tests within a file run in declared order |\\n| `workers` | `1` | One browser at a time; predictable resource usage |\\n| `retries` | `0` | Failures are reported as-is, never retried |\\n| `timeout` | `120_000` | Per-test limit (2 minutes) |\\n| `expect.timeout` | `15_000` | Per-assertion wait limit |\\n| `actionTimeout` | `20_000` | Per-action limit (click, fill, etc.) |\\n| `navigationTimeout` | `60_000` | Per-navigation limit |\\n\\n#### Browser projects\\n\\n```ts\\nprojects: [\\n  { name: \\\"chromium\\\", use: { ...devices[\\\"Desktop Chrome\\\"] } },\\n  { name: \\\"firefox\\\", use: { ...devices[\\\"Desktop Firefox\\\"] } },\\n  { name: \\\"webkit\\\", use: { ...devices[\\\"Desktop Safari\\\"] } },\\n]\\n```\\n\\nEach project uses its engine's desktop device profile, so the suite exercises the app with the same viewport and user-agent defaults as real desktop users. The top-level `use` block applies shared timeouts and capture settings to all three projects.\\n\\n#### Reporting and artifact capture\\n\\n| Option | Value | Effect |\\n|---|---|---|\\n| `reporter` | `list`, `html`, `json` | Console output; HTML report at `artifacts/html-report` (`open: \\\"never\\\"`); JSON results at `artifacts/results.json` |\\n| `outputDir` | `artifacts/test-output` | Playwright's working directory for test output |\\n| `trace` | `retain-on-failure` | Playwright zip traces retained only for failed tests |\\n| `screenshot` | `on` | A screenshot is saved after **every** test |\\n| `video` | `retain-on-failure` | Video retained only for failed tests |\\n\\nThe config comment notes that **manual CDP traces are captured on hangs**, separate from Playwright's own zip traces. This reflects the module's stability focus: a hung page is a distinct failure mode from an assertion failure, and the surrounding operational workflow provides for diagnosing hangs out-of-band via CDP.\\n\\nSince `open: \\\"never\\\"` suppresses auto-opening the HTML report, `npm run test:report` (\u2192 `playwright show-report`) is how a developer opens `artifacts/html-report` after a run.\\n\\n### `tsconfig.json`\\n\\nThe module carries its own strict TypeScript configuration rather than inheriting monorepo settings:\\n\\n- `target: ES2022`, `module: commonjs` \u2014 matches Playwright's CJS runtime\\n- `strict: true`\\n- `esModuleInterop`, `skipLibCheck`\\n- `types: [\\\"node\\\"]` \u2014 no ambient framework globals\\n- `include: [\\\"./**/*.ts\\\"]` \u2014 covers both config and specs\\n\\nThis is part of the isolation strategy: the monorepo root cannot influence how the suite's TypeScript is compiled, and the suite cannot accidentally pick up root-level type overrides.\\n\\n### `tests/` directory\\n\\nThe spec root. Any file placed here is executed against all three browser projects. Specs use the standard Playwright pattern:\\n\\n```ts\\nimport { test, expect } from \\\"@playwright/test\\\";\\n\\ntest(\\\"the page renders\\\", async ({ page }) =&gt; {\\n  await page.goto(\\\"/\\\");\\n  await expect(page.locator(\\\"body\\\")).toBeVisible();\\n});\\n```\\n\\nThe above is illustrative \u2014 the provided source does not include the `tests/` contents \u2014 but it shows the expected style: plain Playwright specs that navigate the app under test via the configured `baseURL`.\\n\\n## Running the suite\\n\\n```bash\\ncd e2e\\nnpm install\\nnpm test\\n```\\n\\nThe application must already be running. To target a different environment:\\n\\n```bash\\nE2E_BASE_URL=https://staging.example.com npm test\\n```\\n\\nAfter a run, open the report:\\n\\n```bash\\nnpm run test:report\\n```\\n\\nNotes on runtime behavior:\\n\\n- Tests run serially in one worker; do not expect the parallelism of a typical CI split.\\n- A failing test exits non-zero with full capture artifacts (trace, video, screenshot) \u2014 suitable for gating CI.\\n- With `screenshot: \\\"on\\\"` across three engines, `artifacts/` grows quickly; plan for artifact retention in CI.\\n\\n## Relationship to the rest of the codebase\\n\\nThis module is intentionally disconnected from the repository's build and module graph:\\n\\n- **No compile-time or build-time dependency.** Nothing in the monorepo imports `assureworx-e2e-stability`, and the suite imports nothing from the monorepo. Its only dependencies are Playwright and TypeScript.\\n- **The sole integration point is the HTTP server.** The suite behaves like a user's browser; the running app listening on `E2E_BASE_URL` is the contract.\\n- **Isolation from monorepo overrides.** The package pins its own `@playwright/test` version and uses its own `tsconfig.json`, so root workspace tooling cannot silently change Playwright behavior, TypeScript compilation, or dependency resolution for this module.\\n\\nThe practical consequence: the suite can run anywhere the app can run \u2014 local development, CI, staging, or production smoke tests \u2014 without needing the monorepo's build artifacts or module graph to resolve.\\n\\n## Operational notes\\n\\n- **Hang diagnosis.** The 120s test timeout and 60s navigation timeout are deliberately generous. If a rendering defect manifests as a hang, Playwright's per-project trace/video artifacts localize the failure, and CDP-level traces are available as a manual out-of-band diagnostic.\\n- **Artifact volume.** With `screenshot: \\\"on\\\"` across three browser engines, `artifacts/` accumulates large volumes of output. Treat it as disposable runtime data.\\n- **Serial execution.** `workers: 1` makes the suite slower than a parallelized layout, but yields deterministic, resource-stable runs \u2014 the right trade-off for stability and rendering regression work.\\n- **Engine coverage.** The Firefox and WebKit projects are the primary differentiators from developer-only Chrome testing. Regressions specific to non-Chromium engines are the main catch target of this suite.\",\"other-frontend-public\":\"# Other \u2014 frontend-public\\n\\n# frontend-public\\n\\nStatic frontend assets and image-processing utilities. The module contains a vendored copy of the Leaflet map stylesheet and a Python script that trims status badge images into centered, text-free icon marks.\\n\\n## Module Contents\\n\\n| Path | Type | Purpose |\\n|---|---|---|\\n| `leaflet/leaflet.css` | Vendored CSS | Leaflet map library styles (version-agnostic upstream file) |\\n| `strip_text.py` | Python script | Crops status badge PNGs to remove trailing text, producing `_mark.png` icon files |\\n| `*.png` | Source assets | Input images: `verified.png`, `pending.png`, `action_required.png`, `status_header.png` |\\n| `*_mark.png` | Generated output | Cropped and centered icon variants produced by `strip_text.py` |\\n\\n## `strip_text.py`\\n\\nA standalone, dependency-light image processing script. It strips the text label from below a logo/badge graphic and saves a tightly cropped, centered icon. It has no module imports beyond `PIL`, `numpy`, and `os`, and no functions \u2014 it executes top-to-bottom when run.\\n\\n### Inputs and Outputs\\n\\nThe script processes four source images, referenced by the `NAMES` list:\\n\\n- `verified.png`\\n- `pending.png`\\n- `action_required.png`\\n- `status_header.png`\\n\\nFor each input it produces a corresponding output file:\\n\\n```\\nverified.png          \u2192 verified_mark.png\\npending.png            \u2192 pending_mark.png\\naction_required.png    \u2192 action_required_mark.png\\nstatus_header.png      \u2192 status_header_mark.png\\n```\\n\\nAll files are read from and written to the same directory as the script (`frontend/public/`), resolved at runtime via `os.path.dirname(__file__)`.\\n\\n### Processing Pipeline\\n\\nThe core algorithm operates on the alpha channel rather than RGB values, since the badges use transparency to define their visible shape.\\n\\n1. **Load and extract alpha.** Each PNG is opened with PIL and converted to a NumPy array. The alpha channel is extracted as `float32` and reduced to row sums.\\n\\n2. **Find the logo/badge extent.** Rows with any nonzero alpha are identified. The top of the content is the first such row, and the bottom is initially the last.\\n\\n3. **Detect the text gap.** The script walks downward from the top, looking for the first significant \\\"dip\\\" in row alpha-sum. A dip is defined as a row whose sum drops below `DIP_THRESHOLD` (2000), **after** at least one row has exceeded 5000. This two-threshold scheme skips the leading anti-aliased edge of the badge and only triggers on the real visual gap between the badge graphic and the text below it. If no dip is found, the bottom of the content is used as-is.\\n\\n4. **Column crop.** Within the row range `[top, bottom)`, columns that contain any visible alpha are found, giving the horizontal bounds of the badge.\\n\\n5. **Pad.** `PAD` (10 pixels) is added on all sides, clamped to the image boundaries.\\n\\n6. **Centered composition.** Since the four badges crop to different sizes, the script determines the maximum width and height across all crops, then pastes each crop onto a transparent canvas of that size, centered both vertically and horizontally. This guarantees uniform dimensions across all four output files.\\n\\n7. **Save.** Each composed canvas is saved as `{name}_mark.png` in RGBA mode.\\n\\n### Flow Diagram\\n\\n```mermaid\\nflowchart TD\\n    A[Open PNG] --&gt; B[Extract alpha channel]\\n    B --&gt; C[Compute row alpha-sums]\\n    C --&gt; D[Detect top of content]\\n    D --&gt; E[Scan for first deep dip in row sums]\\n    E --&gt; F{Deep dip found?}\\n    F -- Yes --&gt; G[Use dip row as bottom]\\n    F -- No --&gt; H[Use last content row as bottom]\\n    G --&gt; I[Column-crop within row range]\\n    H --&gt; I\\n    I --&gt; J[Apply 10px padding]\\n    J --&gt; K[Center on uniform max-size canvas]\\n    K --&gt; L[Save _mark.png]\\n```\\n\\n### Tuning Parameters\\n\\nThe two threshold constants control crop behavior:\\n\\n- `DIP_THRESHOLD = 2000` \u2014 a row with alpha-sum below this counts as a gap. Lower values require a cleaner gap; higher values trigger the crop earlier and more aggressively.\\n- The `5000` threshold (hardcoded in the scan loop) marks \\\"real content\\\" \u2014 a row must exceed this before a dip is considered valid. This prevents the anti-aliased top edge of the badge from being mistaken for the badge/text gap.\\n\\nIf the source images change (new badge art, different text length, different resolution), these values may need adjustment.\\n\\n### Runtime Diagnostics\\n\\nThe script prints status information to stdout:\\n\\n```\\nverified crop size (W, H)\\nsaved /path/to/verified_mark.png (W, H)\\n```\\n\\nThis is useful for verifying that the dip detection found the intended boundary, since the printed crop height visibly reflects whether text was excluded.\\n\\n## `leaflet/leaflet.css`\\n\\nA vendored copy of the Leaflet map stylesheet. It is the standard upstream CSS file and is not modified or extended by this project.\\n\\nThe stylesheet defines:\\n\\n- Positioning and z-index layering for map panes (`tile`, `overlay`, `shadow`, `marker`, `tooltip`, `popup`)\\n- Interaction behavior: touch gestures, drag cursors, user-select/drag suppression on tiles\\n- Controls: zoom buttons, layers control, attribution, scale bar\\n- Popup and tooltip rendering, including directional arrow positioning\\n- Zoom/fade animations\\n- Print-specific color-adjustment rules\\n\\nIt is consumed directly when the leaflet map is loaded in the frontend. Because it is vendored, updates should come from upstream Leaflet releases rather than hand edits.\\n\\n## Integration with the Codebase\\n\\nThis module is a leaf node \u2014 nothing imports from it as a code dependency, and it makes no calls into the rest of the codebase.\\n\\n- `strip_text.py` is a build-time utility. It is run manually or as part of an asset-preparation step whenever the source badge PNGs change. Downstream components (UI markup, map markers, status indicators) reference the generated `*_mark.png` files by filename.\\n- `leaflet.css` is served as a static asset and loaded by the frontend HTML when initializing the map.\\n\\nThere are no runtime dependencies between the two parts of this module; they are grouped together because they share the `frontend/public/` static asset directory.\",\"other-frontend-src\":\"# Other \u2014 frontend-src\\n\\n# frontend-src (app) \u2014 CSS Module Layer\\n\\n## 1. Overview\\n\\nThe `frontend/src/app/(app)` directory contains the authenticated application area of the Assureworx frontend. This module is the styling layer for that area: a set of colocated CSS Module files, one per route or feature, that define the visual contract for the page components.\\n\\nThis module contains **no TypeScript, state, data fetching, or routing logic**. It is the presentational specification for:\\n\\n- the **dashboard** \u2014 KPI row, gauges, 2\u00d72 panel workspace, trade rail, quick-actions menu, activity-rail reservation\\n- the **admin** console \u2014 tab strip, metric cards, searchable tables, verification verdicts, event log\\n- **profile** and **documents** \u2014 status hero, verification seal, work checks, details board, document register\\n- **projects** \u2014 list workspace, creation wizard, project detail, site detail\\n- **requests** \u2014 requirement builder, invite share strip, request register\\n- **billing** \u2014 payment outcome, price summary, allocations\\n- **jobs** \u2014 segmented tab bar\\n\\nBecause Next.js CSS Modules scope class names per file at build time, every class in this module is local to its route. The files share a global design-token vocabulary (defined at the app root, outside this module) and several deliberately consistent layout grammars.\\n\\n## 2. Module Map\\n\\n| Route / feature | Stylesheet | Scope |\\n|---|---|---|\\n| `(app)/dashboard` | `dashboard/dashboard.module.css` | Board grid, KPI row, gauge cluster, workspace panels, trade rail, activity-rail margin, quick-actions menu, filter chip, expiry/readiness lists |\\n| `(app)/admin` | `admin/admin.module.css` | Tab strip, metric cards, toolbar/search, review tables, verdict pills, file links, event log, review-reason select |\\n| `(app)/profile` | `profile/profile.module.css` | Status hero, avatar + verified seal, status meta, work checks, details board, section ribbon, mobile bottom nav |\\n| `(app)/profile/documents` | `profile/documents/documents.module.css` | Desktop operational-row table, mobile card experience (rollback path), inline upload panel |\\n| `(app)/projects` | `projects/projects.module.css` | Workspace panel, view toggle, project list rows, readiness bars |\\n| `(app)/projects` (create) | `projects/CreateProjectWizard.module.css` | Stepper, step badges, draft picker, site/trade selection, contractor invitations, review screen |\\n| `(app)/projects/[id]` | `projects/[id]/project-detail.module.css` | Project header, metric rail, request wizard stepper, checklist grid, site workspace, map frame |\\n| `(app)/projects/[id]/sites/[sid]` | `projects/[id]/sites/[sid]/site.module.css` | Site header, workforce surface |\\n| `(app)/requests` | `requests/requests.module.css` | Requirement groups, toggles, share strip, recognition notices, request register table |\\n| `(app)/billing` | `billing/billing.module.css` | Thanks panel, payment status summary, price block, reason list, allocations |\\n| `(app)/jobs` | `jobs/jobs.module.css` | Segmented tab bar with counts |\\n\\n```mermaid\\nflowchart TD\\n    App[\\\"frontend/src/app/(app)authenticated shell\\\"] --&gt; Dash[\\\"dashboard/\\\"]\\n    App --&gt; Admin[\\\"admin/\\\"]\\n    App --&gt; Prof[\\\"profile/ + documents/\\\"]\\n    App --&gt; Proj[\\\"projects/list \u00b7 create \u00b7 detail \u00b7 site\\\"]\\n    App --&gt; Req[\\\"requests/\\\"]\\n    App --&gt; Bill[\\\"billing/\\\"]\\n    App --&gt; Jobs[\\\"jobs/\\\"]\\n    Dash --&gt; D1[\\\"KPI row + gauges\\\"]\\n    Dash --&gt; D2[\\\"2\u00d72 workspace + trade rail\\\"]\\n    Proj --&gt; P1[\\\"CreateProjectWizard\\\"]\\n    Proj --&gt; P2[\\\"project-detail + site\\\"]\\n```\\n\\n## 3. Design Foundations\\n\\nEvery file consumes a shared set of CSS custom properties. The module never hardcodes a color, radius, or spacing value where a token exists. The only intentional exceptions are local aliases \u2014 dashboard's `--dash-*` color-mix values and billing's `#f3faf5` thanks-panel background.\\n\\n### 3.1 Token groups referenced\\n\\n- **Neutrals / surfaces** \u2014 `--color-carbon` (primary text), `--color-graphite` (secondary), `--color-pewter` (muted), `--color-canvas` (page background), `--color-ash` (subtle fill), `--color-cloud`/`--color-pale` (borders), `--surface-panel`, `--surface-muted`, `--surface-hover`\\n- **Accents** \u2014 `--color-electric-blue`, `--color-electric-dark`, `--color-violet`, `--color-deep-violet`, `--color-link`, `--color-link-hover`\\n- **Status** \u2014 `--status-verified-*`, `--status-warning-*`, `--status-error-*`, `--status-info-*`, `--status-portable-*`, each with `-bg`, `-fg`, `-bd`, `-ring`, or `-seal` variants\\n- **RAG readiness** \u2014 `--rag-ready`, `--rag-pending`, `--rag-attention`, `--rag-blocked`\\n- **Typography** \u2014 `--text-meta`, `--text-label`, `--text-body`, `--text-page`, `--text-title`, `--text-display`, `--font-mono`, `--weight-*`, `--leading-*`, `--tracking-tight`\\n- **Geometry** \u2014 `--radius-sm/md/lg/panel/pill/identity-mark`, `--border-default`, `--space-xs`\u2026`--space-xl`, `--focus-ring`\\n- **Motion / depth** \u2014 `--duration-fast`, `--ease-out`, `--z-sticky`, `--z-modal-backdrop`, `--z-modal`, `--shadow-card-hover`\\n\\n### 3.2 Status \u2192 surface mapping\\n\\nStatus state appears as a consistent tint vocabulary across routes:\\n\\n| State | Tokens | Visible in |\\n|---|---|---|\\n| Verified / ready | `--status-verified-*`, `--rag-ready` | `.verdictClean`, `.status_verified`, gauge segments, `.stepBadgeDone` |\\n| Attention / action required | `--status-warning-*`, `--rag-attention` | `.metricAlert`, `.expiryCohortMissing`, `.verdictCheck`, `.status_action_required` |\\n| Blocked / error | `--status-error-*`, `--rag-blocked` | `.verdictHold`, `.expiryCohortFailed`, `.status_suspended`, `.siteMarkerBlocked` |\\n| Info / pending | `--status-info-*`, `--color-electric-blue` | `.status_pending`, active steppers, `.checkItem:has(input:checked)` |\\n\\nThe profile page exposes the full state machine visually: `.status_verified`, `.status_pending`, `.status_action_required`, and `.status_suspended` each apply a subtle gradient tint derived from the token palette.\\n\\n## 4. Cross-Cutting Patterns\\n\\n### 4.1 Flat token chrome\\n\\nThe stated aesthetic (billing file comment): *\\\"flat token chrome, no gradients, no shadow cards.\\\"* Surfaces are separated by `--border-default` hairlines and near-white `--surface-*` fills. The only gradients are status tints in profile panels; the only prominent shadow is the dashboard `.actionTrigger`'s `--shadow-card-hover`.\\n\\n### 4.2 Identity marks\\n\\nA recurring \\\"black tile with initials/date\\\" motif identifies people, companies, and dates:\\n\\n- `.dateBlock` \u2014 day/month block in dashboard expiry rows\\n- `.statusEmblem` \u2014 clip-path starburst for work status, tone-switched via `[data-tone]`\\n- `.heroAvatar` \u2014 square avatar with hover `.avatarEdit` overlay and `.heroAvatarVerified` ring\\n- `.verifiedSeal` \u2014 small clip-path seal anchored to the avatar corner\\n- `.networkAvatar` \u2014 initials tile in the profile network list\\n\\nThese share `--radius-identity-mark` and the same clip-path polygon, keeping identity rendering consistent with the requests/PartyAvatar system referenced in dashboard comments.\\n\\n### 4.3 Verdict and status pills\\n\\nSmall inline pills summarize review state:\\n\\n- Admin: `.verdict` + `.verdictHold` / `.verdictCheck` / `.verdictClean`\\n- Billing: `.exceptionCell`\\n- Requests: `.recognised` / `.recognisedWarn`\\n\\nEach pill is `inline-flex`, uses a status token for border/background/foreground, and is set in `--text-meta` type.\\n\\n### 4.4 Operational-row grammar\\n\\nTables are the primary collection surface in admin, requests, and documents. The shared grammar:\\n\\n- Fixed or constrained layout (`table-layout: fixed` in requests, `th:nth-last-child(n)` width pins)\\n- Sticky header row (`position: sticky; top: 0` on `.table th`)\\n- Compact 40px headers, ~62px rows\\n- Row hover tinting and selected-row persistence (`.requestRowSelected`)\\n- Expandable detail rows (`.requestDetailRow` \u2192 `.requestDetail`; admin `.readRow` for extraction results)\\n- Monospace identifiers via `.mono`, `.cellSub`, `.awxId`\\n\\nThe documents stylesheet notes this is *\\\"the approved operational-row pilot\\\"* \u2014 it now owns Documents at every viewport, with the previous mobile card experience kept mounted but hidden for low-risk rollback.\\n\\n### 4.5 Focus and accessibility\\n\\nEvery interactive element defines `:focus-visible` with `--focus-ring` (or an explicit outline), and hover-only affordances (`.gaugeTip`, `.avatarEdit`) have `:focus-within` equivalents. The dashboard adds `prefers-reduced-motion` overrides that remove transform transitions and reduce the backdrop blur.\\n\\n### 4.6 Touch accommodation\\n\\nA standard media query appears across files:\\n\\n```css\\n@media (max-width: 1023px), (pointer: coarse) {\\n  .tab { min-height: 44px; }\\n  .search, .select { height: 44px; min-height: 44px; }\\n  .reviewReason { height: 44px; min-height: 44px; }\\n}\\n```\\n\\nThe same pattern raises request toggles to 56px rows, check targets to 44\u00d744, and job tabs to 44px.\\n\\n### 4.7 Mobile behavior\\n\\nAt the small end the module switches to app-like patterns:\\n\\n- **Profile** hides the section ribbon and shows a fixed bottom `.mobileSectionNav`; panels toggle via `data-mobile-panel` / `data-mobile-active` attributes, with the active panel becoming the single scroll container (`overscroll-behavior-y: contain`).\\n- **Documents** swaps the table for a card-centered `.mobileExperience` (kept as rollback; the operational-row table currently shows at all viewports via the second 640px media query).\\n- **Dashboard** converts the board to a single vertically scrolling column; the trade rail becomes a 2\u20133 column grid.\\n- **Admin** metrics lose their left borders and stack; the event list becomes single-column.\\n\\n## 5. Area Walkthrough\\n\\n### 5.1 Dashboard\\n\\n`dashboard/dashboard.module.css` is the largest file and defines the entire board layout:\\n\\n- `.board` is a single-column grid with local color aliases (`--dash-verified`, `--dash-expiring`, `--dash-missing`, `--dash-failed`). `.withActivityRail` reserves `300px` on the right for the ActivityRail component (referenced in comments); its invisible 40px event strip is why `.actions` gets `padding-right: 12px` and an elevated z-index.\\n- `.kpis` packs metric blocks left-to-right. `.kpi` is a clickable block, `.kpiStatic` is inert, `.kpiActive` is tinted. The `.kpiGaugeBlock` renders a cluster of `.gauge` donuts (76\u00d742) with count overlays and hover `.gaugeTip` flyouts containing filter rows (`.gaugeTipRow`).\\n- `.workspace` is the 2\u00d72 grid: `.mid` holds compliance/expiring panels, `.bottom` holds map/companies, and `.tradeRail` spans both rows on the right.\\n- The quick-actions FAB (`.actionTrigger`, 56\u00d756 pill) opens `.actionPill`; the `.actionBackdrop` uses a carbon blur veil, and `:has(.actionMenuElevated)` lifts the whole actions row above it.\\n- Evidence attention is color-coded at row level: `.expiryCohortSoon` (electric blue), `.expiryCohortMissing` (amber `--rag-attention`), `.expiryCohortFailed` (red `--rag-blocked`), matching the comments at the top of the file.\\n\\n### 5.2 Admin\\n\\n`admin/admin.module.css` styles the review console:\\n\\n- `.tabs` is a flat underline tab strip. The inline comment warns not to pull it up \u2014 the page title is portaled into the top bar, and the strip must stay below the top-bar hairline.\\n- `.metrics` is an auto-fit grid of `.metric` cards; `.metricLink` makes a metric clickable; `.metricAlert` recolors the value in warning tone.\\n- The toolbar combines `.search` (flex, 220\u2013360px) and `.select` filters.\\n- `.table` provides the sticky-header data table; `.cellMain` / `.cellMeta` handle truncation; `.mono` renders identifiers in monospace.\\n- Verdict pills, `.fileLink` buttons, the review-reason select (`.reviewReason`), and a `.eventList` timeline complete the console.\\n\\n### 5.3 Profile\\n\\n`profile/profile.module.css` is the identity and compliance hub:\\n\\n- `.statusHero` is a two-column grid: the lead (emblem + headline + copy + `.heroActions`) and the profile column (avatar, verified seal, name, kind, ref, verification).\\n- Status panel variants (`.status_verified`, `.status_pending`, `.status_action_required`, `.status_suspended`) tint the entire panel border and background.\\n- `.statusMeta` is a definition grid with `.statusMetric` and `.statusSince` cells.\\n- `.workBoardInner` / `.detailsBoardInner` are two-column splits with border dividers; `.checksPanel` deliberately sits on canvas (not Ash page grey) as a flat strip.\\n- The mobile section nav (`.mobileSectionNav`) is a fixed bottom grid; hidden panels use `[data-mobile-active=\\\"false\\\"]`.\\n\\n### 5.4 Documents\\n\\n`profile/documents/documents.module.css` implements the dual-path strategy:\\n\\n- Current default (all viewports): `.desktopEvidence`, `.inlineDesktopUpload`, `.desktopActions`, `.viewLink`.\\n- Mobile rollback path: `.mobileExperience`, `.documentCard`, `.documentCardHead`, `.documentFacts`, `.rejectReason`, `.mobileUploadButton`, `.inlineUpload`.\\n- The second `@media (max-width: 640px)` block flips the decision \u2014 `.desktopEvidence` becomes `display: flex` and `.mobileExperience` is hidden \u2014 so the operational-row pilot owns every viewport. The mobile styles remain in the bundle as a rollback.\\n\\n### 5.5 Projects\\n\\nThree stylesheets cover the project area:\\n\\n- **List** (`projects/projects.module.css`): `.workspacePanel` with `.workspaceToolbar` and a `.viewToggle` (map/list). `.projectRow` uses a `.projectHit` grid; `.readinessBar` renders four segment types (`.segReady`, `.segPending`, `.segAction`, `.segBlocked`, `.segEmpty`); the `.openCue` chevron nudges on hover.\\n- **Create wizard** (`CreateProjectWizard.module.css`): a `.stepper` of icon steps with connector lines, `.stepBadge` states (active/done), a `.draftPicker` for resuming drafts, site/trade selection lists (`.siteRow`, `.tradeRow`), an invitation panel with `.invitationTable`, and a review summary (`.reviewDl`, `.reviewPng`).\\n- **Detail** (`[id]/project-detail.module.css`): `.projectHeader` with title, `.refBadge`, and `.metricRail`; a five-step request wizard (`.stepper` \u2192 `.stepPill`); a two-column checklist grid (`.checkList`, `.checkItem`, nested `.tradeBlock` with `.nestedList`); and the site workspace \u2014 `.workspaceGrid` splitting `.operationsColumn` (with `.siteRegister` of `.siteRow` entries and attention/ready/blocked markers) and `.mapFrame` with `.mapLegend`. The workspace supports a map-only collapse via `[aria-expanded]` on `.collapseAction`.\\n- **Site** (`[sid]/site.module.css`): a minimal header (`.siteHeader`, `.eyebrow`) and a scrollable `.workforceSurface` panel.\\n\\n### 5.6 Requests\\n\\n`requests/requests.module.css` covers the request-builder flow:\\n\\n- `.requirementGroups` arranges `.requirementGroup` cards; each `.requirementItem` is a full-row toggle target with a hidden `.toggleInput` and a custom `.toggle` switch.\\n- The share strip (`.shareStrip`) presents the post-send invite link as the deliverable, with `.shareUrl` in monospace and `.shareActions`.\\n- Pre-invite recognition (`.recognised`, `.recognisedWarn`) flags identities that already map to an Assureworx profile and shows the AWX ID.\\n- The register uses the operational-row grammar: `.requestTable`, `.requestRow` (with `.requestRowSelected`), `.recipientCell`, `.manageButton`, and expandable `.requestDetailRow` \u2192 `.requestDetail`. `.abnRow` aligns an ABN input with a lookup button (`margin-top: 27px`).\\n\\n### 5.7 Billing\\n\\n`billing/billing.module.css` styles payment outcomes:\\n\\n- `.thanksPanel` (success) with `.orderRefInline`; `.statusSummary` with `.paymentStateImage` (112\u00d772) and `.orderRef`\\n- `.priceBlock` / `.priceLine` for the amount breakdown\\n- `.reasonList` for failure/exception messaging (with warning icons)\\n- `.allocationList` for spend allocation rows; `.exceptionCell` for inline warnings\\n\\n### 5.8 Jobs\\n\\n`jobs/jobs.module.css` is a narrow, single-purpose file: a segmented control (`.tabRow` \u2192 `.tabs` \u2192 `.tab`) with a numeric `.count` per segment and an active-state tint.\\n\\n## 6. Responsive Strategy\\n\\n| Breakpoint | What changes |\\n|---|---|\\n| `\u22641279px` | Dashboard workspace rail narrows to 190px; status rows compress |\\n| `\u22641100px` | Project detail: header stacks, workspace goes 1-column, site rows stack, form grid becomes 2-column |\\n| `\u22641023px` | Dashboard board scrolls vertically, workspace 1-column, trade rail becomes 2-column grid; touch targets \u2192 44px (admin, jobs, requests) |\\n| `\u2264960px` | Profile hero stacks; identity moves below the status lead; work/details boards 1-column |\\n| `\u2264900px` | Wizard contractors layout 1-column; project hit row regroups |\\n| `\u2264760px` | Request requirement groups 1-column |\\n| `\u2264720px` | Admin metrics and events stack; dashboard KPI 2-column, rail 3-column; project rows stack; project-detail lists 1-column |\\n| `\u2264640px` | Billing status summary shrinks; documents switch to mobile cards (then back to operational rows via the second media query); profile shows bottom nav and panel toggling |\\n\\nThree recurring media-query idioms are worth noting:\\n\\n```css\\n/* Touch accommodation */\\n@media (max-width: 1023px), (pointer: coarse) { ... }\\n\\n/* Mobile vertical-scroll containment \u2014 deliberately not overflow:auto on desktop */\\n@media (max-width: 1023px) {\\n  .board { overflow-x: hidden; overflow-y: auto; height: auto; }\\n}\\n\\n/* Reduced motion */\\n@media (prefers-reduced-motion: reduce) {\\n  .gaugeTip { transition: none; }\\n  .actionBackdrop { backdrop-filter: blur(10px); }\\n}\\n```\\n\\n## 7. Integration Notes\\n\\n- **Colocation**: Each stylesheet lives beside its route component under `src/app/(app)/...`. Next.js compiles and hashes the class names, so styles are route-scoped and tree-shaken per build.\\n- **Token dependency**: The module assumes a global token layer (CSS custom properties) defined at the app root. It references but does not define `--color-*`, `--surface-*`, `--status-*`, `--rag-*`, `--radius-*`, `--space-*`, `--text-*`, `--font-*`, `--z-*`, `--focus-ring`, or `--border-default`. Local exceptions: dashboard's `--dash-*` aliases and billing's `#f3faf5` thanks panel.\\n- **Component coupling**: The CSS encodes behavioral contracts with its React counterparts via class names, `data-` attributes (`data-tone`, `data-mobile-active`, `data-mobile-panel`), ARIA attributes (`aria-expanded` on collapse/map actions), and `:has()` selectors (`.checkItem:has(input:checked)`, `.actions:has(.actionMenuElevated)`).\\n- **No logic here**: This module has no internal or external calls \u2014 it is presentation-only. Data fetching, state, and handlers live in the colocated TSX components that consume these classes.\\n\\n## 8. Gotchas\\n\\n- **Admin tabs**: Do not change `.tabs` margin \u2014 the strip must remain below the top-bar hairline or tabs clip and appear missing (see the inline comment).\\n- **Dashboard overflow**: `.board` deliberately uses `overflow-x: hidden; overflow-y: hidden` on desktop to keep X clipped while allowing gauge tips to paint; mobile switches to `overflow-y: auto` with `height: auto` to avoid creating a horizontal scrollport.\\n- **Profile mobile flex-shrink**: Children of `.statusStack` must not shrink \u2014 `flex: 0 0 auto` is required, or the work-status hero clips under the fold (comment in the 640px block).\\n- **Documents**: The mobile card experience is currently vestigial \u2014 the second 640px media query re-enables desktop operational rows at all viewports. Don't \\\"fix\\\" the first media query without considering the pilot override in the second.\\n- **`:has()` usage**: The module relies on `:has()` for checkbox item selection and elevated action menus; any browser-support or build-target change must account for that.\",\"other-frontend\":\"# Other \u2014 frontend\\n\\n# @assureworx/frontend\\n\\nNext.js 15 App Router CRM frontend for Assureworx. It renders the Principal Contractor and Trade portals from a single shell and talks to the Express backend exclusively through a Backend-for-Frontend (BFF) proxy (ADR-0008). The package lives in `frontend/` and is published as `@assureworx/frontend` within the monorepo workspace.\\n\\n## Purpose\\n\\nThe frontend module provides:\\n\\n- The CRM user interface (dashboard, auth flows, pass badge with camera-based face gate via MediaPipe, Leaflet maps, QR codes).\\n- A BFF layer so the browser never contacts the API directly \u2014 the Next.js origin proxies `/api/v1/*` to the Express backend, keeping the httpOnly session cookie first-party.\\n- A production runtime gate (`gate-proxy.mjs`) that enforces Basic Auth in front of the Next.js standalone server, since Edge middleware cannot reliably read Container App environment variables at runtime.\\n- A self-contained Docker image with a non-root user and health-check endpoints for Azure Container App probes.\\n\\n## Architecture\\n\\nThe production deployment runs two Node processes inside one container: the gate proxy (public port 3000) and the Next.js standalone server (internal port 3001, bound to 127.0.0.1). The gate proxy is the only entry point.\\n\\n```mermaid\\ngraph LR\\n    B[Browser] --&gt;|\\\":3000\\\"| GP[gate-proxy.mjs]\\n    GP --&gt;|\\\"Basic Auth / health check\\\"| GP\\n    GP --&gt;|\\\"proxy\\\"| NX[Next.js standalone127.0.0.1:3001]\\n    NX --&gt;|\\\"rewrite /api/v1/*\\\"| API[Express backendAPI_ORIGIN]\\n    NX --&gt;|\\\"serve\\\"| ST[static / _next assets]\\n    ACA[Container App probe] --&gt;|\\\"GET /__healthz\\\"| GP\\n```\\n\\nRequest flow:\\n\\n1. Browser (or ACA health probe) hits the gate proxy on `PORT` (default 3000).\\n2. The gate proxy checks whether the request requires Basic Auth, responds directly for health probes, and forwards everything else to Next.js on `NEXT_INTERNAL_PORT` (default 3001).\\n3. Next.js serves pages and static assets. For `/api/v1/*`, Next.js rewrites the request to the Express backend at `API_ORIGIN` (configured in `next.config.ts`).\\n4. The API's httpOnly session cookie is set on the Next.js origin, so the browser sees a single first-party origin.\\n\\nDuring development, `next dev` runs directly (no gate proxy); the `API_ORIGIN` rewrite still applies.\\n\\n## Gate proxy (`gate-proxy.mjs`)\\n\\nThe gate proxy is a plain Node `http` server that spawns the Next.js standalone server (`server.js` in the standalone output) as a child process. It exists because:\\n\\n- Container App environment variables are not reliably visible to Next.js Edge middleware (they are inlined at build time).\\n- The proxy reads `DEV_SITE_*` variables from the real process environment **per request**, so the auth gate can be toggled without rebuilding the image.\\n\\n### Startup and lifecycle\\n\\n- Reads `PORT` (public), `NEXT_INTERNAL_PORT` (Next.js child port), and `HOSTNAME`.\\n- Spawns `node server.js` with `stdio: \\\"inherit\\\"` and overrides the child's `PORT` and `HOSTNAME` env to `NEXT_INTERNAL_PORT` and `127.0.0.1`.\\n- On child exit, logs the code/signal and exits with a non-zero status.\\n- Handles `SIGTERM`/`SIGINT` by forwarding the signal to the Next.js child, closing the HTTP server, then exiting (`shutdown` function).\\n\\n### Basic Auth gate\\n\\nThe gate is enabled when `DEV_SITE_GATE_ENABLED` is truthy (`1`, `true`, `yes`, `on` \u2014 see `envTruthy`). When enabled:\\n\\n- Credentials come from `DEV_SITE_BASIC_USER` and `DEV_SITE_BASIC_PASSWORD`. If either is missing, the proxy returns `503 Site gate misconfigured` rather than serving unauthenticated traffic.\\n- Requests without valid credentials receive `401` with a `WWW-Authenticate: Basic` challenge and `Cache-Control: no-store`.\\n- Credential comparison uses `timingSafeEqual` (constant-time XOR comparison) to avoid timing side channels.\\n- The following requests bypass auth entirely (`isUnauthedGetPath`):\\n  - `GET`/`HEAD` for `/__healthz` and `/healthz` (platform probes).\\n  - `GET`/`HEAD` for `/_next/static/*` (Next.js build output).\\n  - `GET`/`HEAD` for public asset extensions: png, jpg/jpeg, gif, webp, svg, ico, woff/woff2, ttf, otf, map. This is required because `next/image` fetches assets server-side without forwarding the browser's Basic Auth header.\\n\\n### Dev auth header injection\\n\\nEdge middleware may not see the dev auth secret, so the proxy injects it. For requests to `/api/v1/auth/dev-personas` and `POST /api/v1/auth/login`, if `DEV_AUTH_SECRET` is set, the proxy adds the `x-assureworx-dev-auth` header before forwarding. This lets dev login flows reach the API authenticated without browser credentials.\\n\\n### Proxying\\n\\nThe `proxy` function forwards the request to Next.js, stripping hop-by-hop headers (`connection`, `keep-alive`, `proxy-connection`, `transfer-encoding`) to avoid double compression and upstream issues. Upstream errors that occur before headers are sent produce `502 Bad Gateway` with the error message.\\n\\n## Next.js configuration (`next.config.ts`)\\n\\n### API rewrites (BFF)\\n\\nAll browser API traffic is proxied through the Next.js origin:\\n\\n```ts\\nsource: \\\"/api/v1/:path*\\\",\\ndestination: `${API_ORIGIN}/api/v1/:path*`,\\n```\\n\\n`API_ORIGIN` defaults to `http://localhost:4000`. In the Docker build it is baked into the rewrites at build time, and re-declared in the runner stage so server-side fetches (RSC, `server-api.ts`) hit the real API rather than `localhost`.\\n\\n### Security headers\\n\\nApplied to every route via `headers()`:\\n\\n- `X-Frame-Options: DENY` and `frame-ancestors 'none'` \u2014 embedding refused.\\n- `X-Content-Type-Options: nosniff`.\\n- `Referrer-Policy: strict-origin-when-cross-origin`.\\n- `Permissions-Policy: camera=(self), microphone=(), geolocation=(), payment=()` \u2014 the narrowest camera grant that still allows `getUserMedia` for the pass badge and verify-selfie step.\\n- A full `Content-Security-Policy`:\\n  - `script-src` allows `'unsafe-inline'` and `'unsafe-eval'` (required in practice for App Router hydration) plus `'wasm-unsafe-eval'` for MediaPipe WASM.\\n  - `connect-src` allows the Next.js origin, `https://cdn.jsdelivr.net` and `https://storage.googleapis.com` (MediaPipe model assets), plus `http://localhost:8400` in development for the Impeccable live-mode integration.\\n  - `img-src` allows `data:` and `blob:` URIs (photos), Carto CDN basemap tiles (Leaflet), and Azure Blob storage.\\n- `Strict-Transport-Security` is added only in production builds.\\n\\n### Other settings\\n\\n- `output: \\\"standalone\\\"` \u2014 produces the self-contained server copied into the Docker image.\\n- `reactStrictMode: true`.\\n- `devIndicators: false` \u2014 hides the route indicator in demos (audit L1); error overlays remain.\\n- `allowedDevOrigins` \u2014 allows LAN HTTP origins for mobile testing (`LAN_DEV_ORIGIN_HOST` from `scripts/run-mobile-demo.bat`, plus `192.168.1.94` and `*.local`). Next 15 warns on unknown dev origins; this list suppresses that.\\n\\n## Docker build\\n\\nThe `frontend/Dockerfile` is a three-stage build whose context is the repository root:\\n\\n1. **deps** \u2014 installs workspace dependencies with `npm ci -w @assureworx/frontend --include-workspace-root`, copying only the `package.json` files needed for resolution (`frontend/`, `backend/`, `packages/shared-types/`).\\n2. **build** \u2014 copies the full workspace (including `packages/shared-types` and `frontend/`), sets `API_ORIGIN` as a build arg (baked into Next rewrites), disables telemetry, and runs `next build`.\\n3. **runner** \u2014 production image:\\n   - Adds image labels from `DEPLOY_COMMIT`, `DEPLOY_BUILT_AT`, `DEPLOY_DIRTY`.\\n   - Re-declares `API_ORIGIN` so runtime server-side fetches use the real backend.\\n   - Creates an `awx` non-root user/group.\\n   - Copies the standalone output, static assets, and `gate-proxy.mjs`.\\n   - Creates and chowns `/app/frontend/.next/cache` \u2014 without this, the non-root user gets `EACCES` and every `` renders a broken icon.\\n   - Runs `node frontend/gate-proxy.mjs` as the `awx` user.\\n\\n## Environment variables\\n\\n| Variable | Default | Used by | Purpose |\\n|---|---|---|---|\\n| `API_ORIGIN` | `http://localhost:4000` | `next.config.ts`, Docker | Backend origin for `/api/v1/*` rewrites and runtime server-side fetches |\\n| `PORT` | `3000` | `gate-proxy.mjs` | Public listen port |\\n| `NEXT_INTERNAL_PORT` | `3001` | `gate-proxy.mjs` | Port for the spawned Next.js child process |\\n| `HOSTNAME` | `0.0.0.0` | `gate-proxy.mjs` | Bind address for the public server |\\n| `DEV_SITE_GATE_ENABLED` | \u2014 | `gate-proxy.mjs` | Enables the Basic Auth gate (truthy: `1`/`true`/`yes`/`on`) |\\n| `DEV_SITE_BASIC_USER` | \u2014 | `gate-proxy.mjs` | Basic Auth username |\\n| `DEV_SITE_BASIC_PASSWORD` | \u2014 | `gate-proxy.mjs` | Basic Auth password |\\n| `DEV_AUTH_SECRET` | \u2014 | `gate-proxy.mjs` | Injected as `x-assureworx-dev-auth` on dev auth endpoints |\\n| `LAN_DEV_ORIGIN_HOST` | \u2014 | `next.config.ts` | Allow-listed dev origin for LAN mobile testing |\\n| `NEXT_TELEMETRY_DISABLED` | \u2014 | Docker | Disables Next.js telemetry |\\n| `NODE_ENV` | `production` (Docker) | Next.js | Runtime mode |\\n\\nDocker build args `DEPLOY_COMMIT`, `DEPLOY_BUILT_AT`, and `DEPLOY_DIRTY` feed container image labels only.\\n\\n## Scripts (from `frontend/package.json`)\\n\\n| Script | Command | Purpose |\\n|---|---|---|\\n| `dev` | `next dev --hostname 127.0.0.1 --port 3000` | Local development |\\n| `dev:turbo` | `next dev --turbopack` | Local development with Turbopack |\\n| `dev:alt` | `next dev --port 3001` | Alternate port for side-by-side runs |\\n| `dev:lan` | `next dev --hostname 0.0.0.0 --port 3000 --experimental-https` | LAN phone testing over HTTPS |\\n| `build` | `next build` | Production build (standalone output) |\\n| `start` | `next start` | Start the built app without the gate proxy |\\n| `typecheck` | `tsc -p tsconfig.json --noEmit` | Type checking |\\n| `lint` | `eslint .` | ESLint over the package |\\n| `perf:selftest` | `tsx src/lib/perf/selftest.ts` | Performance harness selftest |\\n| `perf:prod` | `tsx src/lib/perf/prod-scenarios.ts` | Production scenario performance runs |\\n| `perf:localhost` | `tsx src/lib/perf/localhost-stress.ts` | Localhost stress testing |\\n\\n## TypeScript and linting\\n\\n`tsconfig.json` uses strict mode, `moduleResolution: \\\"bundler\\\"`, and the `@/*` path alias mapped to `./src/*`. The Next.js type plugin is enabled, and `.next/types/**/*.ts` is included so generated route types are checked.\\n\\n`eslint.config.mjs` uses the flat config format with:\\n\\n- `@eslint/js` recommended, `typescript-eslint` recommended, and `@next/eslint-plugin-next` recommended rules.\\n- `react-hooks/rules-of-hooks` as error and `react-hooks/exhaustive-deps` as warning.\\n- `@typescript-eslint/no-unused-vars` as warning with `argsIgnorePattern: \\\"^_\\\"`.\\n- The `@next/next` and `react-hooks` plugins registered specifically for `*.{ts,tsx}` files so `eslint-disable` comments naming those rules resolve \u2014 without this, `next build` fails because the disable comments are themselves lint errors.\\n- Custom globals (`Buffer`, `console`, `process`, `setTimeout`) for `*.mjs` files, covering `gate-proxy.mjs`.\\n\\n## Dependencies and integration points\\n\\n- **`@assureworx/shared-types`** \u2014 shared TypeScript types consumed via the workspace.\\n- **`@assureworx/backend`** \u2014 the Express API; consumed only through `/api/v1/*` rewrites. The proxy URL is configured by `API_ORIGIN`; in dev the default `http://localhost:4000` matches the backend's local port.\\n- **`@mediapipe/tasks-vision`** \u2014 MediaPipe Face Landmarker for the pass-photo face gate (WASM/`.tflite` loaded from jsDelivr/storage.googleapis.com, allowed by CSP).\\n- **`leaflet` / `react-leaflet`** \u2014 maps with Carto CDN tiles (CSP `img-src` allow-list).\\n- **`qrcode`** \u2014 QR generation for pass badges.\\n- **`server-only`** \u2014 marks server-only modules so they cannot be accidentally imported from client components.\\n- **`next/image`** \u2014 requires a writable `.next/cache` directory in the container (handled in the Dockerfile).\\n\\n## Development notes\\n\\n- Run from the repo root: `npm install`, then `npm run dev:frontend` (or `npm run dev -w @assureworx/frontend`).\\n- The **Principal / Trade** toggle in the top bar switches between demo portals \u2014 same shell, different navigation.\\n- Design tokens and visual-system contracts live in the repository root `DESIGN.md` and `PRODUCT.md`; brand narrative in `wiki/brand.md`.\\n- Logos live in `frontend/public/` (`logo.png`, `logo-dark.png`, `fav.png`).\",\"other-isms-archive\":\"# Other \u2014 isms-archive\\n\\n# isms-archive Module\\n\\n## Overview\\n\\n`isms/archive/` is a document archival directory within the Assureworx ISMS repository. It holds **superseded controlled Markdown sources** \u2014 documents that were once active, approved ISMS content but have since been replaced by a newer version or a new document.\\n\\nThis module is **not executable code**. It has no functions, no runtime behavior, no internal or external calls, and no execution flows. It is a governance-controlled storage location, enforced by repository convention and the documents it links to. Contributions to this module are documentation moves, not code changes.\\n\\nThe module itself is a controlled document: it carries frontmatter metadata (`type: isms`, `status: current`, `owner: Assureworx directors`, `review: 2027-07-28`) and is subject to periodic review.\\n\\n## Purpose\\n\\nThe archive exists to preserve the audit trail of the ISMS document lifecycle. When a controlled document is superseded, the repository must retain:\\n\\n- the **last approved version** of the superseded source,\\n- the **approval record** for that version,\\n- a **replacement reference** pointing to the document that superseded it.\\n\\nMoving superseded content into `isms/archive/` keeps the active document tree clean, so readers always see only current, approved sources \u2014 while the archive preserves what was previously in force and why it was retired.\\n\\n## Archival Rules\\n\\nThe module's governing rule, from the README:\\n\\n&gt; Move superseded controlled Markdown sources here only after the replacement is approved.\\n\\nTwo constraints follow from this:\\n\\n1. **Archival is a post-approval action.** A document may not be moved into the archive on the basis of an *intended* or *draft* replacement. The replacement must already be approved.\\n2. **Archival preserves three artifacts**, placed alongside the superseded source:\\n   - the last approved version of the document,\\n   - the approval record,\\n   - a reference to the approved replacement.\\n\\n## Module Layout\\n\\n| Path | Contents |\\n|---|---|\\n| `isms/archive/` | Superseded controlled Markdown sources, each with its approval record and replacement reference |\\n| `deliverables/Assureworx_ISMS/99_Superseded/` | Generated Word release copies of superseded documents |\\n| `../governance/document-register.md` | Document register governing the archive (relative link) |\\n| `../governance/document-control-and-approval-procedure.md` | Approval procedure governing when archival may occur (relative link) |\\n\\nNote the separation of source and deliverable:\\n\\n- **Markdown sources** \u2014 the canonical controlled format \u2014 are archived under `isms/archive/`.\\n- **Word release copies**, which are generated deliverables derived from the Markdown sources, are archived separately under the deliverables tree in `99_Superseded`.\\n\\nIf you archive a superseded Markdown source, check whether a generated Word copy exists in `deliverables/Assureworx_ISMS/` and move it to `99_Superseded/` as well.\\n\\n## Archival Workflow\\n\\n```mermaid\\nflowchart LR\\n    A[Active controlleddocument] --&gt; B[Replacement documentapproved via doc controlprocedure]\\n    B --&gt; C[Move superseded Markdownsource to isms/archive/]\\n    C --&gt; D[Attach last approved version,approval record,replacement reference]\\n    B --&gt; E[Move generated Word copyto deliverables/...99_Superseded[\\\"/99_Superseded/\\\"]]\\n    D --&gt; F[Archive complete;active tree showsonly current docs]\\n    E --&gt; F\\n```\\n\\nEach node corresponds to a manual repository operation by a contributor with write access:\\n\\n1. Confirm the replacement document is approved, per the [document control and approval procedure](../governance/document-control-and-approval-procedure.md).\\n2. Move the superseded Markdown source into `isms/archive/`.\\n3. Add the three required artifacts alongside it: last approved version, approval record, replacement reference.\\n4. If a Word release copy exists, move it to `deliverables/Assureworx_ISMS/99_Superseded/`.\\n5. Update any references so the archived source is no longer linked from active document indexes (coordinated with the [document register](../governance/document-register.md)).\\n\\nThere is no automated enforcement of this workflow \u2014 no scripts, hooks, or CI checks are part of this module. Compliance is maintained through the document control procedure and periodic reviews.\\n\\n## Connections to the Rest of the Repository\\n\\nAlthough the archive itself has no runtime calls, it participates in the document governance structure through two relative links:\\n\\n- **Document register** \u2014 `../governance/document-register.md` \u2014 the register tracks the state of controlled documents. When a document is superseded and archived, its register entry should reflect the archived status and the replacement reference.\\n- **Document control and approval procedure** \u2014 `../governance/document-control-and-approval-procedure.md` \u2014 defines the approval process that must complete *before* a source may be moved into the archive. This is the gate for the archival workflow.\\n\\nIn the broader repository structure, `isms/archive/` sits alongside active ISMS documentation, while the generated Word deliverables for superseded content live under the `deliverables/Assureworx_ISMS/` tree. Anyone maintaining the archive needs to keep both locations in sync.\\n\\n## Contributor Notes\\n\\n- **No code lives here.** Do not add scripts, templates, or automation to this module without revisiting its governance role.\\n- **Do not archive active documents.** Only move sources whose replacement has been approved.\\n- **Do not strip metadata.** The archived document's approval record is part of the required artifact set; preserve it.\\n- **Keep both archives in sync.** Markdown sources go to `isms/archive/`; generated Word copies go to `deliverables/Assureworx_ISMS/99_Superseded/`.\\n- **Respect the review cycle.** The module README is owned by the Assureworx directors and scheduled for review (`review: 2027-07-28`). Changes to the archival rules should go through the document control procedure rather than direct edits.\",\"other-isms-assurance\":\"# Other \u2014 isms-assurance\\n\\n# isms/assurance \u2014 Factual assurance\\n\\n## Purpose\\n\\n`isms/assurance` is the factual assurance layer of the Assureworx ISMS. It maintains a machine-readable register of material security statements, each tied to concrete evidence in source code, database migrations, or deployed configuration. The goal is to convert claims like \\\"tenant data is isolated by row level security\\\" or \\\"card data never reaches Assureworx systems\\\" from assertions into things that can be mechanically checked against the repository.\\n\\nThis is **not a runtime code module**. Nothing under `isms/assurance/` is imported or executed by the application. It is a governance artefact, but one designed to stay truthful: evidence markers are embedded in the codebase, and a validation script fails when those markers disappear or when review dates lapse.\\n\\n## Module layout\\n\\n```\\nisms/assurance/\\n\u251c\u2500\u2500 index.md                      # entry point, procedure links, review metadata\\n\u251c\u2500\u2500 control-truth-register.json   # the claim register: 24 claims (TRUTH 001\u2013024)\\n\u2514\u2500\u2500 (referenced) scripts/isms-truth-check.mjs   # validation script\\n```\\n\\nThe module references outward to three kinds of documents:\\n\\n- **Authority layer** \u2014 policies (`isms/policies/`), plans (`isms/plans/`), the Statement of Applicability (`isms/soa.md`), and supplier posture documents (`isms/didit-posture.md`, `isms/doc-intel-posture.md`, `isms/stripe-posture.md`).\\n- **Evidence layer** \u2014 implementation files under `backend/`, `frontend/`, and `db/migrations/`, plus decision records under `wiki/decisions/` and architecture notes under `wiki/architecture/`.\\n- **Related assurance artefacts** \u2014 `isms/evidence-register.md`, `isms/soa.md`, and `wiki/architecture/security.md`.\\n\\n## The control truth register\\n\\n`control-truth-register.json` is the core of the module. It has a `schema_version` (currently 1), a top-level `updated` date, and a flat `claims` array. Each claim uses the same schema:\\n\\n| Field | Meaning |\\n|---|---|\\n| `id` | Stable identifier, `TRUTH 001` \u2026 `TRUTH 024` |\\n| `statement` | Plain-language security assertion, readable by a director as well as an engineer |\\n| `classification` | `current_fact` or `known_gap` |\\n| `status` | How the statement is verified (see below) |\\n| `owner` | Person or role accountable for the claim |\\n| `last_reviewed` | ISO date of the most recent review |\\n| `review_interval_days` | Review cadence, 30 or 90 days |\\n| `policy_refs` | Paths to the policy, plan, or decision record the claim implements |\\n| `evidence` | One or more `{ \\\"path\\\": ..., \\\"contains\\\": ... }` pairs pointing at the verifying marker |\\n\\n### Classifications and statuses\\n\\nThe `classification` splits the register into two halves:\\n\\n- **`current_fact`** \u2014 an assertion about how the system behaves today. Status is `verified_in_code` for claims proved by source markers, `requires_deployment_verification` for claims that also depend on an Azure resource setting (e.g. TRUTH 013, `disableLocalAuth` on the document intelligence service), or `requires_deployment_and_supplier_review` for claims that depend on a third-party boundary (e.g. TRUTH 006, Didit media retention).\\n- **`known_gap`** \u2014 a recording of something that is *not* yet satisfied. Status is `open` in all current cases.\\n\\nKnown gaps are first-class citizens, not failures of the register. TRUTH 007 (no branch protection), TRUTH 008 (TEST/PROD not recorded as deployed resources), TRUTH 009 (Didit supplier questions open), and TRUTH 017 (Stripe US processing without a completed APP 8 assessment) are deliberately tracked with owners and 30-day review intervals so they stay visible until closed.\\n\\n## What the claims cover\\n\\nThe 24 claims cluster into six domains:\\n\\n- **Authentication &amp; session hardening** \u2014 TRUTH 001 blocks `ALLOW_DEV_AUTH` outside development; TRUTH 002 pins the `__Host-awx_session` cookie prefix.\\n- **Database security** \u2014 TRUTH 003 asserts row level security is applied and *forced* on tenant-scoped tables (`FORCE ROW LEVEL SECURITY` in `db/migrations/008_security_hardening.sql`); TRUTH 004 asserts application access cannot update or delete `core.t_event` (`REVOKE UPDATE, DELETE ON core.t_event`).\\n- **Artifact handling** \u2014 TRUTH 005 asserts uploaded compliance evidence is SHA-256 hashed before its record is written (`createHash(\\\"sha256\\\")` in `backend/src/modules/artifacts/index.ts`).\\n- **Document extraction** \u2014 TRUTH 010 excludes photo identity documents from every reading tier (`NEVER_EXTRACT = new Set([\\\"A002\\\"])`); TRUTH 011 pins the third-party model ID and API version in source; TRUTH 012 reserves monthly quota before any document leaves the application (`reserveUnits(...)`) and writes every billable read to the audit log (`CLOUD_READ_VERB = \\\"extraction.cloud_read\\\"`); TRUTH 013 confirms key-based auth is disabled and access uses the app's managed identity (`new DefaultAzureCredential()`).\\n- **Payments** \u2014 TRUTH 014 (Stripe-hosted card entry, `mode: \\\"payment\\\"`); TRUTH 015 (no mock provider; `PAYMENTS_NOT_CONFIGURED` reported when unset); TRUTH 016 (live key refused outside the prod tier); TRUTH 018 (settlement only on a signature-verified provider event or reconciliation query, never on page return); TRUTH 019 (partial unique index `ux_item_funded` prevents double funding at the database level); TRUTH 020 (payment funds verification work but never writes the verified status); TRUTH 021 (the verified person sees only funding status, organisation-level payer, and an internal order number \u2014 never amount, payer identity, or provider reference); TRUTH 024 (refund retries are scheduled per refund, so a stuck refund cannot starve newer ones).\\n- **Employer verification workflow** \u2014 TRUTH 022 (the paying organisation is taken from the authenticated session, never the request body, and only current employees can be beneficiaries); TRUTH 023 (check selection is snapshotted at invitation and acceptance, so later edits cannot retroactively change what staff were asked to supply).\\n\\n## How verification works\\n\\nEach `evidence` entry is a `{path, contains}` pair. The verification model is deliberately simple: a claim counts as verified in code when the file at `path` still contains the exact string in `contains`. For example, TRUTH 005 carries:\\n\\n```json\\n{\\n  \\\"path\\\": \\\"backend/src/modules/artifacts/index.ts\\\",\\n  \\\"contains\\\": \\\"createHash(\\\\\\\"sha256\\\\\\\")\\\"\\n}\\n```\\n\\nIf a refactor removes that call without updating the register, the validation script fails.\\n\\nThe script `scripts/isms-truth-check.mjs` validates three things:\\n\\n1. **File existence** \u2014 every `evidence.path` and `policy_refs` target resolves.\\n2. **Marker presence** \u2014 every `contains` string exists in its referenced file.\\n3. **Review freshness** \u2014 `last_reviewed + review_interval_days` is not in the past.\\n\\nThis is a mechanical check, not a semantic one. `index.md` is explicit about the limitation: *the register is a review aid. It does not prove deployment or control operation by itself.* Proving that TRUTH 013's managed identity is actually enabled requires a human to inspect the Azure resource; proving TRUTH 006's media-retention boundary requires a supplier review. The status field exists precisely to record claims where `verified_in_code` would overstate confidence.\\n\\n## Review lifecycle\\n\\nEvery claim has an owner and a review interval:\\n\\n- **90 days** for most `current_fact` claims, owned by the Lead Engineer. These are implementation-anchored and stable.\\n- **30 days** for third-party boundaries and open gaps, owned at Director level \u2014 TRUTH 006 (Didit), TRUTH 007 (PR gate), TRUTH 008 (environments), TRUTH 009 (Didit supplier), TRUTH 017 (Stripe/APP 8).\\n\\nWhen a claim's interval elapses, the owner must either confirm the evidence still holds (bumping `last_reviewed`) or rewrite the claim \u2014 for example, moving a `current_fact` to `known_gap` after a regression, or closing a `known_gap` to `current_fact` once the control lands. The folder-level review date lives in the `index.md` front matter.\\n\\n## Relationships to the rest of the repository\\n\\n```mermaid\\ngraph LR\\n    subgraph assurance[\\\"isms/assurance\\\"]\\n        IDX[\\\"index.md\\\"]\\n        REG[\\\"control-truth-register.jsonTRUTH 001\u2013024\\\"]\\n    end\\n\\n    CHK[\\\"scripts/isms-truth-check.mjs\\\"]\\n    POL[\\\"Authority layerpolicies, plans, posture docs\\\"]\\n    EVID[\\\"Evidence layerbackend/, frontend/, db/migrations/\\\"]\\n    PROC[\\\"governance/factual-assurance-procedure.md\\\"]\\n\\n    IDX --&gt; REG\\n    IDX --&gt; PROCESS_DESC[\\\"describes procedure\\\"] --&gt; PROC\\n    REG --&gt;|policy_refs| POL\\n    REG --&gt;|evidence path + contains| EVID\\n    CHK --&gt;|validates markers &amp; dates| REG\\n    CHK --&gt;|checks file existence| EVID\\n    REG --&gt;|review aid, not proof| HUMAN[\\\"human review on 30/90-day cycle\\\"]\\n```\\n\\nThe claim IDs are stable keys used elsewhere in the ISMS and architecture documents \u2014 `wiki/architecture/payments-design-2026-08-01.md` is referenced from multiple payment claims, and `wiki/decisions/0029` through `0031` anchor TRUTH 022\u2013024. When a control is discussed in a review, planning session, or risk register entry, referencing the claim ID disambiguates exactly which behaviour is meant.\\n\\n## Working with the register\\n\\nTo add a claim:\\n\\n1. Assign the next sequential `id`.\\n2. Write the `statement` in plain language \u2014 it should survive being read by a director, an auditor, and a new engineer.\\n3. Classify honestly: `current_fact` if the control exists, `known_gap` if the register exists to track its absence.\\n4. Set `status` to match the evidence available: `verified_in_code` only for a stable source marker; `requires_deployment_verification` for resource settings; `requires_deployment_and_supplier_review` for third-party boundaries.\\n5. Add at least one `{path, contains}` evidence pair. Choose a marker specific enough that accidental matches are unlikely \u2014 prefer `FORCE ROW LEVEL SECURITY` over `ROW LEVEL SECURITY`.\\n6. Point at the implementing policy or plan in `policy_refs`.\\n7. Set `owner`, `last_reviewed`, and a `review_interval_days` that matches the volatility of the claim (30 for supplier or gap items, 90 for stable code facts).\\n\\nWhen a control changes, update the claim in the same change \u2014 the validation script will flag stale markers. When a gap closes, flip `classification` to `current_fact`, set `status` to `verified_in_code` or the appropriate deployment-verification status, and add the evidence that proves closure.\",\"other-isms-evidence\":\"# Other \u2014 isms-evidence\\n\\n# Module: isms-evidence\\n\\n## Overview\\n\\n`isms-evidence` is a non-executable governance module within the ISMS repository. It consists of a single policy document \u2014 `README.md` \u2014 that defines storage rules for evidence-related records. The module has no code, no runtime behaviour, and no call graph edges: it is a declarative policy artefact that constrains how other modules and human operators store and register evidence.\\n\\nThe module answers two questions:\\n\\n1. **What may be stored in this repository?** \u2014 document sources and evidence references that are not sensitive.\\n2. **What must be stored elsewhere?** \u2014 signed confidentiality agreements, identity information, secrets, customer evidence, and other sensitive operating records.\\n\\n## Purpose\\n\\nThe module exists to prevent the main evidence repository from becoming a liability. If sensitive records were stored alongside ordinary evidence references, a single repository compromise could expose confidential material. By drawing a hard boundary at the repository level and delegating sensitive storage to a restricted evidence repository, the module keeps the public-facing evidence store low-risk and auditable.\\n\\nThe module also establishes a registration requirement: any record stored in the restricted repository must be logged in `evidence-register.md` with its controlled link, owner, date, and retention rule. This preserves traceability without requiring the sensitive content itself to leave the restricted environment.\\n\\n## Storage Rules\\n\\nThe policy in `README.md` defines two categories of records.\\n\\n### Permitted in this repository\\n\\n- Document sources\\n- Evidence references (links, pointers, or citations to evidence held elsewhere)\\n\\nThese are non-sensitive by nature and safe to store in the main repository.\\n\\n### Prohibited in this repository\\n\\n- Signed confidentiality agreements\\n- Identity information\\n- Secrets\\n- Customer evidence\\n- Other sensitive operating records\\n\\nAny record falling into these categories **must not** be committed to `isms-evidence`. It must be placed in the restricted evidence repository selected by the Assureworx directors.\\n\\n## Evidence Registration Workflow\\n\\nWhen a sensitive record is stored in the restricted repository, the following registration steps apply:\\n\\n1. Store the record in the restricted evidence repository.\\n2. Record a controlled link to that record in [`evidence-register.md`](../evidence-register.md) (located at the repository root).\\n3. For each entry, capture four required fields:\\n   - **Link** \u2014 the controlled reference to the restricted record\\n   - **Owner** \u2014 the accountable party for the record\\n   - **Date** \u2014 when the record was created or registered\\n   - **Retention rule** \u2014 how long the record must be kept before disposal\\n\\nThis workflow ensures that evidence remains discoverable and accountable even when its content is isolated.\\n\\n## Module Relationships\\n\\nThe module references two related governance documents:\\n\\n| Related module | Relationship |\\n|---|---|\\n| `governance/document-control-and-approval-procedure.md` | Defines the document control and approval lifecycle that governs how this policy and other ISMS documents are created, reviewed, and modified. |\\n| `policies/data-classification-and-handling-policy.md` | Provides the classification framework that determines which records are sensitive and therefore subject to the restricted-repository rule. |\\n\\nThe `evidence-register.md` file at the repository root is a downstream consumer of this module's policy: it is the registration target for records stored under the rules defined here.\\n\\n```mermaid\\nflowchart LR\\n    A[Evidence Storage Rulesisms-evidence/README.md] --&gt;|permit| B[Non-sensitive evidencestored in this repository]\\n    A --&gt;|redirect| C[Sensitive recordsstored in restricted repository]\\n    B -.-&gt;|recorded in| D[evidence-register.md]\\n    C -.-&gt;|recorded in| D\\n    E[Document Control Procedure] --&gt;|governs| A\\n    F[Data Classification Policy] --&gt;|classifies| C\\n```\\n\\n## Metadata &amp; Maintenance\\n\\nThe module's `README.md` carries a YAML front-matter block with the following fields:\\n\\n| Field | Value | Purpose |\\n|---|---|---|\\n| `type` | `isms` | Identifies the document as an ISMS governance artefact. |\\n| `status` | `current` | Indicates the policy is in active use. |\\n| `updated` | `2026-07-28` | Date of the last substantive update. |\\n| `owner` | `Assureworx directors` | The accountable party for this policy. |\\n| `review` | `2026-08-28` | Scheduled review date; the policy must be revisited by this date. |\\n\\nMaintainers updating this module should revise the `updated` field on each change and confirm the `review` date remains in the future.\\n\\n## Execution Flows\\n\\nThere are none. The call graph reports no internal, outgoing, or incoming calls, and no execution flows are detected. This module is not invoked at runtime by any other component. Its effect is realised through human compliance with the storage rules and through the manual registration workflow described above.\\n\\n## Contribution Notes\\n\\nWhen contributing to this module:\\n\\n- Do **not** add sensitive content to this directory \u2014 if a file belongs in the restricted repository, store it there and register the link instead.\\n- Keep the policy concise; it is a boundary definition, not a procedure manual.\\n- When changing storage rules, update the `updated` and `review` fields, and flag the change for review by the Assureworx directors.\\n- Ensure any new record categories added to the prohibited list are consistent with the `data-classification-and-handling-policy.md` classifications.\",\"other-isms-exports\":\"# Other \u2014 isms-exports\\n\\n# Module: isms-exports\\n\\n## Overview\\n\\n`isms/exports` is the release-management module for the Assureworx ISMS documentation vault. It defines how shareable Word documents are produced from the Markdown source of record, and enforces the rule that a release artefact is never edited directly.\\n\\nThe module contains no executable code. It is a process definition (a README) that governs the relationship between two directories:\\n\\n- **Source of record** \u2014 the Markdown vault (the repository root, including `isms/...`).\\n- **Release output** \u2014 generated Word documents under `deliverables/Assureworx_ISMS/`.\\n\\nIn call-graph terms, the module has no runtime dependencies: no internal calls, no outgoing calls, and no incoming calls from other modules. Its integrations are procedural, not programmatic \u2014 it links to governance documents that define the approval workflow.\\n\\n## Purpose\\n\\nThe module exists to prevent a common documentation failure mode: a reviewer or editor edits a generated Word file, assumes the source has been updated, and the next regeneration silently overwrites their changes.\\n\\nThe stated policy is explicit:\\n\\n&gt; Do not manually edit a release copy and assume the source changed.\\n\\nAll accepted changes must flow through the Markdown source, then propagate to a regenerated release.\\n\\n## Key Components\\n\\n### `isms/exports/README.md`\\n\\nThe module's only file. It declares:\\n\\n- **`type: isms`** \u2014 frontmatter classification indicating this is an ISMS control/governance document.\\n- **`status: current`** \u2014 the document is actively maintained.\\n- **`owner: Assureworx directors`** \u2014 the accountable party for the process.\\n- **`review: 2027-07-28`** \u2014 the scheduled review date, one year after the `updated` timestamp.\\n\\n### `deliverables/Assureworx_ISMS/`\\n\\nThe target directory for generated Word releases. This directory is a build artefact location, not a source location. Its contents are disposable \u2014 they can be regenerated at any time from the current Markdown source.\\n\\n### Governance dependencies\\n\\nThe README references two documents that bound the release process:\\n\\n| Document | Role |\\n|---|---|\\n| `../governance/document-register.md` | The authoritative registry of documents; every regenerated release must be recorded there. |\\n| `../governance/document-control-and-approval-procedure.md` | The procedure governing review, approval, and versioning that the release process must follow. |\\n\\n## Release Process\\n\\nThe module prescribes a strict workflow for producing a shareable release. Each step is mandatory and they must be performed in order.\\n\\n```mermaid\\nflowchart LR\\n    A[Apply accepted review changes to Markdown source] --&gt; B[Update version]\\n    B --&gt; C[Regenerate release under deliverables/Assureworx_ISMS]\\n    C --&gt; D[Record release in document register]\\n    D --&gt; E[Share the Word release]\\n    E -.-&gt;|New review cycle| A\\n```\\n\\n### Step-by-step\\n\\n1. **Apply accepted review changes to the Markdown source.** Edits land in the vault files (e.g., under `isms/...`), never in a generated Word file.\\n2. **Update the version.** Bump the version metadata in the affected source document(s) so the change is traceable.\\n3. **Regenerate the release.** Produce the updated Word document under `deliverables/Assureworx_ISMS/`.\\n4. **Record the release in the document register.** Append the new release to `../governance/document-register.md` so there is an auditable trail of what was published, when, and at which version.\\n\\nThe loop closes when the released document attracts new review comments, at which point the cycle repeats from step 1.\\n\\n## Connections to the Rest of the Codebase\\n\\nThe module has no code-level dependencies \u2014 nothing imports, calls, or reads it at runtime. Its connections are architectural:\\n\\n- **Upstream:** The `isms/` vault content (the Markdown sources that feed the release generation).\\n- **Downstream:** `deliverables/Assureworx_ISMS/` (generated Word artefacts consumed by external stakeholders).\\n- **Governance:** `governance/document-register.md` and `governance/document-control-and-approval-procedure.md`, which define the controls this module's process implements.\\n\\nA contributor working in this module is not writing code but maintaining the process contract that keeps the vault and its deliverables in sync. Changes to this README should therefore be reviewed against the document control procedure, and the frontmatter (`updated`, `review`, `owner`) must be kept accurate.\\n\\n## Contributing Notes\\n\\n- Never create or edit files directly inside `deliverables/Assureworx_ISMS/` by hand. If a generated document is wrong, fix the source, not the output.\\n- When the release process itself changes (new output format, new target directory, new recording step), update this README and revisit the linked governance documents for consistency.\\n- The `updated` and `review` frontmatter fields are part of the module's contract \u2014 update `updated` whenever the README changes, and keep `review` set to the next scheduled review date.\",\"other-isms-governance\":\"# Other \u2014 isms-governance\\n\\n# isms-governance\\n\\n## Overview\\n\\n`isms/governance` is the governance layer of the Assureworx ISMS vault. It answers three questions on which every other ISMS document depends:\\n\\n1. What has the company decided, and who approved it?\\n2. What is the current version and status of each controlled document?\\n3. How do we stop policies and customer statements from claiming things the code and operations do not actually do?\\n\\nThe module is deliberately small. The governing assumption, set out in ISMS GOV 001, is that a new Australian company should run a proportionate management system rather than pay for ISO/IEC 27001 certification or a SOC 2 examination until a customer, tender, insurer, or investor makes it commercially necessary. The documents here establish control discipline without fabricating a large compliance apparatus. The shareholder brief is explicit about the current position \u2014 9 of 201 implementation tasks complete \u2014 so that secure design work is never confused with a finished management system.\\n\\nThe module contains no executable code and has no internal call graph or execution flow. Its interface to the rest of the repository is:\\n\\n- a stable document ID scheme referenced across the vault (`ISMS GOV xxx`, `ISMS POL xxx`, `ISMS TPL xxx`, `ISMS REG xxx`);\\n- the controlled document register, the lookup table for every ISMS document;\\n- the document control and approval procedure, which defines the version and status rules;\\n- the factual assurance procedure, which ties policy statements to code and dated evidence; and\\n- the `scripts/isms-truth-check.mjs` check, which validates cited files, implementation markers, and review expiry dates.\\n\\n## Module contents\\n\\n| File | Document ID | Type | Role |\\n|---|---|---|---|\\n| `shareholder-brief.md` | ISMS GOV 001 | isms | Strategic brief for shareholders and directors. Explains the proportionate approach, the current implementation position, and the commercial triggers for certification. |\\n| `approval-record.md` | ISMS GOV 002 | register | The signed instrument used to approve the brief, the first policy set, the ISMS coordinator role, and the review schedule in one pass. |\\n| `document-control-and-approval-procedure.md` | ISMS GOV 003 | control | Defines document identity, version rules, statuses, required metadata, the approval workflow, and change triggers. |\\n| `factual-assurance-procedure.md` | ISMS GOV 004 | control | Defines how factual security claims are classified and evidenced, and drives the automated truth check. |\\n| `document-register.md` | \u2014 | register | One-row-per-document index of ID, version, status, owner, approval state, and next review date for the whole ISMS, including policies, templates, and other registers. |\\n\\nEach file carries the same metadata in two places: YAML frontmatter (`type`, `status`, `updated`, `owner`, `prepared_by`, `review`) and the body header (Document ID, Version, Status, Prepared by). Keep both in sync when editing.\\n\\n## Document identity and numbering\\n\\nEvery controlled document carries a permanent ID from one of four series:\\n\\n- `GOV` \u2014 governance documents and procedures\\n- `POL` \u2014 policies\\n- `TPL` \u2014 templates and agreements\\n- `REG` \u2014 registers and acknowledgement records\\n\\nIDs never change. When a document is updated, the ID stays the same and the version changes. The document register is the source of truth for the current version and status of each ID.\\n\\nEvery controlled document must show:\\n\\n- document title and ID;\\n- version and status;\\n- owner, preparer, approver;\\n- approval date, effective date, and next review date;\\n- classification;\\n- revision history; and\\n- the location of its approval record.\\n\\n## Document life cycle and versioning\\n\\nVersions below `1.0` are never approved. The status and version rules from ISMS GOV 003:\\n\\n| Status | Version | Meaning |\\n|---|---|---|\\n| Working | 0.1, 0.2, ... | Still being prepared |\\n| For review | 0.1, 0.2, ... | Ready for review but not approved |\\n| Approved | 1.0 | Approved, dated, and in force |\\n| Approved minor update | 1.1, 1.2, ... | Meaning unchanged or risk is low |\\n| Approved major update | 2.0, ... | Material duties, scope, or controls changed |\\n| Superseded | Last approved version retained | Replaced but kept for history |\\n\\nThe single most important rule: **changing a version number does not approve a document.** Approval happens only through a signed approval record. The approval record itself additionally uses a \\\"For signature\\\" status, since it is the instrument that creates approvals rather than a document that gets approved.\\n\\n```mermaid\\nstateDiagram-v2\\n    [*] --&gt; Working[\\\"Working:\\\"] new document\\n    Working --&gt; ForReview[\\\"ForReview:\\\"] ready for review\\n    ForReview --&gt; Working[\\\"Working:\\\"] changes requested\\n    ForReview --&gt; Approved[\\\"Approved:\\\"] signed approval record (GOV 002)\\n    Approved --&gt; Superseded[\\\"Superseded:\\\"] replaced by newer version\\n    Approved --&gt; ForReview[\\\"ForReview:\\\"] change trigger (1.x minor / 2.0 major)\\n    Superseded --&gt; [*]\\n```\\n\\nA change to an approved document re-enters the review pipeline; it does not silently become `1.1`. GOV 003 lists the change triggers: product behaviour changes, new environments/suppliers/data types, public or contractual claim changes, incidents or control failures, legal or standard changes, audit findings, or a director request.\\n\\n## Review and approval workflow\\n\\nThe path to version `1.0`, defined in ISMS GOV 003:\\n\\n1. The preparer checks content against current code, deployed services, contracts, and public statements.\\n2. A second person reviews material technical claims where practicable.\\n3. The directors review policy, risk, scope, and resource decisions.\\n4. Required legal advice is obtained before legal templates are used \u2014 ISMS TPL 001 (the NDA) is explicitly gated on Australian legal review.\\n5. Directors record approval in a signed approval record (ISMS GOV 002).\\n6. The preparer sets version `1.0`, records the effective date, and generates a clean release copy.\\n7. The approved document is communicated to people in scope.\\n8. Acknowledgements and operating evidence are retained.\\n\\nISMS GOV 002 exists so that several documents \u2014 the brief, ISMS POL 001, ISMS POL 002, ISMS TPL 001, and the personnel templates \u2014 can be approved in a single signing pass. One approval record is routed to all directors. Confidentiality agreements remain separate because each has a different recipient and may have different terms.\\n\\nTwo authority rules are worth noting:\\n\\n- Only directors or people with a documented delegation may approve operational documents.\\n- Shareholder signatures on the approval record are acknowledgements only; they do not confer director authority or constitute company approval.\\n\\nSigned records and sensitive evidence are never stored in the public repository. They are kept in the approved restricted evidence repository, and only a reference is recorded in the evidence register.\\n\\n## Factual assurance\\n\\nISMS GOV 004 is the quality gate that keeps governance documents honest. It classifies every material security statement as one of four types:\\n\\n| Type | Meaning | Required support |\\n|---|---|---|\\n| Current fact | True in code or the deployed service now | Code path, configuration, or dated operational evidence |\\n| Policy requirement | A rule the company has approved | Approved policy and communication record |\\n| Planned change | Work the company intends to complete | Work item and owner \u2014 never described as current |\\n| Known gap | A control is missing or incomplete | Risk or plan reference with treatment |\\n\\nWhen sources disagree, the priority order is:\\n\\n1. verified deployed behaviour;\\n2. current code and infrastructure configuration;\\n3. approved policy and decision records;\\n4. product specifications and contracts;\\n5. working notes and drafts.\\n\\nKey properties:\\n\\n- Code proves how a feature is written, not that the same version is deployed, monitored, or used correctly. Operational claims require dated evidence.\\n- Any change affecting authentication, access, logging, customer data, suppliers, recovery, security claims, or compliance status must include an ISMS impact check covering policy impact, the data inventory, the Statement of Applicability, customer statements, and the evidence that will show the new control operates.\\n- The policy source and the code change should be reviewed in the same pull request where practical.\\n\\nMaterial claims are recorded in `isms/assurance/control-truth-register.json` and validated by the automated check:\\n\\n```text\\nnode scripts/isms-truth-check.mjs\\n```\\n\\nThe check confirms that cited source files exist, expected implementation markers remain present, and review dates have not expired. It does not replace human review of deployed behaviour or legal statements.\\n\\nA document may move from `0.x` to `1.0` only when the factual review is complete, open gaps are stated honestly, the directors have approved it, approval and effective dates are recorded, and the release copy matches the approved source.\\n\\n## The document register\\n\\n`document-register.md` is the index that ties the identity scheme together. One row per document records ID, version, status, owner, preparer, approval state, and next review date for the entire ISMS \u2014 not just the governance files. At present every governance document is at `0.x`, unapproved, pending review between August 2026 and July 2027.\\n\\nThe register also defines the release folder structure under `deliverables/Assureworx_ISMS`:\\n\\n| Folder | Purpose |\\n|---|---|\\n| `00_Shareholder_Review` | Brief, decisions, and approval record |\\n| `01_Policies` | Controlled policy release copies |\\n| `02_People` | Confidentiality and personnel records |\\n| `03_Plans` | Implementation plans and progress reports |\\n| `99_Superseded` | Retained release copies no longer current |\\n\\nThe Markdown file in the `isms` vault is the controlled source; Git is the version history. Word and PDF files are release copies for review, signature, and external sharing. Word files are never treated as a second version history, because binary changes are difficult to compare.\\n\\n## Integration with the wider ISMS\\n\\n- **Policies** \u2014 ISMS GOV 003 governs the approval of ISMS POL 001 (Information Security Policy) and ISMS POL 002 (Data Classification and Handling Policy). Approval is enacted through ISMS GOV 002.\\n- **Templates and registers** \u2014 the personnel onboarding/offboarding checklists (TPL 002/003) and the Policy Acknowledgement Register (REG 004) flow through the same lifecycle; TPL 001 requires legal review before approval.\\n- **Assurance** \u2014 GOV 004 consumes `isms/assurance/control-truth-register.json`; the truth-check script is a repository-level script at `scripts/isms-truth-check.mjs`.\\n- **Evidence** \u2014 signed approval records are stored outside the public repository; `isms/evidence/README.md` defines evidence handling and the evidence register.\\n- **Entry points** \u2014 the ISMS index (`../index.md`) and dashboard (`../Dashboard.md`) link into the governance documents; `wiki/architecture/security.md` is the implementation side of the factual assurance reviews.\\n\\n## Operational notes for contributors\\n\\n**Adding a new controlled document:**\\n\\n1. Assign the next free ID in the correct series (`GOV`, `POL`, `TPL`, `REG`).\\n2. Create the Markdown source with complete frontmatter and body metadata; status `Working`, version `0.1`.\\n3. Add a row to `document-register.md`.\\n4. Route it through the GOV 003 workflow, record approval in GOV 002, then bump to `1.0`.\\n\\n**Updating an approved document:**\\n\\n- Check the GOV 003 change triggers first.\\n- If a policy or customer statement changes, run the GOV 004 factual assurance steps and update `control-truth-register.json`.\\n- Bump the version \u2014 `1.x` for minor, `2.0` for major \u2014 and re-enter the review cycle. Never change a version without an accompanying review and approval step.\\n\\n**Running the automated check:**\\n\\n```text\\nnode scripts/isms-truth-check.mjs\\n```\\n\\nRun it before closing a review cycle. It will flag missing cited files, removed implementation markers, and expired review dates, but it will not catch factual drift in deployed behaviour \u2014 that remains a human review responsibility.\\n\\n## Related modules\\n\\n- [isms/policies](../policies/index.md) \u2014 the policy documents approved through this module\\n- [isms/templates](../templates/index.md) \u2014 personnel templates, NDA, and acknowledgement register\\n- [isms/assurance](../assurance/index.md) \u2014 truth register consumed by the factual assurance procedure\\n- [isms/evidence](../evidence/README.md) \u2014 restricted storage for signed records and audit evidence\\n- [ISMS dashboard](../Dashboard.md) \u2014 implementation tracker and current position\",\"other-isms-plans\":\"# Other \u2014 isms-plans\\n\\n# isms/plans \u2014 ISMS Workstream Plans\\n\\n## Overview\\n\\n`isms/plans/` is the operating instrument of the Assureworx compliance programme. It contains ten workstream plans that define every task between the current state and a defensible ISO 27001 certification plus SOC 2 Type II report, together with an index that aggregates progress and sequences the work.\\n\\nThe module is deliberately simple: each plan is a markdown file whose tasks are GitHub-style checkboxes. A task is either done or not done, and \\\"done\\\" has a strict definition \u2014 the evidence exists and is cited on the task line. Progress is derived mechanically from those checkboxes by `scripts/isms-progress.mjs`; nobody edits the progress figures by hand. This makes the module auditable in the same way the ISMS it builds is auditable: every claim is traceable to an artefact.\\n\\nThe plans are planning documents, not a code library. They work by referencing the rest of the repository \u2014 the Statement of Applicability, policy pack, evidence register, wiki decisions, runbooks, and the application source itself \u2014 and by being referenced back from those places as the source of truth for what remains to be done.\\n\\n## Module layout\\n\\n| File | Workstream | Scope |\\n| --- | --- | --- |\\n| `index.md` | \u2014 | Progress table, wave sequencing, working rules |\\n| `01-governance.md` | Governance and the management system | ISMS scope, roles, risk method, policy pack, review cadence |\\n| `02-access-control.md` | Identity and access control | Access policies, production/privileged access, application auth defects |\\n| `03-change-management.md` | Change management and secure development | Version control foundation, CI/CD pipeline, dependency/secret scanning, testing |\\n| `04-environments.md` | Environments and infrastructure as code | DEV/TEST/PROD provisioning, hardening, Bicep, test data rules |\\n| `05-logging-monitoring.md` | Logging, monitoring and alerting | Audit trail completion, infrastructure logging, alert rules, threat intelligence |\\n| `06-data-protection.md` | Data protection, privacy and retention | Classification, retention/deletion, privacy rights, encryption, breach readiness |\\n| `07-supplier-management.md` | Supplier and third-party management | Supplier register, Didit and Stripe assurance, cloud services policy |\\n| `08-resilience.md` | Incident response, backup and continuity | Incident response plan, backup/restore testing, RTO/RPO, redundancy |\\n| `09-people.md` | People, endpoints and remote working | Employment lifecycle, awareness training, endpoint baseline |\\n| `10-audit-evidence.md` | Audit readiness and evidence | Evidence capture, internal assurance, certification path |\\n\\nEach plan is independently reviewable but not independent: plans reference each other's task outcomes, and the index defines the order in which they should run.\\n\\n## Anatomy of a plan file\\n\\nEvery plan follows the same skeleton, which keeps the set scannable and lets the progress script parse it reliably.\\n\\n### Front matter\\n\\nAll plans carry YAML front matter:\\n\\n```yaml\\n---\\ntype: plan\\nstatus: current\\nupdated: 2026-08-04\\nowner: Assureworx directors\\nreview: 2026-08-26\\n---\\n```\\n\\n- `type` \u2014 `plan` for workstreams, `index` for the hub.\\n- `updated` \u2014 date of last content change.\\n- `review` \u2014 next scheduled review; the monthly review cadence is itself audit evidence.\\n\\n### Title and summary\\n\\nA one-line statement of what the workstream delivers, e.g. plan 04: \\\"Provision and separate DEV, TEST and PROD, and define the estate in code so its configuration can be evidenced rather than described.\\\"\\n\\n### Progress block\\n\\nAn auto-generated block delimited by `` and ``:\\n\\n```markdown\\n\\n**Progress: 33%** | 7 of 21 tasks complete\\n\\n```\\n\\nThis is machine-written by `scripts/isms-progress.mjs` and must not be edited by hand.\\n\\n### Why this matters\\n\\nA short argument for the workstream: what the auditors actually test, which controls are at stake, why the work can't be deferred. These sections carry the institutional context that task lines cannot \u2014 e.g. plan 02's observation that the application layer already has strong tenant isolation while the surrounding access model is absent, and plan 03's warning that un-gated commits are \\\"not a samplable control population\\\".\\n\\n### Tasks\\n\\nCheckbox lists grouped into `###` subsections. Each task line may carry one or more annotations, described below.\\n\\n### Exit criteria\\n\\nThe testable definition of done for the workstream as a whole, e.g. plan 08: \\\"An incident response plan with named roles and a completed tabletop; a restore tested at least twice with recorded timings against stated RTO and RPO.\\\"\\n\\n### Related\\n\\nLinks to the Statement of Applicability, evidence register, dependent plans, and relevant wiki pages.\\n\\n## Task conventions\\n\\nTask lines are the smallest unit of work. They are strictly binary: `- [ ]` (open) or `- [x]` (complete). A completed task is not a claim of intent \u2014 it is a pointer to evidence.\\n\\n### Checkbox states\\n\\nThe index defines the rule precisely:\\n\\n&gt; A task is ticked only when its evidence exists and is cited on the task line. \\\"Nearly done\\\" stays unticked. Split it into two tasks instead.\\n\\nSo plan 02 contains:\\n\\n```markdown\\n- [x] Implement account lockout, or amend the security documentation to describe what the code actually does | control: A.8.5 | evidence: [wiki/architecture/security.md](../../wiki/architecture/security.md) rate-limit only (R14); lockout not claimed\\n```\\n\\nThe task is ticked because the evidence line shows the documentation now matches the code, not because a lockout was implemented. That is the key semantic: the checkbox asserts a *reconciled state*, and the evidence citation is what makes it auditable.\\n\\n### Annotations\\n\\nTask lines use pipe-separated key/value annotations. The recognised keys are:\\n\\n| Annotation | Meaning | Example |\\n| --- | --- | --- |\\n| `control:` | The ISO 27001 Annex A control, SOC 2 criterion, or ISO clause the task satisfies | `A.8.2`, `CC6.3`, `ISO clause 9.3` |\\n| `evidence:` | Artefact proving completion \u2014 only present on ticked tasks | `backend/src/shared/config.ts`, `az group show -n rg-assureworx-test-aue` |\\n| `review copy:` | Artefact awaiting approval, not yet effective | `../policies/information-security-policy.md` |\\n| `template:` | Template to use for the deliverable | `ISMS GOV 002` |\\n| `ref:` | Finding or backlog identifier the task closes | `S2-2`, `F10`, `F11` |\\n| `resolves:` | Open question from `wiki/decisions/open-questions.md` that the task settles | `Q11`, `Q26` |\\n| `note:` | Caveat or constraint, often saying what does *not* count as closure | \\\"not A.8.31 / PROD closure\\\" |\\n\\nA task may carry several annotations; the `control:` reference is what ties the task back to the Statement of Applicability and ultimately to the audit assertion.\\n\\n### Cross-plan references\\n\\nPlans explicitly reference other plans' work where it gates their own tasks. Plan 08's \\\"Move production Container Apps to multiple replicas\\\" task notes that it \\\"depends on the Redis work in plan 04\\\". Plan 05's retention task points at plan 06. The index's sequencing section generalises these edges.\\n\\n## Progress computation\\n\\n`scripts/isms-progress.mjs` scans every plan file for checkbox syntax, counts open and closed tasks, and rewrites two things:\\n\\n1. Each plan's `` block with its percentage and counts.\\n2. The aggregated table in `index.md`, including the overall figure.\\n\\nThe index table is explicit that this is mechanical:\\n\\n&gt; Progress below is computed from the task checkboxes in each plan by `scripts/isms-progress.mjs` and is never edited by hand.\\n\\nCurrently the module reports 34 of 218 tasks complete (16%). The script is the only writer of `` blocks; a plan whose progress block disagrees with its checkboxes is a sign that the script has not been run after the last edit.\\n\\n## Sequencing and dependencies\\n\\nThe index defines four waves. Two constraints drive the ordering, and they are worth restating because they explain why the plans are ordered the way they are:\\n\\n1. **Evidence needs calendar time.** SOC 2 Type II samples an observation window, so controls that produce recurring evidence \u2014 change management, logging, access reviews \u2014 must start early enough to accumulate samples. Plan 03 puts it bluntly: \\\"Every day it runs is a day of audit history accruing.\\\"\\n2. **Some work unblocks other work.** Environments cannot be hardened before they exist; change management cannot gate deployments to environments that are not there.\\n\\n```mermaid\\ngraph LR\\n    W1[\\\"Wave 103 Change management04 Environments01 Governance\\\"] --&gt;|unblocks and feeds| W2[\\\"Wave 202 Access control05 Logging07 Suppliers\\\"]\\n    W2 --&gt;|provides inputs| W3[\\\"Wave 306 Data protection08 Resilience09 People\\\"]\\n    W3 --&gt;|is evidenced by| W4[\\\"Wave 410 Audit readiness\\\"]\\n```\\n\\nThe dependencies are not one-directional in practice. Plan 07 (suppliers) sits in wave 2 but the index flags that the Didit questions \\\"may have long external lead times, so raise them now\\\". Wave labels indicate when a workstream *completes*, not when its first task starts.\\n\\n## Connections to the rest of the repository\\n\\nThe plans form the middle layer of the compliance architecture: framework requirements on one side, implementation evidence on the other.\\n\\n```mermaid\\ngraph LR\\n    P[\\\"isms/plans \u2014 workstream plans\\\"] --&gt; C[\\\"Framework controlsISO A.5-A.8, clauses 4-10SOC 2 CC / A1\\\"]\\n    P --&gt; E[\\\"Evidence artefactsSoA, scope, policies,evidence register, data inventory\\\"]\\n    P --&gt; W[\\\"WikiADRs, runbooks,security audits\\\"]\\n    P --&gt; S[\\\"Source code &amp; scriptsbackend, frontend,deployment scripts\\\"]\\n    S --&gt;|evidence citations| P\\n```\\n\\n### Upstream: framework controls\\n\\nEvery task's `control:` annotation binds it to the Statement of Applicability (`../soa.md`), the SOC 2 mapping (`../soc2-mapping.md`), and the gap analysis. The plans effectively operationalise the SoA: each applicable control appears as one or more tasks in the relevant workstream, and the exit criteria name the ISO clauses (4\u201310) the workstream satisfies.\\n\\n### Downstream: evidence artefacts\\n\\nA ticked task's `evidence:` citation points at the artefact that proves it. These citations land in several places:\\n\\n- **Repository documents** \u2014 `../scope.md`, `../risk-register.md`, `../data-inventory.md`, `../didit-posture.md`, `../stripe-posture.md`, the policy pack under `../policies/`, templates under `../templates/`, and the central `../evidence-register.md`.\\n- **Wiki decisions and runbooks** \u2014 e.g. `wiki/decisions/0027-data-retention-and-public-legal.md` (retention periods), `wiki/runbooks/azure-dev-deploy.md`, `wiki/architecture/azure-dev-inventory.md`.\\n- **Source code** \u2014 the most concrete form of evidence. Completed tasks cite files such as `backend/src/shared/config.ts` (the `ASSUREWORX_ENV` assertion and `assertDeployAuthPosture`), `backend/src/shared/auth/dev-auth-gate.ts`, `backend/src/shared/auth/otp-callback-auth.ts`, `backend/src/modules/retention/`, migration `030_retention_and_account_closure.sql`, and `frontend/src/app/(legal)/` for the published privacy policy.\\n- **Commands and infrastructure state** \u2014 e.g. `az group show -n rg-assureworx-test-aue`, `docker port assureworx-pg` output, or a Set-Cookie observation from Azure DEV.\\n\\nThis is a deliberate design: because the plan files cite implementation artefacts, the plans double as an auditor-facing trace from control requirement to code.\\n\\n## Contributing to a plan\\n\\nThe index's working rules define the contribution process:\\n\\n1. **New work becomes a task in an existing plan wherever possible.** Adding plans dilutes the progress signal; a new workstream is a last resort.\\n2. **Reference the control.** A task without a `control:` or `ref:` annotation cannot be traced to the SoA and will not survive an audit.\\n3. **Tick only when evidence exists and is cited.** If the evidence is \\\"almost there\\\", split the task rather than ticking it.\\n4. **After ticking anything, run the progress script and log the change:**\\n\\n   ```sh\\n   node scripts/isms-progress.mjs\\n   ```\\n\\n   This rewrites the plan's progress block and the index table. Then append an entry to `log.md` (per the index: \\\"After ticking anything, run `node scripts/isms-progress.mjs` and append to `log.md`\\\"). The log entry is what makes the progress visible as a historical record rather than an instantaneous snapshot.\\n\\n5. **Respect the evidence semantics.** Editing a checkbox without updating the evidence citation breaks the module's core invariant. A ticked task whose evidence line does not exist is worse than an unticked one.\\n\\n### Adding a task\\n\\nChoose the most relevant plan and subsection, add the checkbox with its annotations, and follow the format of existing tasks:\\n\\n```markdown\\n- [ ]  | control:  | evidence: \\n```\\n\\nFor a task that will not be evidenced until approval, use `| review copy:` instead of `| evidence:`.\\n\\n### Verifying a task\\n\\nA task is verifiable if a reader can follow the `evidence:` citation to the artefact and confirm it supports the claim. The completed tasks in plan 02 and plan 04 are good models: they cite specific files, functions, command outputs, and dates (e.g. \\\"2026-07-30; CIAM app reg redirect includes `https://dev.assureworx.com.au/auth/callback`\\\"). Vague evidence such as \\\"done in PR #42\\\" would not survive the standard the module sets for itself.\",\"other-isms-policies\":\"# Other \u2014 isms-policies\\n\\n# isms-policies\\n\\nModule path: `isms/policies/`\\n\\nCategory: Other \u2014 documentation-only module with no executable code\\n\\n## Overview\\n\\nThe `isms/policies` module is the Assureworx ISMS policy set. It holds the normative rules for how the company protects information, plus the register that tracks every policy the ISMS requires. The module is documentation-only: there is no runtime behaviour, no function calls, and no execution flow. Its equivalent of a call graph is a cross-reference graph \u2014 policies point to implementation plans, governance records, evidence repositories, and the architecture wiki.\\n\\nState of the module:\\n\\n- 21 policies, standards, and procedures tracked in the register\\n- 3 written, all \\\"For review\\\", none approved\\n- 18 \\\"Not started\\\"\\n- All documents owned by the Assureworx directors\\n- ISO/IEC 27001:2022 is the control framework; SOC 2 CC7.2 also appears for alert triage\\n\\nThe register's core rule: *a document for review is not an operating control.* Only an approved, communicated policy with retained acknowledgement evidence can be presented to auditors or customers as implemented.\\n\\n## Module layout\\n\\n| File | Role | Document ID | Version / Status |\\n| --- | --- | --- | --- |\\n| `index.md` | Policy register hub, control mapping, writing rules | \u2014 (type: `index`) | draft |\\n| `information-security-policy.md` | Top-level security policy | ISMS POL 001 | 0.2 / for review |\\n| `data-classification-and-handling-policy.md` | Classification, handling, transfer, disposal | ISMS POL 002 | 0.2 / for review |\\n| `alert-triage.md` | Alert response procedure for Azure Monitor (DEV starter) | \u2014 | 0.1 / for review |\\n\\n## Document model\\n\\n### Frontmatter schema\\n\\nEvery file carries YAML frontmatter with the same core fields:\\n\\n| Field | Required | Meaning |\\n| --- | --- | --- |\\n| `type` | yes | `policy` for an individual policy; `index` for the register |\\n| `status` | yes | Lifecycle state. All current files are `draft`; the register table displays the human-readable status (\\\"Not started\\\", \\\"For review\\\") |\\n| `updated` | yes | Last content update, ISO date |\\n| `owner` | yes | Accountable owner. Currently `Assureworx directors` for every document |\\n| `review` | yes | Next scheduled review date, ISO date |\\n| `prepared_by` | no | Author. Present on POL 001 and POL 002 |\\n\\n### Status lifecycle\\n\\nThe register tracks a three-state pipeline:\\n\\n```\\nNot started \u2192 For review (draft) \u2192 Approved\\n```\\n\\nThe `Approved` column in `index.md` is currently `Not approved` for every entry. Not-started entries carry placeholder versions (` to `) until content exists.\\n\\n### Versioning and document control\\n\\nNumbered policies carry a `**Document ID:**` and `**Version:**` in the body and keep a version history table. Both numbered policies use `ISMS GOV 002` (`../governance/approval-record.md`) as their approval record and specify an effective date of \\\"after approval\\\" plus a next review date twelve months out, or earlier after a material change or incident.\\n\\nThe alert triage procedure intentionally has no document ID or version table \u2014 it is a starter procedure, not a numbered policy.\\n\\n## Key components\\n\\n### `index.md` \u2014 Policy register\\n\\nThe register is the entry point. Its table maps every required policy to ISO/IEC 27001:2022 controls:\\n\\n- **A.5.x governance** \u2014 A.5.1 information security policy, A.5.10 acceptable use, A.5.12\u2013A.5.14 classification, A.5.15 access control, A.5.17 authentication, A.5.19 supplier security, A.5.23 cloud services, A.5.24 incident response, A.5.29 business continuity, A.5.33/A.5.34 retention and privacy\\n- **A.6.x / A.7.x people and physical** \u2014 A.6.7 remote working, A.7.7 clear desk and clear screen\\n- **A.8.x technology** \u2014 A.8.1 endpoint security, A.8.10 data deletion, A.8.13 backup, A.8.15/A.8.16 logging and monitoring, A.8.24 cryptography, A.8.25 secure development, A.8.28 secure coding, A.8.32 change management\\n- **SOC 2** \u2014 CC7.2 for alert triage\\n\\nThe register also encodes the writing rules for contributors and the approval/review policy: directors approve, review is annual (or sooner after an incident or material change), and approval/review records are evidence for A.5.1 and A.5.36 logged in `../evidence-register.md`.\\n\\n### `information-security-policy.md` \u2014 ISMS POL 001\\n\\nThe top-level policy establishing management direction and the rules from which supporting standards and procedures derive.\\n\\n- **Purpose and scope** \u2014 applies to personnel, the SaaS platform, all environments (DEV and future TEST/PROD), suppliers, and everything within the ISMS scope\\n- **Objectives** \u2014 eight objectives, including \\\"retain evidence that controls operate, not merely that they are documented\\\"\\n- **Principles** \u2014 least privilege and need to know; secure by design and by default; data minimisation; separation and accountability; evidence over assertion\\n- **Roles** \u2014 directors, ISMS coordinator (a working responsibility, explicitly not an executive title), system and control owners, all personnel\\n- **Risk management** \u2014 via the risk register; material residual risk requires written director acceptance with an expiry or review date\\n- **Core control requirements** \u2014 individual identities, MFA for privileged services, no shared privileged accounts, information handling, development and change, logging and monitoring, supplier and cloud, incident and continuity\\n- **Exceptions, compliance, review, document history**\\n\\nCross-references: `../scope.md`, `../soa.md`, `../risk-register.md`, `../governance/factual-assurance-procedure.md`.\\n\\nA developer working on this module should note \u00a77.3: the reviewed-PR gate is explicitly flagged as a known gap and \\\"must not be described to customers or auditors as implemented.\\\"\\n\\n### `data-classification-and-handling-policy.md` \u2014 ISMS POL 002\\n\\nDefines how information is classified, labelled, accessed, stored, transferred, shared, used, and disposed. Supports ISO controls A.5.12\u2013A.5.14, A.5.33, A.5.34 and the SOC 2 Confidentiality criteria.\\n\\nDeveloper-relevant content:\\n\\n- **Classification model** \u2014 four policy classes: `Restricted`, `Confidential`, `Internal`, `Public`. Default-up rule: when uncertain, treat as Confidential. Collections inherit the highest class of any item.\\n- **Operational labels** \u2014 five engineering labels used for schema, API, and code work: `GP`, `CIC`, `C`, `PII`, `SPII`, mapped to policy classes. Full definitions live in the living catalogue at `../../wiki/architecture/data-classification-catalogue.md`; the combined auditor view is `../data-inventory.md`.\\n- **Sensitive information** \u2014 Privacy Act 1988 (Cth) categories (health, biometric) are always Restricted. Personnel are prohibited from entering health details, passwords, recovery codes, or identity document images into support tickets, chat, source control, or telemetry.\\n- **Handling requirements** \u2014 an 8-activity \u00d7 4-class matrix: access, storage, email/messaging, external sharing, non-production use, printing, portable media, disposal.\\n- **Labelling** \u2014 documents carry classification in header/footer where practical; system records may use metadata and access policy instead of a visible label when reliably enforced.\\n- **Transfer checks** \u2014 a 6-step pre-send checklist for Confidential/Restricted information (verify recipient, use approved service, minimise, verify destination, apply expiry, retain evidence).\\n- **Retention and disposal** \u2014 explicitly blocked on the undecided retention schedule (see Known gaps).\\n- **Security events** \u2014 loss, misdirection, excessive access, and unexpected sensitive information are reportable events; personnel must not self-investigate or delete records.\\n\\n### `alert-triage.md` \u2014 Alert triage procedure\\n\\nA DEV-stage operational procedure for Azure Monitor alerts. This is the only document in the module that names concrete infrastructure resources:\\n\\n- **Channel** \u2014 action group `ag-aw-dev-ops` in `rg-assureworx-dev-aue`; primary inbox `tech@assureworx.com.au`\\n- **Response targets** \u2014 Sev 2 (cost/abuse or instability): acknowledge within 1 business day, disposition same day or next; Sev 1 (future PROD: customer-facing outage/security): 30 minutes, documented same day\\n- **Live alert rules** \u2014 three rules, all DEV:\\n  - `alert-aw-api-replicas-dev-aue` \u2014 API avg Replicas &gt; 2 for 5m (cost/abuse)\\n  - `alert-aw-web-replicas-dev-aue` \u2014 Web avg Replicas &gt; 2 for 5m (cost/abuse)\\n  - `alert-aw-api-restarts-dev-aue` \u2014 RestartCount &gt; 3 in 15m (crash loop)\\n- **Disposition log** \u2014 until a ticket tool exists, record each firing in `isms/evidence-register.md` under A.8.16 with date, rule name, cause, action taken, closed-by\\n\\n## Integration with the rest of the codebase\\n\\nThe module is a documentation hub. Policies reference implementation plans for anything not yet operating, governance records for approval evidence, and the wiki for technical detail. The \\\"reference, do not restate\\\" rule keeps policies stable while the implementation changes underneath them.\\n\\n```mermaid\\ngraph LR\\n    REG[Policy Registerindex.md] --&gt; POL1[Information Security PolicyPOL 001 \u00b7 A.5.1]\\n    REG --&gt; POL2[Data Classification &amp; HandlingPOL 002 \u00b7 A.5.12\u2013A.5.14]\\n    REG --&gt; AT[Alert Triage ProcedureA.8.16 \u00b7 CC7.2]\\n    POL1 --&gt; AP[Approval RecordGOV 002]\\n    POL2 --&gt; AP\\n    POL2 --&gt; DI[Data Inventory &amp;Classification Catalogue]\\n    AT --&gt; ER[Evidence RegisterA.8.16]\\n    POL1 --&gt; P03[Plan 03 \u00b7 Change Mgmt]\\n    POL2 --&gt; P06[Plan 06 \u00b7 Data Protection]\\n    AT --&gt; P05[Plan 05 \u00b7 Logging &amp; Monitoring]\\n```\\n\\n| Referenced module | Relationship |\\n| --- | --- |\\n| `../plans/01-governance.md` | Governance roadmap; parent of the policy framework |\\n| `../plans/03-change-management.md` | Change management; tracks the reviewed-PR/code-check blocker cited in POL 001 \u00a77.3 |\\n| `../plans/05-logging-monitoring.md` | Logging and monitoring roadmap; parent of the alert triage procedure |\\n| `../plans/06-data-protection.md` | Data protection roadmap; parent of POL 002 |\\n| `../plans/07-supplier-management.md` | Supplier management; tracks the unresolved Didit processing-location question |\\n| `../governance/approval-record.md` | ISMS GOV 002 \u2014 approval evidence for POL 001 and POL 002 |\\n| `../governance/factual-assurance-procedure.md` | Procedure for fact-checking policy claims against the actual system |\\n| `../governance/document-register.md` | Controlled document register |\\n| `../evidence-register.md` | Where alert dispositions (A.8.16) and policy acknowledgements are logged |\\n| `../scope.md`, `../soa.md`, `../risk-register.md` | ISMS scope, statement of applicability, risk register |\\n| `../data-inventory.md` | Auditor view of classification; holds the undecided retention schedule |\\n| `../didit-posture.md` | Didit identity-check posture; evidence and open questions for POL 002 \u00a74 |\\n| `../assurance/control-truth-register.json` | Machine-readable truth register for control claims |\\n| `../../wiki/architecture/data-classification-catalogue.md` | Living classification catalogue (`GP`, `CIC`, `C`, `PII`, `SPII`) |\\n| `../../wiki/brand.md` | Spelling and style rules (Australian English) |\\n\\n## Known gaps and tracked blockers\\n\\nThe policies are explicit about what is not yet true. This honesty is deliberate: the register warns that \\\"a policy describing an aspirational control is a finding waiting to happen, and it is worse than admitting the gap.\\\" The cited anti-pattern is account lockout and signed document URLs \u2014 both documented in product docs, neither implemented.\\n\\n| Gap | Where recorded | Blocker status |\\n| --- | --- | --- |\\n| Reviewed-PR + automated code-check gate not operating | POL 001 \u00a77.3 | Tracked in plan 03; must not be described as implemented |\\n| Data retention schedule undecided | POL 002 \u00a79 | POL 002 cannot be treated as fully implemented until the schedule is approved and technically enforced |\\n| Didit processing location unresolved | POL 002 \u00a78 | Tracked in plan 07 |\\n| No ticket tool for alert dispositions | `alert-triage.md` | Dispositions recorded manually in evidence register A.8.16 |\\n\\n## Contribution checklist\\n\\nTo add or update a policy in this module:\\n\\n1. **Map it to a control first.** Add the entry to the register with its ISO 27001:2022 control reference (and SOC 2 criterion where relevant) before writing content.\\n2. **Write the frontmatter.** `type: policy`, `status: draft`, `updated`, `owner`, `review` date, and `prepared_by` for authored documents. Numbered policies additionally get a Document ID, version table, and approval-record link.\\n3. **State what is actually done.** If the control is not operating, say so and link the plan that will make it operate. Do not write aspirational controls.\\n4. **Keep it short and specific.** The register's rule: two pages that people follow are worth more than fifteen pages nobody reads.\\n5. **Reference, do not restate.** Technical detail belongs in `wiki/architecture/`; the policy links to it so it does not go stale.\\n6. **Record decisions and evidence.** Approvals go through the approval record (GOV 002 pattern); operational dispositions go to the evidence register; factual claims are validated via the factual assurance procedure.\\n7. **Get director approval.** Until then, the document is \\\"for review\\\", not an operating control \u2014 and must not be presented as one.\\n8. **Use Australian English** and the spelling rules from `wiki/brand.md`.\\n\\nThe module has no code, but its quality bar is high: it is read by auditors (who reject vagueness), staff (who ignore length), and customers (who spot claims the product does not honour).\",\"other-isms-templates\":\"# Other \u2014 isms-templates\\n\\n# isms-templates Module Documentation\\n\\n## Overview\\n\\nThe `isms-templates` module provides the reusable personnel-control records for Assureworx's Information Security Management System (ISMS). It sits under `isms/templates/` and contains the people-lifecycle forms referenced by **Plan 09: People** and the wider policy set.\\n\\nThe module is a static Markdown collection \u2014 there is no executable code, no call graph, and no runtime behaviour. Its functional contract is documentary: each template dictates what must be recorded, when, by whom, and where the completed record must be stored.\\n\\nA core design rule is stated in the module index:\\n\\n&gt; A blank template is not control evidence; the completed, dated and retained record is.\\n\\nTemplates are therefore intentionally structured as fill-in forms, not prose. Their value is realised only when a named person completes them and the result is stored in the approved evidence repository.\\n\\n## Module Layout\\n\\n| File | Template ID | Type | Purpose |\\n| --- | --- | --- | --- |\\n| `index.md` | \u2014 | index | Registry of all templates, with the evidence rule and related-links |\\n| `confidentiality-and-non-disclosure-agreement.md` | ISMS TPL 001 | control | NDA for employees, contractors and advisers receiving Assureworx information |\\n| `personnel-onboarding-checklist.md` | ISMS TPL 002 | control | Pre-access checks, first-day and first-week steps for new people |\\n| `personnel-offboarding-checklist.md` | ISMS TPL 003 | control | Access revocation, asset recovery and exit verification |\\n| `policy-acknowledgement.md` | ISMS TPL 004 | control | Individual signed acknowledgement of policy receipt and understanding |\\n| `policy-acknowledgement-register.md` | ISMS REG 004 | register | One multi-signer record covering the full team against the policy set |\\n\\nTemplate IDs follow a fixed scheme: `ISMS TPL nnn` for individual controls, `ISMS REG nnn` for registers.\\n\\n## Front-Matter Conventions\\n\\nEvery file opens with YAML front matter carrying the metadata that drives review and ownership:\\n\\n```yaml\\n---\\ntype: control            # control | register | index\\nstatus: draft\\nupdated: 2026-07-28\\nowner: Assureworx directors\\nprepared_by: Farrel Alvis, Director and Lead Engineer\\nreview: 2026-08-28\\n---\\n```\\n\\n- `type` distinguishes control records (`control`), consolidated records (`register`) and the module index (`index`).\\n- `updated` records the last content revision; `review` is the next mandatory review deadline.\\n- `review` dates are set to **2026-08-28** for most controls but **2027-07-28** for the register, which is a longer-lived record.\\n- The NDA is the exception: its `status` is `Legal review required`, signalling that Australian counsel must approve it before Assureworx signs or issues it.\\n\\n## Personnel Lifecycle and Data Flow\\n\\nThe templates implement a single personnel lifecycle: screen and contract a person, grant minimal access, obtain policy acknowledgement, track it centrally, and revoke everything at exit. Each completed template ultimately becomes evidence in the Evidence Register.\\n\\n```mermaid\\nflowchart LR\\n    subgraph Onboarding\\n        A[Personnel Onboarding ChecklistISMS TPL 002]\\n        B[Confidentiality and NDAISMS TPL 001]\\n        C[Policy AcknowledgementISMS TPL 004]\\n    end\\n    subgraph Register\\n        D[Policy Acknowledgement RegisterISMS REG 004]\\n        E[Evidence Register]\\n    end\\n    subgraph Offboarding\\n        F[Personnel Offboarding ChecklistISMS TPL 003]\\n    end\\n\\n    A --&gt; B\\n    B --&gt; C\\n    C --&gt; D\\n    D --&gt; E\\n    A --&gt; E\\n    F --&gt; E\\n```\\n\\n- **Onboarding** (`personnel-onboarding-checklist.md`) is the entry point. It contains three timed phases \u2014 *Before access is granted*, *First day*, and *First week* \u2014 and is the only template that gates account creation.\\n- The **NDA** (`confidentiality-and-non-disclosure-agreement.md`) is signed before access under the *Before access is granted* phase of the onboarding checklist.\\n- **Policy acknowledgement** (`policy-acknowledgement.md`) is signed in the *First week* phase, after policies have been provided and explained.\\n- The **register** (`policy-acknowledgement-register.md`) consolidates individual acknowledgements into a single team-wide record, so auditors can verify coverage without inspecting every individual form.\\n- **Offboarding** (`personnel-offboarding-checklist.md`) runs in the reverse direction: revocation of everything the onboarding granted, plus physical asset recovery and post-exit verification.\\n\\n## Control Coverage\\n\\nEach checklist maps to ISO/IEC 27001:2022 Annex A controls. This mapping is declared in the template front matter under `Control coverage`.\\n\\n| Template | Annex A controls |\\n| --- | --- |\\n| Personnel Onboarding Checklist (TPL 002) | A.5.15\u2013A.5.18, A.6.1\u2013A.6.3, A.6.6, A.6.7, A.8.1 |\\n| Personnel Offboarding Checklist (TPL 003) | A.5.11, A.5.15\u2013A.5.18, A.6.5, A.6.6, A.7.14 |\\n| Policy Acknowledgement (TPL 004) | A.5.36, A.6.3 |\\n| NDA (TPL 001) | not declared \u2014 it is a legal instrument rather than a control-evidence checklist |\\n\\nThe covered areas correspond to: roles and responsibilities, access provisioning and revocation (A.5.15\u2013A.5.18), screening (A.6.1), terms and conditions of employment (A.6.2), information security awareness and training (A.6.3), and working from home / remote working (A.7.14).\\n\\n## Key Components\\n\\n### Confidentiality and Non-Disclosure Agreement (ISMS TPL 001)\\n\\nA full legal NDA governed by New South Wales law, drafted for Australian use. Notable provisions:\\n\\n- **Legal review gate**: the document explicitly states it is not legal advice and requires Australian counsel to confirm enforceability, employment-law interaction, execution method, duration, remedies and governing-law wording before use.\\n- **Defines Confidential Information** broadly: customer and personnel data, compliance evidence, source code, credentials, security architecture, vulnerabilities, and derived materials.\\n- **Recipient obligations** include least-privilege access, MFA use, no account sharing, immediate incident reporting, and return or secure deletion on request or termination.\\n- **Permitted and required disclosure** carve-outs, including lawful protected disclosures and Fair Work Act 2009 (Cth) pay-secrecy rights.\\n- **Duration**: obligations continue five years after the engagement ends, with indefinite continuation for trade secrets, credentials and personal information.\\n- **Remedies**: acknowledges that unauthorised use or disclosure may warrant injunctive relief.\\n\\n### Personnel Onboarding Checklist (ISMS TPL 002)\\n\\nA three-phase gating checklist that ties every access grant to a documented decision:\\n\\n- **Record** \u2014 person, role, manager, start date, employment type, access-profile approver, evidence location.\\n- **Before access is granted** \u2014 identity checks, signed agreement, NDA, least-privilege profile, device baseline (full disk encryption, screen lock, patching, endpoint protection).\\n- **First day** \u2014 individual identity (explicitly no shared accounts), MFA enrolment, password manager, policy briefing, incident-reporting channel demonstration.\\n- **First week** \u2014 training completion, phishing and credential-hygiene training, signed policy acknowledgement, access verification with the person and manager, asset inventory update.\\n- **Completion** \u2014 open actions and exceptions must be recorded with an owner and due date before sign-off.\\n\\n### Personnel Offboarding Checklist (ISMS TPL 003)\\n\\nThe mirror of onboarding, structured in five phases:\\n\\n- **Plan** \u2014 inventory of accounts, devices, repositories and operational responsibilities; transfer of ownership to a named person.\\n- **Revoke and recover** \u2014 disable identity, revoke sessions and tokens, remove GitHub/Azure/database/support access, rotate shared secrets, recover devices and records.\\n- **Information and confirmation** \u2014 deletion from personal and unauthorised locations, removal of forwarding/delegates/integrations, written reminder of continuing obligations.\\n- **Verification after exit** \u2014 independent verification of access removal within one business day, escalation of residual access, retention-schedule compliance.\\n- **Completion** \u2014 exceptions and retained access must be documented rather than silently ignored.\\n\\nElevated-risk and involuntary exits get coordinated immediate revocation with legal/HR involvement.\\n\\n### Policy Acknowledgement (ISMS TPL 004)\\n\\nA per-person signed record covering receipt, understanding and ongoing duties:\\n\\n- Confirms the person received the policy documents and had an opportunity to ask questions.\\n- Commits the person to protecting credentials, using MFA, using only approved channels, and reporting incidents through the designated channel.\\n- Records questions, exceptions or accommodations inline.\\n- Includes the explicit statement that acknowledging does not remove lawful workplace rights, protected-disclosure rights or pay-discussion rights.\\n\\n### Policy Acknowledgement Register (ISMS REG 004)\\n\\nA single table that collects signatures from the whole team against the policy set. The referenced documents mirror those in TPL 004 but include version and effective-date columns, so the register shows which revision of each policy was acknowledged.\\n\\nIts record-handling section requires storage in the restricted evidence repository and, where electronic signatures are used on different dates, retention of the completion certificate alongside the register.\\n\\n## Integration with the Codebase\\n\\nThe templates are inward-facing records within the ISMS document tree, linked from and to the following modules:\\n\\n- **`isms/plans/09-people.md`** \u2014 the personnel security plan is the parent plan; both onboarding and offboarding checklists and the register link to it.\\n- **`isms/policies/information-security-policy.md`** and **`isms/policies/data-classification-and-handling-policy.md`** \u2014 the policies that TPL 004 and the register reference; the NDA and onboarding checklist also require that these be provided and explained.\\n- **`isms/evidence-register.md`** \u2014 the destination for all completed templates. The templates repeatedly instruct the user to store completed records and system evidence in the approved evidence repository.\\n- **`isms/templates/index.md`** \u2014 the module entry point, which lists all templates and states the blank-template/evidence rule.\\n\\nThe `Related` sections at the foot of each template implement these links explicitly, so the document set is navigable in both directions \u2014 from a policy to its acknowledgement form, and from a plan to its checklists.\\n\\n## Usage Workflow\\n\\nTo bring a person through the lifecycle:\\n\\n1. Complete and approve the **Record** section of the onboarding checklist.\\n2. Obtain a signed, legally reviewed **NDA** and signed **employment/services agreement**.\\n3. Create the individual identity and grant only the documented least-privilege access.\\n4. Complete the **first-day** and **first-week** phases, ending with a signed **policy acknowledgement**.\\n5. Aggregate signatures into the **policy acknowledgement register** and store both in the evidence repository.\\n6. At exit, run the **offboarding checklist**, revoke everything, recover assets, and have an independent verifier confirm removal within one business day.\\n7. Store the completed offboarding checklist and system evidence in the evidence repository.\\n\\n## Maintenance and Contribution\\n\\n- **Versioning**: each template carries a `Version` field in its body (currently 0.1 or 0.2) and a `Document history` table where substantive changes must be logged with date, change description and preparer.\\n- **Review cadence**: the `review` front-matter field drives the next mandatory review. Most controls require review by **2026-08-28**; the register by **2027-07-28**.\\n- **New templates**: allocate the next `ISMS TPL nnn` or `ISMS REG nnn` identifier, declare the covered Annex A controls, and add the file to `index.md`.\\n- **The NDA is special**: any change to the NDA must be re-reviewed by Australian counsel before use. Do not edit it as a routine housekeeping change.\\n- **Status discipline**: templates are marked `draft` or `For review` in their body (`Status` field). Promote the status only after the owner and, where applicable, legal review approve the content.\",\"other-isms\":\"# Other \u2014 isms\\n\\n# ISMS Module (`isms/`)\\n\\n## Overview\\n\\nThe `isms/` directory is a standalone documentation wiki that implements the Assureworx Information Security Management System (ISMS). It is the evidence base and working document set for achieving **ISO/IEC 27001:2022** certification and passing a **SOC 2 Type II** examination.\\n\\nThe module is deliberately separate from the product wiki at `wiki/`. The two wikis cross-link but never duplicate:\\n\\n- `wiki/` describes *what the product is and how it is built*.\\n- `isms/` describes *how the company governs, secures, and evidences it*.\\n\\nWhen the ISMS needs a technical fact, it links to a page in `wiki/architecture/` rather than restating it. When the product wiki needs a control decision, it links into `isms/`.\\n\\nAn auditor reads the ISMS end-to-end and expects a fixed shape: scope, risk, Statement of Applicability, policies, evidence. A product wiki is organised by domain and changes with every feature. Mixing them degrades both, and keeping them apart means the ISMS can be handed to an assessor without exposing product roadmap material.\\n\\n## Module map\\n\\n```mermaid\\nflowchart TD\\n    subgraph ISMS[\\\"isms/\\\"]\\n        DASH[Index \u00b7 Dashboard \u00b7 Overview]\\n        REG[Registers and posture pages]\\n        PLANS[Workstream plans]\\n        GOV[Governance \u00b7 policies \u00b7 templates]\\n    end\\n    subgraph TOOL[\\\"Scripts\\\"]\\n        PROG[isms-progress.mjs]\\n        TRUTH[isms-truth-check.mjs]\\n    end\\n    subgraph SRC[\\\"Evidence sources\\\"]\\n        CODE[backend/ code and tests]\\n        WIKI[wiki/ product wiki]\\n        AZURE[Azure subscription]\\n    end\\n    DASH --&gt; REG\\n    DASH --&gt; PLANS\\n    GOV --&gt; REG\\n    PROG --&gt; PLANS\\n    TRUTH --&gt; CODE\\n    REG --&gt; CODE\\n    REG --&gt; WIKI\\n    REG --&gt; AZURE\\n```\\n\\nThe module is static Markdown plus one JSON claim register \u2014 there are no runtime execution flows. Its \\\"execution\\\" is the maintenance workflow: edit registers, tick tasks, run the two Node scripts, append to the log.\\n\\n## Directory structure\\n\\n```\\nisms/\\n  AGENTS.md              # schema and maintenance rules\\n  index.md               # catalog \u2014 every page, one-line summary\\n  log.md                 # append-only chronological log\\n  overview.md            # programme status and rolled-up progress\\n  scope.md               # ISMS scope statement (ISO 27001 cl. 4.3)\\n  soa.md                 # Statement of Applicability \u2014 all 93 Annex A controls\\n  gap-analysis.md        # current state vs required, by clause and theme\\n  soc2-mapping.md        # SOC 2 TSC \u2192 Annex A \u2192 evidence\\n  risk-register.md       # identified risks, treatment, owner\\n  environments.md        # DEV / TEST / PROD separation and current reality\\n  data-inventory.md      # data classification, PII, flows, residency\\n  didit-posture.md       # identity sub-processor: what is and is not stored\\n  doc-intel-posture.md   # OCR sub-processor posture\\n  stripe-posture.md      # payment sub-processor posture\\n  evidence-register.md   # control \u2192 artefact \u2192 where it lives \u2192 cadence\\n  Dashboard.md           # Obsidian progress dashboard\\n  README_OBSIDIAN.md     # vault setup and sharing instructions\\n  governance/            # document control, approval records, procedures\\n  assurance/             # claim register: control-truth-register.json\\n  policies/              # policy pack register with drafting status\\n  templates/             # reusable people records and signature forms\\n  plans/                 # 10 numbered workstreams, each with a task list\\n  evidence/              # boundary for sensitive operating evidence\\n  exports/               # release copy rules\\n  archive/               # superseded source rules\\n```\\n\\n## Design principles\\n\\n### 1. Evidence beats assertion\\n\\nThe module's golden rule: **never claim a control is implemented without a citable artefact.** A cite is a file path, a migration number, an Azure resource name, a test name, or a wiki page. \\\"Planned\\\" and \\\"implemented\\\" are different columns and must never blur.\\n\\nFor SOC 2 Type II the auditor tests operation over a window, so every implemented control needs a *recurring* artefact \u2014 a log, a ticket, a signed review \u2014 not a one-off statement. The cadence is recorded in `evidence-register.md`.\\n\\n### 2. Progress is computed, never hand-written\\n\\nEvery workstream in `plans/` carries a `## Tasks` section of Markdown checkboxes. The script `scripts/isms-progress.mjs`:\\n\\n1. Counts `- [x]` against `- [ ]` per plan.\\n2. Rewrites the block between `` and `` in each plan file.\\n3. Rolls totals up into `plans/index.md`, `overview.md`, and `Dashboard.md`.\\n\\nThose marker comments are a machine-writable contract \u2014 never edit inside them by hand, because the next run overwrites them. `--check` mode fails CI when blocks are stale.\\n\\nA task is only ticked when its evidence exists and is cited on the task line. \\\"Mostly done\\\" stays unticked; split it into two tasks instead.\\n\\n### 3. Truth is checked against code, not asserted\\n\\n`scripts/isms-truth-check.mjs` verifies material factual claims against anchors in the repository, driven by `assurance/control-truth-register.json`. The register is updated when a security claim, policy statement, public offer, or cited implementation changes. The log shows it growing from 9 claims (initial) to 21 claims with every anchor matched \u2014 each addition corresponds to a shipped control (Document Intelligence boundary, payment integrity rules, no-card-data guarantee).\\n\\nThe check exists because the module treats a *documented-but-unimplemented* control as a worse problem than an admitted gap. Two known cases are tracked explicitly: account lockout after five failures (only a rate limiter exists) and signed 15-minute document URLs (documents stream through the API instead). The rule is: either implement the control or correct the documentation \u2014 never leave the two disagreeing.\\n\\n### 4. Unknowns and contradictions are explicit\\n\\n- Unknowns use `&gt; **\u2753 Open:**` and are filed in the relevant plan as a task.\\n- Contradictions use `&gt; **\u26a0 Contradiction:**` and cite both sides.\\n\\nExample from `data-inventory.md`: the scope statement's unqualified Australia East residency commitment contradicts the fact that Stripe processes payments in the United States. The contradiction is flagged, linked to risk R23, and must be resolved before the first live charge.\\n\\n## Page conventions\\n\\nFrontmatter follows the product wiki contract plus two extra fields on controlled documents:\\n\\n```markdown\\n---\\ntype: isms | policy | plan | register | control | overview | index | log\\nstatus: current | draft | superseded\\nupdated: YYYY-MM-DD\\nowner: \\nprepared_by: \\nreview: YYYY-MM-DD        # next scheduled review date\\n---\\n```\\n\\nAdditional conventions:\\n\\n- Australian English; Assureworx brand rules from `wiki/brand.md` apply.\\n- `log.md` is append-only, never rewritten. Entries use prefixes (`establish`, `progress`, `finding`, `review`, `incident`, `control`, `build`, `lint`, `query`) so `grep \\\"^## \\\\[\\\" isms/log.md | tail -5` yields the recent timeline.\\n- Status honesty: SoA rows are `Partial` or `Not started` until the full requirement holds. A.8.31 stayed `Not started` until TEST was actually provisioned, then moved to `Partial` \u2014 PROD still does not exist.\\n\\n## Key components\\n\\n### Auditor-facing registers\\n\\n| Page | Role | Notable content |\\n| --- | --- | --- |\\n| `scope.md` | ISO 27001 cl. 4.3 scope statement | In/out of scope, legal context, residency commitment |\\n| `soa.md` | Statement of Applicability | All 93 Annex A controls with status and evidence; the cover document for an audit |\\n| `risk-register.md` | Risk treatment | Risks R01\u2013R29+, each with owner and treatment; severity escalations recorded in the log |\\n| `evidence-register.md` | Control \u2192 artefact mapping | Three cadence classes (below) |\\n| `data-inventory.md` | Data classification and flows | Rolled-up inventory, inbound/outbound flows, Privacy Act analysis |\\n| `environments.md` | Environment truth | Verified DEV/TEST/PROD state, naming convention, target model |\\n| `gap-analysis.md` | Current state vs required | Mandatory clauses 4\u201310, Annex A by theme, the three blockers |\\n| `soc2-mapping.md` | TSC \u2192 Annex A \u2192 evidence | SOC 2 Trust Services Criteria mapping |\\n\\n### Sub-processor posture pages\\n\\nThree pages document every supplier that touches customer data, in the form an auditor or privacy regulator will ask for. They share a shape: why this supplier matters, an explicit what-we-store table, an explicit what-we-do-not-store list, data flow, sub-processor register entry, cost/abuse controls, evidence artefacts, and a control mapping.\\n\\n| Page | Sub-processor | Risk position | Key facts |\\n| --- | --- | --- | --- |\\n| `didit-posture.md` | Didit (identity verification) | Highest \u2014 holds identity media on our behalf | Outcome-only persistence on `core.t_check` (C02); `result` JSONB cleared to `{}` once settled; single vendor-boundary file `backend/src/modules/identity/didit.ts`; data-minimisation enforced and tested by `didit-boundary.test.ts`; DPA, processing location, and attestation still open |\\n| `doc-intel-posture.md` | Azure AI Document Intelligence | Lower \u2014 inside the existing Azure trust boundary | `disableLocalAuth: true`, managed identity auth, `prebuilt-read` model pinned as a source constant, application-level spend caps (~US$3/month DEV ceiling), circuit breaker degrades to \\\"could not be read automatically\\\" |\\n| `stripe-posture.md` | Stripe (payments) | Medium \u2014 US processing is a known APP 8 disclosure | No card data ever (hosted Checkout only), webhook allowlist reduces payloads before storage, nothing live \u2014 `PAYMENTS_NOT_CONFIGURED` until a DPA and privacy-policy disclosure exist |\\n\\n### Workstream plans (`plans/`)\\n\\nTen numbered files, each a task list with computed progress:\\n\\n1. **01 Governance** \u2014 roles, risk method, policies, review cadence\\n2. **02 Access control** \u2014 identity, privileged access, reviews\\n3. **03 Change management** \u2014 source control, review, testing, release evidence\\n4. **04 Environments** \u2014 DEV/TEST/PROD separation\\n5. **05 Logging and monitoring** \u2014 audit records, alerts, response\\n6. **06 Data protection** \u2014 classification, retention, privacy, breach readiness\\n7. **07 Supplier management** \u2014 supplier register, Didit assurance\\n8. **08 Resilience** \u2014 incident response, backup, continuity\\n9. **09 People** \u2014 employment lifecycle, training, endpoints, remote work\\n10. **10 Audit evidence** \u2014 evidence capture, internal audit, certification readiness\\n\\nSequencing is driven by the three blockers identified in `gap-analysis.md`: no operable change management, environment separation that cannot be evidenced, and the undocumented Didit relationship. The external-dependency column matters: supplier response times, pen-test firms, and certification bodies have lead times that run in parallel with internal work, so they are contacted early.\\n\\n### Governance, policies, templates\\n\\n`governance/` contains the document control and approval procedure, factual assurance procedure, approval records, and the controlled document register. `policies/` is the policy pack register with drafting status. `templates/` holds the personnel NDA, onboarding/offboarding checklists, and policy acknowledgement records.\\n\\n## Evidence model\\n\\n`evidence-register.md` classifies every evidence artefact by cadence, because a Type II auditor samples the observation window and a missing periodic record is a finding regardless of how good the control is.\\n\\n| Cadence class | Evidence form | Example |\\n| --- | --- | --- |\\n| **Continuous** | Configuration plus a population of events | Pull requests with approvals; `core.t_event` audit rows; `extraction.cloud_read` rows per OCR read |\\n| **Periodic** | Dated records for every occurrence that should have happened | Quarterly access reviews, annual training completion, semi-annual restore tests |\\n| **Event-driven** | Records produced when the event occurs | Incident records, offboarding checklists, signed DPAs per supplier |\\n\\nThe register also records *what is not yet being captured*, even when the underlying control operates. For example, the payment reconciliation job runs every five minutes but its result is not yet written as a dated, countable artefact \u2014 marked honestly as **Not capturing** rather than claimed as evidence.\\n\\n## Tooling\\n\\n| Script | Purpose | Run when |\\n| --- | --- | --- |\\n| `scripts/isms-progress.mjs` | Recomputes all progress blocks and rolls totals into `plans/index.md`, `overview.md`, `Dashboard.md`; `--check` fails CI on stale blocks | Weekly; after any task tick change |\\n| `scripts/isms-truth-check.mjs` | Verifies material factual claims against code anchors via `assurance/control-truth-register.json` | On any security claim, policy statement, or cited implementation change |\\n| `scripts/build-isms-starter-docs.py` | Generates branded Word review copies under `deliverables/Assureworx_ISMS/` | Before director/shareholder distribution |\\n\\n## Operational workflow\\n\\n**Weekly.** Re-run the progress script; append a `## [YYYY-MM-DD] progress | ...` entry to `log.md` with what moved and what blocked.\\n\\n**On any control change.** Update three places, every time:\\n1. The control row in `soa.md`\\n2. The matching task in `plans/`\\n3. The artefact row in `evidence-register.md`\\n\\n**On any controlled document change.** Follow `governance/document-control-and-approval-procedure.md`. Markdown is the controlled source in Git; Word/PDF files are review or release copies. An approved document must have a signed approval record, effective date, version, and review date.\\n\\n**On any new risk.** Add to `risk-register.md` with owner and treatment; if treatment needs work, add tasks to the relevant plan.\\n\\n**Before an audit.** Run the progress script, resolve every `\u2753 Open`, confirm every evidence artefact has a sample from inside the observation window, and produce the SoA as the cover document.\\n\\n## Integration with the codebase\\n\\nThe module references three evidence sources, and the log shows a discipline of checking them directly rather than taking claims on trust:\\n\\n1. **Code and tests** (`backend/`, `.github/`) \u2014 for example `backend/src/modules/identity/didit.ts` as the only file that knows Didit exists, `didit-boundary.test.ts` as the operating control behind data-minimisation claims, `payments/inbox.ts`, `process-event.ts`, `ledger.ts`, `refunds.ts` for payment integrity, and `.github/workflows/ci.yml` for automated checks.\\n2. **Azure state** \u2014 verified by CLI/MCP enumeration: `di-aw-dev-aue` with `disableLocalAuth: true`, resource groups `rg-assureworx-dev-aue` and `rg-assureworx-test-aue`, Container App hostnames, alert rules. Where wiki claims disagreed with tenant state, the tenant and code won.\\n3. **The product wiki** (`wiki/`) \u2014 ADRs (`wiki/decisions/`), architecture pages, runbooks, the element-level data classification catalogue at `wiki/architecture/data-classification-catalogue.md`, and engagement reports such as `security-engagement-2026-08-04.md`.\\n\\nThe truth-check script closes the loop: a refactor that breaks a stated control fails the check rather than surprising an auditor. The ISMS is not a passive record \u2014 it is a set of claims continuously verified against the repository and the live Azure subscription.\\n\\n## Related\\n\\n- [Product wiki index](../wiki/index.md) \u2014 technical facts this module references\\n- `AGENTS.md` inside `isms/` \u2014 the full schema and maintenance rules\\n- `wiki/architecture/security.md` \u2014 security architecture and intent, linked as evidence from the SoA\",\"other-lloyd\":\"# Other \u2014 LLoyd\\n\\n# LLoyd \u2014 Invitation &amp; Onboarding Module\\n\\n## Overview\\n\\nLLoyd is the invitation-driven onboarding and verification cluster. A principal contractor (`tier1_admin`) manages a project, adds physical sites, and builds an onboarding request aimed at trade companies. That request eventually produces a tokenized invitation link. A recipient who opens the link is met by one of two acceptance surfaces:\\n\\n- **InvitePage** \u2014 the desktop onboarding surface. Collects personal details, persists the draft, and routes the invitee into the full onboarding wizard (`/onboarding/abn`).\\n- **VerifyInvitePage** \u2014 the mobile verification surface. Creates the client-side verification session and routes the invitee into the `/verify` journey (`/verify/details`).\\n\\nBoth surfaces consume the same backend contract (`/onboarding/invite/:token`) and branch on the same `OnboardingContext`: whether the invitation is still open, and whether the invitee already has a portable Assureworx profile. The project page originates the flow; the invite pages complete it.\\n\\n## Module layout\\n\\n| File | Component | Responsibility |\\n|---|---|---|\\n| `page 2.tsx` | `ProjectDetailPage` | Project workspace at `/projects/[id]`: add sites, create onboarding requests, render the site map board |\\n| `page 2 (1).tsx` | `ProjectDetailPage` | Byte-identical duplicate of `page 2.tsx` \u2014 likely an accidental copy; reconcile before editing |\\n| `page 3.tsx` | `InvitePage` | Token-based route (onboarding surface). Accept or connect an invitation, then continue to `/onboarding/abn` |\\n| `page 4.tsx` | `VerifyInvitePage` | Token-based route (verify surface). Accept or connect an invitation, then continue to `/verify/details` |\\n| `invite.module.css` | \u2014 | Styles for `InvitePage` |\\n| `requests.module.css` | \u2014 | Styles for the request-management interface that lists, checks, and sends invitations (companion UI to the invite pages) |\\n\\n## Invitation lifecycle\\n\\nBoth invite pages fetch the same resource \u2014 `GET /onboarding/invite/:token` \u2014 and receive an `OnboardingContext` that describes the invitation. The context includes the requesting organisation (`senderOrgName`), the root organisation on whose behalf the request is made (`rootOrgName`), the invitee's email (`targetEmail`), the invitation's `status`, and \u2014 when the invitee is already on the platform \u2014 an `existingProfile` with name, public reference, verification status, and a `credentialPending` flag.\\n\\nThe context drives a three-way branch:\\n\\n```mermaid\\nflowchart TD\\n    A[Invite link \u00b7 token] --&gt; B[GET /onboarding/invite/:token]\\n    B --&gt; C{Status open?}\\n    C --&gt;|no| D[Terminal: closed screen]\\n    C --&gt;|yes| E{Existing profile?}\\n    E --&gt;|no| F[POST /accept \u2192 Entra sign-up or push]\\n    F --&gt; G[App flow: /onboarding/abn \u00b7 /verify/details]\\n    E --&gt;|yes| H[POST /connect]\\n    H --&gt;|verified| I[Terminal: /profile?connected=1]\\n    H --&gt;|unverified| J[createSession \u00b7 Entra sign-in/up]\\n    J --&gt; G\\n```\\n\\nThere are two operations an invitee can perform:\\n\\n- **Accept** \u2014 `POST /onboarding/invite/:token/accept`. Creates a new party on the platform. The body carries `firstName`, `lastName`, and optionally `companyName` (for `direct_company` invitations) and `phone`. The response may signal that an Entra credential must be created; the pages detect this with `isRequireEntraSignIn(result)` and hand off to `startEntraSignUp` or `startEntraSignIn` with the invitee's email as the login hint.\\n- **Connect** \u2014 `POST /onboarding/invite/:token/connect`. Links an existing Assureworx party to the requesting organisation. No personal data is collected; the existing compliance profile is shared as-is.\\n\\nA `404` from the token lookup is mapped to a \\\"link is no longer valid\\\" message; a context whose `status` is neither `\\\"pending\\\"` nor `\\\"accepted\\\"` renders a closed/expired screen referencing the sender organisation.\\n\\n## ProjectDetailPage \u2014 `/projects/[id]`\\n\\nThe project workspace is read-only for non-admins: the `canWrite` gate (`me.active?.role === \\\"tier1_admin\\\"`) controls whether the **Create request** and **Add site** actions render at all.\\n\\n### Data loading\\n\\n- `useApi(\\\"/nodes/projects/${id}\\\")` \u2014 project header, dates, and site summaries. `refresh()` re-fetches after site creation.\\n- `useApi(\\\"/directory?pageSize=20\\\")` \u2014 the current network directory, used to populate the company-selection step of the request wizard.\\n\\nThe `places` memo maps `data.sites` to `MapPlace[]` for `SiteMapBoardLazy` (`mode=\\\"board\\\"`). Each pin derives its address from `geocodeQueryForSite(site)` falling back to `formatSiteAddress(site)` and finally `\\\"Location not recorded\\\"`. The pin detail shows `assigned`, `ready`, and (when non-zero) `blocked` counts, and deep-links to `/projects/${id}/sites/${site.nodeId}`.\\n\\n### Add site\\n\\nThe inline \\\"New site\\\" form (`CreateSiteBody`) collects name (required), street address, suburb, state (from the `AU_STATES` constant: NSW, VIC, QLD, WA, SA, TAS, ACT, NT), and postcode (numeric, max length 4). Submitting `POST /nodes/projects/${id}/sites` trims the name, shows a toast, closes the panel, and refetches the project. `ApiClientError` messages surface under the name field.\\n\\n### Create request wizard\\n\\nToggled by `requesting`, the wizard has four steps (`wizardSteps`): **Project context**, **Select companies**, **Set requirements**, **Review &amp; send**.\\n\\n1. **Project context** \u2014 read-only summary: project name, site focus (first site in `data.sites`), and an objective line.\\n2. **Select companies** \u2014 a checkbox list of directory rows filtered to `kind === \\\"trade_company\\\"`, showing name and email. Loading and empty states are handled (`networkLoading`, `availableCompanies.length === 0`).\\n3. **Set requirements** \u2014 three checkbox groups, all managed by the shared `toggleSelection(value, list, setList)` helper:\\n   - Company-level requirements (`companyRequirements`, defaulting to ABN verification and public liability insurance)\\n   - Trade-specific qualification bundles (`tradeRequirements`, defaulting to Working at heights)\\n   - Trade types (`tradeTypes`, defaulting to Electrical and Plumbing)\\n4. **Review &amp; send** \u2014 a text summary of the project, site, selected company names (resolved against the directory), and all requirement lists.\\n\\nThe Back / Continue / Send requests controls step through the wizard. Note that `submitRequest` is currently a stub: it shows a \\\"Request draft prepared\\\" toast and resets the wizard without persisting anything to the backend.\\n\\nWhen the project has no sites, an `EmptyState` invites the admin to add the first one; otherwise the `SiteMapBoardLazy` renders with the derived `places`.\\n\\n## InvitePage \u2014 onboarding surface\\n\\n`InvitePage` renders the desktop acceptance flow inside `WizardShell`. It resolves the same `OnboardingContext` as the verify surface, but the no-profile path is richer:\\n\\n- **Persistent draft** \u2014 `usePersistentDraft(\\\"awx.draft.onboarding.invite.${token}\\\", EMPTY_INVITE, isInviteDraftEmpty)` saves the in-progress form across page loads. `DraftStatus` shows saved/restored state and offers a discard action.\\n- **Name capture** \u2014 first and last name are required. For `kind === \\\"direct_company\\\"` invitations, a company-name field is also rendered with an ABN hint. A mobile number field is optional. The server-side `targetEmail` is displayed as the identity the invitee is signing in as.\\n- **Accept flow** \u2014 `accept` posts to `/onboarding/invite/:token/accept`, clears the draft, and either calls `startEntraSignUp({ loginHint: result.email, returnTo: \\\"/onboarding/abn\\\" })` or pushes directly to `/onboarding/abn`.\\n\\nThe existing-profile path (also used here) is the **portability path**: a one-click **Connect my profile** button posts to `/onboarding/invite/:token/connect` and redirects to `/profile?connected=1`. The page makes the value explicit: the invitee's verified status is shared with `rootOrgName` without re-entering details or re-uploading documents. The recognised profile is shown via a `\n` (`.identity`) with `PartyIdentity`, the Assureworx public reference (`.awxId`), and a `StatusChip`.\\n\\nA subtle detail in the load effect: the `direct_company` branch calls `setForm` with the current `companyName` value \u2014 effectively a no-op today, likely a placeholder for pre-filling the company from the context.\\n\\n## VerifyInvitePage \u2014 verification surface\\n\\n`VerifyInvitePage` is the tokenized entry into the mobile verification journey. Its file-level comment is the key architectural note:\\n\\n&gt; Accepting the invitation is what makes the rest of `/verify` real: it creates the party and the session that the ABN write and the Didit identity check are recorded against. The anonymous `/verify` landing cannot do this, which is why it never runs an identity check.\\n\\n### Accept flow\\n\\n`accept` posts to `/onboarding/invite/:token/accept` with first/last name, then:\\n\\n1. Calls `createSession(\\\"invited\\\", { fullName, email, inviterName })` \u2014 this **must happen before any redirect**. Signing up with Entra navigates the tab away and back, and `/verify/details` bounces anyone without a session; creating the session afterwards would strand Entra users on the landing page.\\n2. If `isRequireEntraSignIn(result)`, calls `startEntraSignUp({ loginHint: result.email, returnTo: \\\"/verify/details\\\" })`; otherwise pushes to `/verify/details`.\\n\\nThe session email is resolved from `context.targetEmail`, falling back to `result.email` when the context is unavailable.\\n\\n### Connect flow\\n\\n`connect(thenVerify: boolean)` distinguishes two sub-cases of an existing profile:\\n\\n- **`thenVerify === false`** \u2014 the profile is already `\\\"verified\\\"`. The page posts to `/onboarding/invite/:token/connect` and redirects to `/profile?connected=1`. No session or Entra interaction.\\n- **`thenVerify === true`** \u2014 the profile exists but is not yet verified. Connecting alone would share an unusable status, so the page links the parties and then carries the invitee into verification. It creates the session, then branches:\\n  - `existingProfile.credentialPending` \u2192 `startEntraSignUp({ loginHint, returnTo: \\\"/verify/details\\\" })`\\n  - otherwise \u2192 `startEntraSignIn({ loginHint, returnTo: \\\"/verify/details\\\" })`\\n\\nThe invited UI (`VerifyShell` with `invited`) presents a three-step summary \u2014 ABN check, licence/passport photo plus selfie, wallet pass \u2014 and a compact name form. The `404` and closed-status handling mirror `InvitePage`.\\n\\n## Entra handoff\\n\\nBoth surfaces route Entra-required outcomes through `@/lib/entra` helpers:\\n\\n- `isRequireEntraSignIn(result)` \u2014 inspects the accept/connect response for a pending credential requirement.\\n- `startEntraSignUp({ loginHint, returnTo })` / `startEntraSignIn({ loginHint, returnTo })` \u2014 navigate to Microsoft Entra with a return path.\\n\\nBehind the scenes the sign-up path calls into `startCredentialSetup`, which stashes a credential-setup draft (`stashCredentialSetupDraft`) and computes the post-sign-in destination through `safeReturnTo` / `safeReturnToOrNull`. The invite pages themselves only need to know the `loginHint` and the desired app route.\\n\\n## API surface\\n\\n| Endpoint | Used by | Purpose |\\n|---|---|---|\\n| `GET /nodes/projects/:id` | ProjectDetailPage | Project header, dates, site summaries |\\n| `POST /nodes/projects/:id/sites` | ProjectDetailPage | Create a site (`CreateSiteBody`) |\\n| `GET /directory?pageSize=20` | ProjectDetailPage | Network directory for company selection |\\n| `GET /onboarding/invite/:token` | InvitePage, VerifyInvitePage | Resolve invitation context |\\n| `POST /onboarding/invite/:token/accept` | InvitePage, VerifyInvitePage | Accept invitation; create party |\\n| `POST /onboarding/invite/:token/connect` | InvitePage, VerifyInvitePage | Connect existing party to requesting org |\\n\\nShared types come from `@assureworx/shared-types`: `ProjectDetail`, `CreateSiteBody`, `DirectoryRow`, and `OnboardingContext`.\\n\\n## Styling\\n\\nThe stylesheets are CSS Modules built on the global token system. `invite.module.css` is tightly scoped to `InvitePage`; `requests.module.css` styles the request-management table, share strip, and recognition banners that surround the invitation flow.\\n\\nKey patterns:\\n\\n- **Path cards** \u2014 `.pathCard` is a blue-tinted gradient card with an uppercase `.pathEyebrow` label (\\\"New profile path\\\" / \\\"Existing profile path\\\") and a `.pathList` of benefit bullets. The invite detail itself uses the neutral `.inviteCard` on `--color-canvas` with a `--shadow-card` elevation.\\n- **Checkmarked requirements** \u2014 `.whatList` items use an inline SVG check on a `--status-verified-bg` circle, rendering the \\\"what will happen\\\" list in the invitation card.\\n- **Portability affordances** \u2014 `.portable` uses the `--status-portable-*` tokens; `.recognised` / `.recognisedWarn` in the requests stylesheet preview an existing profile before an invite is sent, with `.awxId` in tabular numerals.\\n- **Forms** \u2014 `.nameRow` (auto-fit grid for first/last name) and `.form`/`.formGrid` for the site and invite forms. In requests, `.abnRow` pairs an ABN input with a trailing lookup button.\\n- **Requests table** \u2014 `.cellMain`/`.cellSub` for primary/subtitle cells, `.checkCol`/`.check`/`.checkTarget` for row selection (touch targets expand to 44px below 1024px), `.rowActions`, and a `.revoke` action in the error colour.\\n- **Post-send deliverable** \u2014 `.shareStrip` presents the generated invite link (`.shareUrl`, monospace, ellipsized) with copy actions \u2014 the invitation link is the output of a successful request.\\n- **Summary banner** \u2014 `.summaryBanner` with `.summaryMetrics` cards for the request overview.\\n\\n## Integration points\\n\\n- **`@/lib/api`** \u2014 `api.get` / `api.post` for all backend calls; `ApiClientError` provides user-facing error messages. Data fetches through `useApi` are instrumented via the perf store (`trackNetworkInstant` \u2192 `push` \u2192 `emit` / `isRecording`), so these pages contribute to network telemetry automatically.\\n- **`@/lib/entra`** \u2014 Entra sign-in/up orchestration and response detection.\\n- **`@/lib/verify-demo`** \u2014 `createSession` establishes the client-side verification session on the invited verify path; the session must be created before any Entra navigation.\\n- **`@/lib/use-persistent-draft`** \u2014 draft persistence for the onboarding accept form, keyed per token.\\n- **`@/lib/geo`** \u2014 geocoding and address formatting for the project site board.\\n- **`@/lib/session`** \u2014 `useSession` supplies the role used for the `tier1_admin` write gate.\\n- **UI kit** \u2014 `Button`, `Panel`, `InputField`, `SelectField`, `EmptyState`, `SkeletonRows`, `DraftStatus`, `PartyIdentity`, `StatusChip`/`verifiedChip`.\\n- **Domain shells** \u2014 `WizardShell` (onboarding), `VerifyShell` (verification), `SiteMapBoardLazy` (project map board).\\n\\n## Notes &amp; gotchas\\n\\n- **`page 2.tsx` and `page 2 (1).tsx` are identical.** Both export `ProjectDetailPage`; the duplicate should be reconciled to avoid divergent edits.\\n- **`submitRequest` is a stub.** The review step of the request wizard only toasts and resets \u2014 the request is never sent to a backend endpoint. The real \\\"send\\\" is expected to produce the shareable invite link shown in the requests UI (`.shareStrip` / `.shareUrl`).\\n- **Session ordering on the verify path is load-bearing.** `createSession` must run before the Entra redirect; moving it after would break every Entra user returning from sign-up.\\n- **Invitations are single-use in effect.** `404` means the link is no longer valid; any status other than `pending`/`accepted` renders the closed screen.\\n- **The connect/accept split preserves portability.** Invitees with an existing profile never re-enter details; unverified existing profiles connect first, then continue into verification rather than starting from scratch.\",\"other-loadtests-zap\":\"# Other \u2014 loadtests-zap\\n\\n# loadtests/zap \u2014 ZAP Baseline Security Scan\\n\\n## Overview\\n\\n`loadtests/zap` is a declarative security-scanning module for the web application published at `http://host.docker.internal:4000`. It contains a ZAP Automation Framework plan (`zap.yaml`) that runs a **baseline scan**: spider the target, then run passive scan rules only \u2014 no active attack payloads. The remaining files in the directory are artifacts captured from a single run of that plan.\\n\\n| File | Role |\\n|---|---|\\n| `zap.yaml` | The scan plan. This is the module's entry point. |\\n| `zap-baseline-stdout.txt` | Console output from the ZAP run. |\\n| `zap-baseline.html` | Human-readable HTML report (ZAP `traditional-html` template). |\\n| `zap-baseline.json` | Machine-readable JSON report (ZAP `traditional-json` template). |\\n\\nThe module contains no executable code and has no internal call graph \u2014 it is a configuration artifact consumed by the ZAP Automation Framework. Because it lives under `loadtests/`, it typically runs as part of the repository's QA/load-test pipeline (CI or a local validation script) and acts as a **security regression gate**: if the scan reports errors, the plan fails and the pipeline stops.\\n\\n## Execution Flow\\n\\nThe `zap.yaml` plan is executed by the ZAP Automation Framework (ZAP 2.17.0 in the captured run). The jobs run sequentially:\\n\\n```mermaid\\nflowchart LR\\n    YAML[zap.yaml] --&gt; AF[ZAP Automation Framework]\\n    AF --&gt; CFG[passiveScan-config]\\n    CFG --&gt; SP[spidercrawl root URL]\\n    SP --&gt; WAIT[passiveScan-waitdrain scan queue]\\n    WAIT --&gt; SUM[outputSummaryzap_out.json]\\n    WAIT --&gt; HTML[report: traditional-htmlzap-baseline.html]\\n    WAIT --&gt; JSON[report: traditional-jsonzap-baseline.json]\\n    SUM --&gt; EXIT[stdout + exit code]\\n```\\n\\nThe spider discovers URLs, passive scan rules run against every response, the `passiveScan-wait` job blocks until the scan queue drains, and the results are then emitted in three forms: a machine-readable summary, an HTML report, and a JSON report. The console output (captured in `zap-baseline-stdout.txt`) is produced by the Automation Framework itself.\\n\\n## Configuration Reference\\n\\n### Environment\\n\\n```yaml\\nenv:\\n  contexts:\\n  - excludePaths: []\\n    name: baseline\\n    urls:\\n    - http://host.docker.internal:4000\\n  parameters:\\n    failOnError: true\\n    progressToStdout: false\\n```\\n\\n- **Context `baseline`** defines the scan scope. Everything outside `http://host.docker.internal:4000` is treated as off-site; passive scan rules that flag off-site content (e.g. cross-domain inclusion) will report but not fail relevant checks.\\n- **`failOnError: true`** \u2014 if any job reports an error, the entire plan fails with a non-zero exit code. This is the gate that makes the scan CI-enforceable.\\n- **`progressToStdout: false`** \u2014 the framework does not stream per-job progress to stdout; only the final summary and warnings are printed.\\n\\n### Jobs\\n\\n| # | Job type | Key parameters | Purpose |\\n|---|----------|----------------|---------|\\n| 1 | `passiveScan-config` | `enableTags: false`, `maxAlertsPerRule: 10` | Configure the passive scanner before scanning begins. Caps alert instances per rule at 10 and disables alert tag generation. |\\n| 2 | `spider` | `maxDuration: 1`, `url: http://host.docker.internal:4000` | Crawl the target starting at the context root. `maxDuration` is in minutes; 1 minute is a tight budget for anything beyond the root and well-known files. |\\n| 3 | `passiveScan-wait` | `maxDuration: 0` | Block until all passive scan messages are processed. `0` means no time limit. |\\n| 4 | `outputSummary` | `format: Long`, `summaryFile: /home/zap/zap_out.json`, `rules: []` | Write a machine-readable summary of rule results. `format: Long` includes alert details per rule; empty `rules` means include all rules. |\\n| 5 | `report` | `template: traditional-html`, `reportDir: /zap/wrk/`, `reportFile: zap-baseline.html` | Generate the HTML report artifact. |\\n| 6 | `report` | `template: traditional-json`, `reportDir: /zap/wrk/`, `reportFile: zap-baseline.json` | Generate the JSON report artifact. |\\n\\nNotable design points:\\n\\n- **No `activeScan` job.** This is a deliberate baseline posture: passive rules only. The plan never sends attack payloads, so it is safe to run against any environment.\\n- The `report` jobs write to `/zap/wrk/`, which implies the plan is intended to run inside the ZAP container with that directory mounted as an output volume. The `outputSummary` file path (`/home/zap/zap_out.json`) is separate from the two report artifacts.\\n- `host.docker.internal` resolves to the Docker host from inside a container. The plan is therefore designed to scan an application running on the host (or a sibling container publishing port 4000) from a ZAP container.\\n\\n## Output Artifacts\\n\\n### `zap-baseline-stdout.txt`\\n\\nConsole output from the Automation Framework. Contains the final tally and any automation plan warnings:\\n\\n```\\nFAIL-NEW: 0   FAIL-INPROG: 0   WARN-NEW: 3   WARN-INPROG: 0   INFO: 0   IGNORE: 0   PASS: 64\\n```\\n\\n**Parsing gotcha:** this file is UTF-16LE encoded (each ASCII character is followed by a null byte, with a BOM at the start), which happens when the ZAP container's stdout is redirected. Any tooling that reads this file in CI must decode it as UTF-16, not UTF-8.\\n\\n### `zap-baseline.html`\\n\\nThe ZAP `traditional-html` report. Contains:\\n\\n- **Summary of Alerts** \u2014 counts by risk level (High/Medium/Low/Informational).\\n- **Insights** \u2014 aggregate statistics about the scanned site (see below).\\n- **Alerts** \u2014 the list of triggered rules with risk level and instance count.\\n- **Alert Detail** \u2014 per-alert descriptions, affected URLs, evidence, solution, CWE/WASC references, and plugin IDs.\\n\\n### `zap-baseline.json`\\n\\nThe ZAP `traditional-json` report. Top-level structure:\\n\\n- `@programName`, `@version`, `@generated` \u2014 ZAP version and report timestamp.\\n- `insights[]` \u2014 key/value statistics: `{level, reason, site, key, description, statistic}`.\\n- `site[]` \u2014 per-site scan data, including `@host`, `@port`, `@ssl`, and `alerts[]`.\\n- `alerts[]` \u2014 one object per triggered rule. Key fields for programmatic consumption: `pluginid`, `alertRef`, `riskcode`, `confidence`, `riskdesc`, `count`, `instances[]` (each with `uri`, `method`, `param`, `evidence`, `otherinfo`), `cweid`, `wascid`.\\n- `sequences[]` \u2014 empty in this run; used only when the plan defines automation sequences.\\n\\nThis file is the most stable input for CI assertions: check `riskcode`/`count` per `pluginid`, rather than parsing the HTML.\\n\\n### `zap_out.json`\\n\\nNot committed, but produced by the `outputSummary` job at `/home/zap/zap_out.json`. A Long-format summary suitable for gating decisions on individual rules.\\n\\n## Interpreting the Sample Run\\n\\nThe captured run scanned three URLs \u2014 `/`, `/robots.txt`, and `/sitemap.xml` \u2014 and every one returned **404 Not Found**. The plan's own warning states this explicitly:\\n\\n```\\nJob spider error accessing URL http://host.docker.internal:4000 status code returned: 404 expected 200\\n```\\n\\nThe 64 PASS results and the three alerts below therefore describe **the application's 404 error pages**, not the live application. Treat the sample artifacts as a demonstration of the scanning pipeline, not as a security assessment of the real app. Once the target serves 200 responses, the alert set may change substantially.\\n\\n### Alerts raised\\n\\n| Plugin ID | Alert | Risk | Instances | Cause |\\n|---|---|---|---|---|\\n| `10055` | CSP: Failure to Define Directive with No Fallback | Medium | 3 | `Content-Security-Policy: default-src 'none'` is sent, but `frame-ancestors` and `form-action` are missing. Neither directive falls back to `default-src`, so omitting them leaves those behaviors unrestricted. |\\n| `10063` | Permissions Policy Header Not Set | Low | 3 | No `Permissions-Policy` header is present on the responses. |\\n| `10049` | Storable and Cacheable Content | Informational | 3 | Responses carry no explicit cache lifetime; ZAP assumes the RFC 7234 liberal heuristic of 1 year. |\\n\\nRemediation guidance:\\n\\n- **`10055`** \u2014 extend the CSP header with explicit fallback-free directives, e.g. `frame-ancestors 'self'` and `form-action 'self'`.\\n- **`10063`** \u2014 add a `Permissions-Policy` header (e.g. `Permissions-Policy: camera=(), microphone=(), geolocation=()`).\\n- **`10049`** \u2014 for non-public or user-specific responses, send `Cache-Control: no-cache, no-store, must-revalidate, private` (plus `Pragma: no-cache` and `Expires: 0` for HTTP/1.0 compatibility).\\n\\n### Insights\\n\\nThe JSON report's insights for this run:\\n\\n| Key | Statistic | Meaning |\\n|---|---|---|\\n| `insight.code.4xx` | 100 | Every scanned response was a 4xx. |\\n| `insight.endpoint.ctype.text/html` | 100 | Every endpoint served `text/html`. |\\n| `insight.endpoint.method.GET` | 100 | Every endpoint was exercised via GET. |\\n| `insight.endpoint.total` | 2 | Distinct endpoint count. |\\n\\nNote the discrepancy between \\\"Total of 3 URLs\\\" in stdout and an endpoint count of 2 in the insights: the insight counting logic normalizes endpoints differently than the spider's URL list. Do not write CI assertions against the endpoint count; assert on alert plugin IDs and risk levels instead.\\n\\n## Operational Notes &amp; Troubleshooting\\n\\n- **The plan fails when the target is down or misconfigured.** `failOnError: true` means the spider's 404-on-root error marks the whole run as failed, even though the alert tally shows 0 failures. This is intentional: a scan that cannot reach the application is not a pass.\\n- **Spider `maxDuration: 1`** gives the crawler one minute. If the application grows, the spider may time out and discover fewer URLs, which weakens passive scan coverage. Raise it as the site grows.\\n- **`maxAlertsPerRule: 10`** caps reported instances per rule in the reports. Raising it increases report fidelity at the cost of report size.\\n- **Report generation timestamps** \u2014 the artifacts show ZAP 2.17.0 and a generated time. When upgrading the ZAP image, expect alert behavior to drift; pin the ZAP version in CI to keep the baseline stable.\\n\\n## Integration with the Rest of the Codebase\\n\\nThere are no inbound/outbound code calls \u2014 this module is not imported by any source file. Its connection to the codebase is operational:\\n\\n1. A CI or local runner (outside this module) starts the target application so it is reachable at `http://host.docker.internal:4000`.\\n2. The runner executes the ZAP container with this `zap.yaml` plan (Automation Framework `-autorun` mode), mounting a writable directory to `/zap/wrk/` to collect the HTML/JSON reports.\\n3. Downstream steps consume `zap-baseline.json` for programmatic assertions (e.g. \\\"no alerts with `riskcode &gt;= 2`\\\") and archive `zap-baseline.html` as a build artifact.\\n\\nTo modify the module:\\n\\n- **Change the scan target** \u2014 update `env.contexts[].urls` and the `spider` job's `url`.\\n- **Add scan scope** \u2014 add URLs to the context's `urls` list; add an `activeScan` job to move from baseline to full scanning (not recommended for load-test environments).\\n- **Tighten/loosen the gate** \u2014 toggle `failOnError`, or add a `rules` filter to the `outputSummary` job to fail on specific rule IDs only.\\n- **Add output formats** \u2014 add further `report` jobs with different `template` values (e.g. `traditional-markdown` or `sarif`).\",\"other-loadtests\":\"# Other \u2014 loadtests\\n\\n# Load Tests &amp; Security Probes (`loadtests/`)\\n\\nThe `loadtests/` directory contains the Assureworx API's performance and security verification tooling. It has two distinct purposes:\\n\\n1. **k6 load testing** via `user-journey.js` \u2014 simulates real user sessions against the API to establish baseline latency, sustained-load capacity, and stress ceilings.\\n2. **Security probing** via the `pentest-*.mjs` scripts \u2014 targeted checks for exposed Redis/Postgres services, JWT signing weaknesses, SQL injection paths, and route enumeration.\\n\\nThe directory also accumulates dated evidence artifacts (`RESULTS.md`, Azure metrics captures, fix-evidence reports) so load and security findings are auditable alongside the code that produced them.\\n\\n## Module Layout\\n\\n| File | Purpose |\\n| --- | --- |\\n| `user-journey.js` | k6 script. Single configurable user journey, run in three stages (baseline / load / stress). |\\n| `README.md` | Quick-start: prerequisites, run commands, journey description. |\\n| `RESULTS.md` | Dated results from the 2026-08-04 local run, including root-cause analysis and fixes landed. |\\n| `pentest-route-extract.mjs` | Walks backend route registrations to enumerate exposed endpoints. |\\n| `pentest-live-probes.mjs` | Reachability probes (`probe`, `rawRequest`, `tcpProbe`, `redisPing`) against a live target, with cookie handling. |\\n| `pentest-deep-probes.mjs` | Deeper boundary probes: `tryPg`, `redisCmd`, HS256 JWT forgery attempts. |\\n| `pentest-curl-proofs.mjs` | Produces `curl` commands as reproducible evidence for each finding. |\\n| `pentest-redis-sqlmap.mjs` | Attempts SQL injection through Redis-backed request paths. |\\n| `pentest-jwt-pg-chain.mjs` | Chains JWT forging into PostgreSQL access attempts. |\\n| `azure-aca-metrics.json`, `azure-pg-metrics.json`, `azure-health-burst.txt`, `azure-health-probe.txt` | Raw Azure Container Apps / Postgres metrics and health-probe captures. |\\n| `assurance-fix-evidence-20260806-191548.txt` | Evidence bundle: Defender for Storage config, unit-test TAP output, geocode persistence code references. |\\n\\n## The k6 User-Journey Harness (`user-journey.js`)\\n\\n`user-journey.js` is the primary load-testing entry point. It models an authenticated user's full request chain against the Assureworx API and asserts on every response.\\n\\n### Request Journey\\n\\nEach virtual user executes the following sequence. The functions `login`, `extractSessionCookie`, and `hit` implement the per-request work; `ensureSession` and `stageOptions` configure behavior per stage.\\n\\n```mermaid\\nflowchart LR\\n    A[\\\"POST /api/v1/auth/login{ email }\\\"] --&gt; B[\\\"GET /api/v1/auth/me\\\"]\\n    B --&gt; C[\\\"GET /api/v1/reporting/dashboard\\\"]\\n    C --&gt; D[\\\"GET /api/v1/nodes/projects\\\"]\\n    D --&gt; E[\\\"GET /api/v1/requests\\\"]\\n    E --&gt; F[\\\"GET /api/v1/notifications\\\"]\\n    F --&gt; G{\\\"baseline orAUTH_MODE=journey?\\\"}\\n    G --&gt;|yes| H[\\\"POST /api/v1/auth/logout\\\"]\\n    G --&gt;|no| I[\\\"iterate / finish\\\"]\\n```\\n\\nThe login step posts only an email address; this works because the API runs with `ALLOW_DEV_AUTH=1`, which enables scripted dev authentication against a seeded principal (default `alex@meridian.dev`).\\n\\n### Stages\\n\\nThe `STAGE` environment variable selects the scenario:\\n\\n| Stage | Profile | Auth mode | Purpose |\\n| --- | --- | --- | --- |\\n| `baseline` | 1 VU, 3 iterations | `journey` (full login \u2192 shell \u2192 logout) | Per-endpoint latency floor and correctness check. |\\n| `load` | Ramp to 100 VU over 30s, hold 2m | `session` | Sustained capacity validation; the results show 312 req/s with 0% errors. |\\n| `stress` | Ramp 100 \u2192 200 \u2192 400 \u2192 800 \u2192 1200 | `session` | Find the breaking point; aborts via `abortOnFail` when `http_req_failed` exceeds 25%. |\\n\\n### Auth Modes\\n\\n- **`journey`** \u2014 every VU logs in and logs out in each iteration. Used by baseline.\\n- **`session`** \u2014 each VU logs in once and reuses the session cookie across iterations. Used by load/stress.\\n\\nThe distinction matters because logout bumps `session_ver` and revokes every other VU sharing the same actor. With multiple VUs logged in as the same seeded principal, a logout in one VU would invalidate sessions in all others \u2014 so load and stress runs deliberately skip logout.\\n\\n### Configuration Reference\\n\\n| Env var | Required | Default | Notes |\\n| --- | --- | --- | --- |\\n| `STAGE` | yes | \u2014 | `baseline` \\\\| `load` \\\\| `stress` |\\n| `BASE_URL` | yes | \u2014 | e.g. `http://localhost:4000` or the Azure API URL |\\n| `ORIGIN` | no | \u2014 | Web origin for CORS-correct requests against Azure targets |\\n| `AUTH_MODE` | no | per stage | Overrides the stage's default auth mode |\\n\\n### Backend Prerequisites\\n\\nThe API being tested must run with:\\n\\n```env\\nALLOW_DEV_AUTH=1\\n```\\n\\nLocal runs beyond ~40 concurrent logins additionally require:\\n\\n```env\\nLOAD_TEST_RELAX_LIMITS=1\\nDB_POOL_MAX=80\\n```\\n\\n`LOAD_TEST_RELAX_LIMITS` is deliberately scoped: it only takes effect when `ALLOW_DEV_AUTH=1`, so it can never activate on Entra-only Azure tiers.\\n\\nOne operational warning from `README.md`: prefer a **non-watch** API process (`npx tsx --env-file=.env src/server.ts`) during stress runs. `tsx watch` restarts can fake connection failures and corrupt results.\\n\\n## Security Probe Scripts (`pentest-*.mjs`)\\n\\nThe pentest scripts are standalone Node scripts (not k6) that audit a deployed or local API for the kinds of issues that load testing alone would not surface. They share a common pattern: forge or reuse auth material, hit a target, and emit findings with reproduction evidence.\\n\\n| Script | Key helpers | What it probes |\\n| --- | --- | --- |\\n| `pentest-route-extract.mjs` | `walk` (recursive) | Enumerates the route table to discover unadvertised endpoints. |\\n| `pentest-live-probes.mjs` | `probe`, `rawRequest`, `tcpProbe`, `redisPing`, `parseCookies`, `cookieHeader` | Checks which services are actually reachable on the target (HTTP, raw TCP, Redis). |\\n| `pentest-deep-probes.mjs` | `tryPg`, `redisCmd`, `signHs256`, `b64url`, `finding`, `note` | Tests whether Postgres and Redis are exposed; attempts HS256 JWT forgery. |\\n| `pentest-curl-proofs.mjs` | `sign`, `req`, `log` | Reproduces each finding as a concrete `curl` command. |\\n| `pentest-redis-sqlmap.mjs` | `req`, `redis`, `sign` | Attempts SQL injection through Redis-backed request paths. |\\n| `pentest-jwt-pg-chain.mjs` | `sign`, `b64url` | Chains a forged JWT into PostgreSQL access attempts. |\\n\\nThe JWT helpers (`signHs256`, `b64url`, `sign`) are used to test for signature-algorithm confusion and weak-secret acceptance. The `finding`/`note` helpers standardize output so results can be compared across runs.\\n\\n## Evidence Artifacts\\n\\nThe directory keeps raw captures and dated reports so findings are reproducible after the fact:\\n\\n- **`RESULTS.md`** \u2014 full k6 results for baseline, load, and stress runs, per-endpoint latency tables, and root-cause analysis of the stress failure.\\n- **`azure-aca-metrics.json`** / **`azure-pg-metrics.json`** \u2014 Azure Monitor metric exports for the Container App and Postgres flexible server during the probe window.\\n- **`azure-health-burst.txt`** / **`azure-health-probe.txt`** \u2014 cold-start and warm-burst health-check measurements against DEV/TEST.\\n- **`assurance-fix-evidence-20260806-191548.txt`** \u2014 a dated evidence bundle containing:\\n  - Defender for Storage on-upload malware scanning configuration (enabled, 5000 GB/month cap),\\n  - TAP output for 42 passing unit tests across 11 suites (assurance sweep config, `deriveReadiness`, identity currency, retention config, SMS reliability guards, and others),\\n  - code-level evidence for site geocode persistence via `enqueueSiteGeocode`.\\n\\n## Findings &amp; Capacity Guidance\\n\\nThe 2026-08-04 runs established concrete numbers that should anchor future capacity decisions:\\n\\n| Scenario | Result |\\n| --- | --- |\\n| Baseline (1 VU) | 0% errors, p95 21 ms, journey wall ~66 ms |\\n| Load (100 VU, 2 min hold) | **312 req/s**, 53,256 requests, p95 204 ms, p99 270 ms, **0% errors** |\\n| Stress ceiling | Aborted at ~400 VU when `http_req_failed` exceeded 25% (26.2% error rate, all `connection refused`) |\\n| Azure cold start | 24\u201327 s first `/health` after scale-to-zero |\\n| Azure warm `/health` | ~20 ms median |\\n\\nTwo bottlenecks were identified:\\n\\n1. **`GET /api/v1/reporting/dashboard` is the first endpoint to degrade** under concurrency \u2014 p95 266 ms at load, ~577 ms for successful responses during stress.\\n2. **Postgres connection termination killed the Node process.** Postgres sent `57P01` (`terminating connection due to administrator command`) under pool pressure; the Node process had no `pg` error handler and crashed, producing the connection-refused storm.\\n\\nThe same-day fixes landed in the backend:\\n\\n- `backend/src/shared/db/pool.ts` \u2014 added `pool.on('error')`, checked-out client error listeners, and connect/query/statement timeouts.\\n- `backend/src/shared/telemetry.ts` \u2014 added `@azure/monitor-opentelemetry` so the API emits App Insights telemetry (previously uninstrumented); wired via Key Vault secretref on DEV/TEST.\\n\\nPractical ceilings from the run: **~200\u2013400 concurrent VUs** against local Postgres on a laptop before the API dies; **100 VUs sustained for 2 minutes is healthy**. On Azure, the dominant latency factor is the scale-to-zero cold start, not steady-state throughput.\\n\\n## Connection to the Codebase\\n\\n- `user-journey.js` exercises real API routes under `backend/src` \u2014 auth, reporting, nodes, requests, notifications \u2014 and depends on the backend's dev-auth path (`ALLOW_DEV_AUTH=1`) rather than mocking.\\n- The `pg` failure modes it exposed drove concrete changes to `backend/src/shared/db/pool.ts`.\\n- The lack of App Insights telemetry it highlighted drove `backend/src/shared/telemetry.ts` and deploy-script updates.\\n- The pentest scripts target the same surface as the API's auth/Redis/Postgres boundaries; `pentest-route-extract.mjs` reads the backend's own route registrations.\\n\\n## Running\\n\\n```bash\\n# Baseline \u2014 1 VU, full login \u2192 shell \u2192 logout\\nk6 run -e STAGE=baseline -e BASE_URL=http://localhost:4000 loadtests/user-journey.js\\n\\n# Load \u2014 ramp to 100 VU / 30s, hold 2m\\nk6 run -e STAGE=load -e BASE_URL=http://localhost:4000 loadtests/user-journey.js\\n\\n# Stress \u2014 ramp 100 \u2192 200 \u2192 400 \u2192 800 \u2192 1200 until abort thresholds\\nk6 run -e STAGE=stress -e BASE_URL=http://localhost:4000 loadtests/user-journey.js\\n```\\n\\nAgainst an Azure target with dev auth enabled:\\n\\n```bash\\nk6 run -e STAGE=baseline \\\\\\n  -e BASE_URL=https://api.beta.assureworx.com.au \\\\\\n  -e ORIGIN=https://beta.assureworx.com.au \\\\\\n  loadtests/user-journey.js\\n```\\n\\nRequires k6 (`winget install GrafanaLabs.k6`) and an API on `:4000` with `ALLOW_DEV_AUTH=1` and a seeded principal.\",\"other-package-json\":\"# Other \u2014 package.json\\n\\n# Root `package.json` \u2014 Assureworx Monorepo Manifest\\n\\n## Overview\\n\\nThe root `package.json` is the declarative configuration manifest for the Assureworx monorepo. It is not an executable module \u2014 it defines no runtime exports, imports, or internal call edges. Its role is entirely structural: it declares the workspace topology, standardizes the repository-wide command surface, enforces the Node.js runtime floor, and overrides two transitive dependencies to patched versions.\\n\\nEverything that runs in Assureworx \u2014 the frontend dev server, the backend API, shared package builds, and database migrations \u2014 is launched through the scripts defined here.\\n\\n## Repository Topology\\n\\nThe manifest declares three workspace groups via the `workspaces` field:\\n\\n| Workspace | Path | Role |\\n|---|---|---|\\n| `frontend` | `frontend/` | Frontend application |\\n| `backend` | `backend/` | Backend service |\\n| `packages/*` | `packages/...` | Shared, microservice-ready modules |\\n\\nThe `private: true` flag prevents the root package from being accidentally published to a registry \u2014 this is a coordination manifest, not a distributable package.\\n\\n```mermaid\\ngraph TD\\n    Root[\\\"root package.jsonassureworx@0.1.0\\\"] --&gt; FE[\\\"frontend/ workspace\\\"]\\n    Root --&gt; BE[\\\"backend/ workspace\\\"]\\n    Root --&gt; PKGS[\\\"packages/* workspaces\\\"]\\n    Root --&gt; MIG[\\\"scripts/migrate.mjs\\\"]\\n    Root --&gt; OV[\\\"overrides:sharp ^0.35.3,postcss ^8.5.18\\\"]\\n    FE --&gt; FECMD[\\\"dev, build, lint,typecheck, test\\\"]\\n    BE --&gt; BECMD[\\\"dev, build, lint,typecheck, test, test:db\\\"]\\n    PKGS --&gt; PKGCMD[\\\"dev, build, lint,typecheck, test\\\"]\\n```\\n\\n## Command Surface\\n\\nAll repository-level operations are funneled through the `scripts` block. Workspace-scoped scripts use the `-w` flag to target a specific workspace; aggregate scripts use `--workspaces --if-present`, which safely skips any workspace that does not define the corresponding script.\\n\\n| Script | Command | Scope | Purpose |\\n|---|---|---|---|\\n| `dev` | `npm run dev --workspaces --if-present` | All workspaces | Start all dev servers / watchers in parallel |\\n| `dev:frontend` | `npm run dev -w frontend` | `frontend` | Start only the frontend dev server |\\n| `dev:backend` | `npm run dev -w backend` | `backend` | Start only the backend dev server |\\n| `build` | `npm run build --workspaces --if-present` | All workspaces | Produce production builds for every workspace |\\n| `lint` | `npm run lint --workspaces --if-present` | All workspaces | Run lint across all workspaces |\\n| `typecheck` | `npm run typecheck --workspaces --if-present` | All workspaces | Run static type checks across all workspaces |\\n| `test` | `npm run test --workspaces --if-present` | All workspaces | Run unit/integration tests across all workspaces |\\n| `test:db` | `npm run test:db -w backend` | `backend` | Run database-specific tests (requires a running DB) |\\n| `migrate` | `node scripts/migrate.mjs` | Root | Apply database migrations |\\n| `migrate:status` | `node scripts/migrate.mjs --status` | Root | Show current migration status without applying |\\n\\nTwo patterns are worth noting:\\n\\n- **Per-workspace scripts are defined in each workspace's own `package.json`.** The root scripts only fan out to them. For example, `npm run dev` at the root resolves `dev` in `frontend`, `backend`, and every package under `packages/*` that defines it.\\n- **Migration scripts bypass the workspace mechanism entirely.** They invoke `scripts/migrate.mjs` directly with Node, meaning migrations are a repository-level concern rather than a backend-only one. `/ \u2014status` is passed as a CLI flag to toggle behavior.\\n\\n## Dependency Overrides\\n\\nThe `overrides` block forces npm to resolve two transitive dependencies to patched versions, regardless of the ranges declared by intermediate packages:\\n\\n- **`sharp ^0.35.3`** \u2014 Sharp is an image-processing library. The backend processes user-uploaded images with it at runtime, so an unpatched version is a direct security risk there. Next.js 15.5.22 also resolves sharp, but at a vulnerable range whose transitive `libvips` CVE is tracked as GHSA-f88m-g3jw-g9cj.\\n- **`postcss ^8.5.18`** \u2014 PostCSS is resolved by Next at build time only. The vulnerable range has an XSS issue tracked as GHSA-qx2v-qp2m-jg93. It is pinned to keep the tree clean even though the exposure is build-time.\\n\\nThe explicit `comment:overrides` field documents the rationale in-place: by pinning these two packages to single patched versions, the entire dependency tree converges on one safe copy of each. This matters for `sharp` in particular, because the backend handles untrusted image input at runtime. The comment also flags that these overrides should be revisited when Next.js widens its dependency ranges.\\n\\n## Runtime Requirements\\n\\nThe `engines` field requires **Node.js &gt;= 20**. This is enforced by npm during install and script execution. The choice matters in practice: the migration script (`scripts/migrate.mjs`) is an ES module, relying on the stable ESM support that Node 20 provides.\\n\\n## Integration Points\\n\\nThe root manifest connects to the rest of the codebase in four places:\\n\\n1. **Workspace discovery** \u2014 `workspaces` tells npm where to find the frontend, backend, and shared packages, enabling hoisted `node_modules` and cross-workspace dependency resolution.\\n2. **Script delegation** \u2014 every aggregate script (`dev`, `build`, `lint`, `typecheck`, `test`) is a thin fan-out to workspace-level scripts. Adding a new workspace automatically includes it in these flows as long as it defines the corresponding script.\\n3. **Migration tooling** \u2014 `migrate` and `migrate:status` are the public entry points for the migration runner at `scripts/migrate.mjs`.\\n4. **Security pinning** \u2014 `overrides` globally constrains the vulnerable transitive dependencies (`sharp`, `postcss`) shared between the Next.js frontend and the image-processing backend.\",\"other-packages-shared-types\":\"# Other \u2014 packages-shared-types\\n\\n# `@assureworx/shared-types`\\n\\n## Overview\\n\\n`@assureworx/shared-types` is a private, type-only package in the AssureWorx monorepo. It is the central home for TypeScript types and interfaces shared between packages. It contains no runtime logic and never executes \u2014 its entire contribution to the codebase happens at compile time.\\n\\nKey facts:\\n\\n- **Package name:** `@assureworx/shared-types`\\n- **Entry point:** `./src/index.ts` (exposed via `main`, `types`, and `exports`)\\n- **Module system:** ESM (`\\\"type\\\": \\\"module\\\"`)\\n- **Visibility:** `private: true` \u2014 never published to a registry\\n- **Runtime behavior:** none \u2014 the module is erased during compilation\\n\\n## Purpose\\n\\nPackages in a monorepo need to agree on the shapes of the data they exchange \u2014 API responses, domain entities, event payloads, configuration objects. Without a shared home for those shapes, each package either redefines them locally (leading to drift and type mismatches) or imports them from a package that carries runtime code (creating unnecessary coupling).\\n\\n`@assureworx/shared-types` avoids both problems by providing a dependency-light home for types only. Any package in the monorepo can import from it without pulling in runtime dependencies or risking circular runtime imports.\\n\\nBecause the package is private, it is not intended for external consumers \u2014 it exists solely to serve the monorepo.\\n\\n## Package layout\\n\\nThe package is minimal by design:\\n\\n```\\npackages/shared-types/\\n\u251c\u2500\u2500 package.json\\n\u251c\u2500\u2500 tsconfig.json\\n\u2514\u2500\u2500 src/\\n    \u2514\u2500\u2500 index.ts        # public type exports\\n```\\n\\n`src/index.ts` is the only entry point. The `exports` field in `package.json` exposes exactly one subpath (`.`), so consumers cannot deep-import internal modules:\\n\\n```json\\n\\\"exports\\\": {\\n  \\\".\\\": \\\"./src/index.ts\\\"\\n}\\n```\\n\\nBoth `main` and `types` point directly at the TypeScript source rather than a build artifact. This is a common monorepo pattern: consuming packages resolve and type-check the source directly, so no build step is required before the types are usable.\\n\\n## Build &amp; type checking\\n\\nThe package does not define a `build` script \u2014 its only verification step is type checking:\\n\\n```bash\\nnpm run typecheck\\n```\\n\\nwhich runs:\\n\\n```bash\\ntsc -p tsconfig.json --noEmit\\n```\\n\\nThe `tsconfig.json` is strict and modern:\\n\\n| Option | Value | Purpose |\\n|---|---|---|\\n| `target` | `ES2022` | Type-check against current ECMAScript semantics |\\n| `module` | `NodeNext` | ESM-style module resolution compatible with Node |\\n| `moduleResolution` | `NodeNext` | Resolves packages according to Node's ESM rules |\\n| `strict` | `true` | Enables all strict type-checking flags |\\n| `declaration` | `true` | Emits `.d.ts` files when a build is run |\\n| `outDir` / `rootDir` | `dist` / `src` | Build output layout, should a build ever be added |\\n\\nThe `--noEmit` flag means the compiler validates the types but produces no output. If a build step is added later, the `declaration`/`outDir`/`rootDir` settings are already in place to emit `.d.ts` files into `dist/`.\\n\\n## Usage\\n\\nOther packages in the monorepo import types from this package using `import type`, which guarantees the import is erased at compile time and contributes no runtime dependency:\\n\\n```ts\\nimport type { SomeSharedType } from '@assureworx/shared-types';\\n```\\n\\nwhere `SomeSharedType` represents any type exported from `src/index.ts`.\\n\\nBecause the package is ESM and uses `NodeNext` resolution, consumers must follow the same module resolution rules \u2014 the exact import syntax depends on the consuming package's own module configuration.\\n\\n## Runtime behavior and execution flow\\n\\nThis package has no runtime presence:\\n\\n- **Internal calls:** none \u2014 the source contains types only, with no functions to call\\n- **Outgoing calls:** none \u2014 the package imports nothing at runtime\\n- **Incoming calls:** none \u2014 no package imports it at runtime\\n- **Execution flows:** none \u2014 there is no code path to execute\\n\\nThe call graph for this module is empty by design. The module's only \\\"flow\\\" is a compile-time one: TypeScript pulls in the exported types, validates how they are used across the monorepo, and then erases them entirely from the emitted JavaScript.\\n\\n```mermaid\\ngraph LR\\n    A[src/index.tstype definitions] --&gt;|import type| B[Consumer packagesin monorepo]\\n    C[tsc --noEmittypecheck] --&gt;|validates| A\\n    B -.-&gt;|runtime: nothing| D[Emitted JStypes erased]\\n```\\n\\nThis diagram shows the two things the package participates in: compile-time type resolution and compile-time validation. At runtime, nothing crosses the package boundary.\\n\\n## Relationship to the rest of the codebase\\n\\n`@assureworx/shared-types` is a leaf package \u2014 it sits at the bottom of the dependency graph. Its only dependency is `typescript` as a devDependency, so any package in the monorepo can import from it without pulling in additional transitive dependencies.\\n\\nThe empty call graph confirms it is not currently wired into any runtime execution path. Its role is purely contractual: it defines shapes that other packages rely on at compile time. As the monorepo evolves, the typical workflow is:\\n\\n1. Define or update a shared type in `src/index.ts`.\\n2. Run `npm run typecheck` (or the repo-wide type check) to verify the change.\\n3. Import the type in consuming packages with `import type`.\\n\\nBecause the package exposes only its root entry, new shared types must be exported from `src/index.ts` \u2014 subpath exports are intentionally not enabled.\",\"other-product-md\":\"# Other \u2014 PRODUCT.md\\n\\n# Assureworx Product Specification (PRODUCT.md)\\n\\n## Overview\\n\\n`PRODUCT.md` is the product definition module for Assureworx, an operational CRM for Australian trade compliance. It contains no executable logic \u2014 the call graph reports no internal, outgoing, or incoming calls, and no execution flows are detected. Its role in the codebase is **constraining rather than computational**: it is the source of truth for what the application must be, who it serves, how it must behave, and what it must look like.\\n\\nThe product is a single Next.js web application serving two user classes through one shared app shell. It is explicitly positioned as an operational tool \u2014 live compliance status, network navigation, and evidence-led workflows \u2014 not a marketing site.\\n\\n## Product Model\\n\\nAssureworx runs as one Next.js app on the web. Both user classes share the same navigation chrome (sidebar, account menu in the top-right); role and membership determine which routes and data are visible.\\n\\n| Aspect | Principal Contractor | Trade |\\n|---|---|---|\\n| Surface | Desktop-first compliance CRM | Mobile-capable profile within the same app |\\n| Core need | Control across trade network, projects, and sites | Portable profile and a clear path to stay compliant |\\n| Primary view | Network-wide compliance oversight | Own compliance status |\\n\\nThe dual-role model is fundamental to the architecture: one shared shell, role-aware experiences. There is no separate application per user type \u2014 role-based routing and data scoping happen inside the single Next.js app.\\n\\n## Scope and Roadmap\\n\\nThe v0 milestone is deliberately narrow: prove that the shared shell works for **status visibility** and **network navigation**.\\n\\nThe canonical compliance status vocabulary is:\\n\\n- **Verified**\\n- **Pending**\\n- **Expiring**\\n- **Suspended**\\n\\nA successful v0 lets a user see compliance status at a glance and navigate the trade network inside a consistent app shell. These four states are the core status model and should be treated as a first-class domain concept. Every status display must combine colour, icon, and explicit label \u2014 never colour alone (see Accessibility).\\n\\nPost-v0 phases introduce deeper workflows, in order:\\n\\n1. **Invite \u2192 onboard \u2192 verified profile** \u2014 the network growth loop\\n2. **Projects / sites readiness** \u2014 extending compliance visibility into project and site context\\n\\nWork on these flows should not start until the v0 shell and status model are proven.\\n\\n## Brand and Design Constraints\\n\\nThe brand tokens are **Electric Blue / Carbon / Ash**. The document defines explicit anti-references:\\n\\n- No neon / candy SaaS aesthetics (harsh pink\u2013purple palettes, playful Brutalist novelty)\\n- No glassmorphism or \\\"AI sparkle\\\" chrome in operational UI\\n- No dense enterprise grey (cluttered tables, tiny type, no breathing room)\\n\\nFamiliar CRM layout patterns \u2014 sidebar + header + workspace \u2014 are acceptable. Differentiation comes from colour, type, and density, not from novel layout.\\n\\nThe five design principles translate directly into implementation requirements:\\n\\n1. **Trust must be visible** \u2014 status, source, and currency must be immediately understandable. Status displays need to surface provenance (where the data came from) and freshness (when it was last confirmed).\\n2. **Complexity stays behind the interface** \u2014 the compliance chain can be deep; screens must stay simple. Favor progressive disclosure over dumping the full compliance graph.\\n3. **Evidence beats assertion** \u2014 show platform-confirmed status, not self-declared claims. Self-declared information must be visually distinguished from evidence-backed data.\\n4. **One shell, role-aware** \u2014 shared chrome; role and membership decide what each user sees.\\n5. **Status first for v0** \u2014 navigation and compliance overview take priority over deeper workflows.\\n\\nThe brand voice is **Clear \u00b7 Assured \u00b7 Evidence-led**: calm, operational guidance with direct and specific risk language \u2014 no fearmongering, no \\\"AI platform\\\" framing. All copy is Australian English.\\n\\n## Accessibility Requirements\\n\\nWCAG 2.2 AA compliance is a hard, non-negotiable requirement:\\n\\n- Sufficient contrast for all text and UI elements\\n- Full keyboard operability\\n- `prefers-reduced-motion` alternatives for any animated behaviour\\n- Status must **never** be communicated by colour alone \u2014 always colour + icon + explicit label\\n\\nThis applies to any component that renders compliance status: badges, table rows, list items, profile headers. Accessibility is an acceptance criterion, not a follow-up.\\n\\n## Relationship to the Codebase\\n\\n`PRODUCT.md` defines product intent rather than runtime behavior, so its connections to the codebase are architectural. It constrains:\\n\\n- **Route structure** \u2014 role-aware routes inside one Next.js app shell\\n- **Data model** \u2014 the four compliance status states, with source and currency as first-class attributes so evidence can be shown\\n- **Design tokens** \u2014 Electric Blue / Carbon / Ash colour system, density and typography guidance\\n- **Component contracts** \u2014 status displays combine colour + icon + label; shared navigation chrome (sidebar, top-right account)\\n- **Accessibility** \u2014 WCAG 2.2 AA as a non-functional requirement\\n- **Scope guard** \u2014 v0 must not implement invite/onboarding or project/site readiness flows\\n\\n```mermaid\\ngraph TD\\n    Shell[\\\"Next.js app shellsidebar \u00b7 account top-right\\\"]\\n    Shell --&gt; PC[\\\"Principal Contractordesktop-first compliance CRM\\\"]\\n    Shell --&gt; Trade[\\\"Trademobile-capable portable profile\\\"]\\n    PC --&gt; V0[\\\"v0 scopestatus visibility + network navigation\\\"]\\n    Trade --&gt; V0\\n    V0 --&gt; Post[\\\"Post-v0invite/onboarding, project\u2013site readiness\\\"]\\n```\\n\\n## Engineering Implications\\n\\nFor a developer working in this codebase, `PRODUCT.md` translates into concrete concerns:\\n\\n- **Role-based access control** \u2014 the shell is shared, so route guards and data scoping must branch on role and membership. Principal Contractors see network-wide data; Trade users see their own profile.\\n- **Status as a first-class entity** \u2014 the four status states form the core domain model. Each status instance should carry source and currency metadata so the UI can present evidence, not assertion.\\n- **Evidence-led presentation** \u2014 platform-confirmed data must not be visually conflated with self-declared data. This may require distinct badge treatments, source labels, or timestamp display.\\n- **Design token enforcement** \u2014 colours, type scale, and spacing should be defined once as theme tokens and constrained to the Electric Blue / Carbon / Ash palette.\\n- **Accessibility as acceptance criteria** \u2014 any status component that communicates by colour alone fails review. Keyboard operation and `prefers-reduced-motion` handling are mandatory.\\n- **Scope discipline** \u2014 resist building invite/onboarding or project/site workflows until v0 status visibility and navigation are proven.\\n\\nIn short: build the shared, role-aware shell; model status with evidence and currency; keep the UI calm, clear, and accessible; and do not outrun the v0 scope.\",\"other-raw\":\"# Other \u2014 Raw\\n\\n# Other \u2014 Raw\\n\\n## Overview\\n\\n`Other \u2014 Raw` is a static documentation module for the **Assureworx** platform \u2014 a multi-tenant B2B SaaS compliance network for the Australian construction industry. It contains the working source-of-truth documents that define the product's brand, behaviour, and technical architecture.\\n\\nThis module has **no runtime behaviour**. The call graph reports no internal, outgoing, or incoming calls, and no execution flows. None of the files are imported, executed, or served by the application; they are consumed by engineers, designers, and AI-assisted development as authoritative reference context.\\n\\nThe module consists of three files:\\n\\n| File | Kind | Version / Date | Status |\\n| --- | --- | --- | --- |\\n| `ASSUREWORX_BRAND.md` | Brand guidelines | v1.0, 20 July 2026 | Working source of truth |\\n| `ASSUREWORX_PRODUCT.md` | Product definition | v1.0, 20 July 2026 | Working product source of truth |\\n| `assureworx-platform-spec.html` | Technical specification | v1.1, June 2026 | Draft \u2014 awaiting dev review |\\n\\nThe HTML file is a fully self-contained, styled specification document (inline CSS, no external dependencies). It is not a web application page.\\n\\n## Document hierarchy and governance\\n\\nThe three documents are not peers. `ASSUREWORX_PRODUCT.md` is the consolidation point: it explicitly states that it merges the technical spec, the trade onboarding flow, and the current website copy, and that where those sources conflict it identifies the conflict rather than silently choosing one position.\\n\\n```mermaid\\nflowchart TD\\n    SPEC[\\\"assureworx-platform-spec.htmlTechnical Spec v1.1\\\"]\\n    ONB[\\\"Trade Onboarding Flow v1.0(external to this module)\\\"]\\n    WEB[\\\"Assureworx website copy(external to this module)\\\"]\\n    BRAND[\\\"ASSUREWORX_BRAND.mdBrand Guidelines v1.0\\\"]\\n    PROD[\\\"ASSUREWORX_PRODUCT.mdProduct Definition v1.0\\\"]\\n    SPEC --&gt; PROD\\n    ONB --&gt; PROD\\n    WEB --&gt; PROD\\n    PROD --&gt;|governs behaviour, scope, claims| IMPL[\\\"Product &amp; Engineering Implementation\\\"]\\n    BRAND --&gt;|governs visual identity, voice, UI| IMPL\\n```\\n\\nGovernance rules stated in the documents themselves:\\n\\n- `ASSUREWORX_BRAND.md` is the source of truth for brand work; product behaviour and commercial policy belong in `ASSUREWORX_PRODUCT.md`.\\n- `ASSUREWORX_PRODUCT.md` should be used as the starting context for product design, engineering, marketing, and AI-assisted development.\\n- Confirmed decisions must be recorded in the product doc before changing public claims, pricing logic, verification rules, or access behaviour.\\n- The brand guide must be reviewed whenever the master logo, typeface, pricing position, or verification definition changes.\\n\\n## Key content by document\\n\\n### `ASSUREWORX_BRAND.md`\\n\\nDeveloper-relevant brand constraints for UI and marketing work:\\n\\n- **Colour system.** Core tokens (`#3E6AE1` Electric Blue, `#23C9EE` Cyan, `#7B5CE0` Violet, `#171A20` Carbon, plus neutrals) and a semantic colour set for compliance states \u2014 Current/Verified, Warning/Expiring, Suspended/Error, Information, Portable profile. Status must never be communicated by colour alone; pair with an icon and explicit label.\\n- **Typography.** Primary stack is `\\\"Segoe UI\\\", system-ui, -apple-system, BlinkMacSystemFont, sans-serif`; monospace is `\\\"Cascadia Code\\\", \\\"Fira Code\\\", Consolas, \\\"Courier New\\\", monospace` for technical identifiers only.\\n- **Accessibility.** Normal body text must meet WCAG AA 4.5:1 contrast; large text and meaningful graphics at least 3:1.\\n- **Status labels.** Use explicit wording \u2014 `Verified`, `Current`, `Pending verification`, `Expiring soon`, `Expired`, `Suspended`, `Rejected`, `Manual review required`. No bare green ticks.\\n- **Messaging guardrails.** A preferred/avoided language table constrains claims (e.g., \\\"Verified by Assureworx\\\" not \\\"Approved by the government\\\"; \\\"Continuous monitoring\\\" not \\\"Set and forget\\\").\\n- **Logo constraints.** Brand is always spelled **Assureworx**; the logo must not be recoloured, stretched, or combined with AI/robot/brain motifs.\\n\\n### `ASSUREWORX_PRODUCT.md`\\n\\nThe behavioural source of truth. Key content:\\n\\n- **Product promise.** \\\"Verify once. Trust everywhere.\\\" with the outcome of \\\"Complete trade compliance visibility without manual chasing.\\\"\\n- **Core entities.** Principal Contractor organisation, trade organisation, individual, project, site, engagement/participation, onboarding request, evidence item, requirement, verification result, training assignment, incident, and audit event. Subcontracting depth is unlimited; every request retains both its immediate `parent_request_id` and the originating `chain_root_id`.\\n- **Assureworx Verified.** A platform-confirmed status, not a government endorsement, not a guarantee of future conduct. Five core checks: ABN validated, identity confirmed, licence current, insurance current, qualifications verified or on record. Profile lifecycle states: `Pending`, `Verified`, `Action required`, `Suspended`.\\n- **Verification methods.** Authoritative API, controlled manual review, provider verification (KYC), and self-declaration \u2014 which must never be presented as independently verified.\\n- **Requirement layers.** Universal profile, business, trade-class, Principal Contractor, and project/site requirements. A profile can remain portable while a single engagement is blocked for a site-specific gap.\\n- **Block types.** `Warning` (near expiry), `Engagement block` (project/site requirement missing), `Profile suspension` (foundational hard requirement expired, failed, or revoked).\\n- **Expiry policy.** Notify owner at 30 days; notify owner and affected Principal Contractor admins at 14 days; suspend immediately at expiry for hard requirements; notify on reinstatement.\\n- **Audit.** Append-only audit events with actor, action, resource, tenant context, timestamp, and material change data. No destructive deletion through normal product flows.\\n- **Phase 1 MVP scope.** Onboarding and request chain, ABN verification, KYC identity verification, evidence upload and manual review, NSW licence verification where a reliable integration exists, Principal Contractor portal, mobile-first trade onboarding, training (PDF/video), six report types, incident management, billing, and Australian data residency.\\n- **Confirmed technical decisions.** Microsoft Azure (Australia East), Azure Database for PostgreSQL Flexible Server, Microsoft Entra External ID, private Azure Blob Storage, Stripe, Next.js with TypeScript, GitHub with controlled CI/CD.\\n- **Open decisions.** Recorded with priority levels \u2014 see the conflicts table below.\\n\\n### `assureworx-platform-spec.html`\\n\\nThe engineering blueprint. It contains the most concrete implementation details in the module:\\n\\n- **Data model.** Ready-to-use PostgreSQL DDL for core tables: `tier1_organisations`, `trade_organisations`, `individuals`, `onboarding_requests`, `documents`, and `audit_log`. All tables use UUID primary keys and soft delete via `deleted_at TIMESTAMPTZ NULL`. The `audit_log` table is immutable \u2014 no `UPDATE` or `DELETE` permitted, enforced via PostgreSQL RLS policy.\\n- **Request chain.** Three request types: `direct_company`, `direct_individual`, and `sub_request`, linked through `parent_request_id` and `chain_root_id`.\\n- **Middleware contract.** Named middleware for the backend: `requireAuth` (JWT validation via Azure Entra External ID), `resolve-tenant-context.ts` (injects `tier1_org_id` or `trade_org_id` into `req.tenantContext`), and `require-subscription.ts` (billing enforcement, run after auth and tenant resolution so suspended users can still authenticate).\\n- **Per-module file pattern.** Each backend module follows `routes.ts` \u2192 `controller.ts` \u2192 `validation.ts` (Zod) \u2192 `service.ts` \u2192 `repository.ts` \u2192 `types.ts`, with tenant isolation enforced in the repository layer, not at route level.\\n- **API response envelope.** Success: `{ \\\"success\\\": true, \\\"data\\\": {}, \\\"meta\\\": {} }`. Error: `{ \\\"success\\\": false, \\\"error\\\": { \\\"code\\\", \\\"message\\\", \\\"details\\\" } }`.\\n- **Background jobs.** BullMQ on Azure Cache for Redis, including `document.expiry.check` (daily midnight AEST), `document.verify.api`, `notification.send.email`, `training.overdue.check`, `subscription.expiry.check`, `kyc.result.webhook`, and `report.generate` (async only \u2014 never block interactive API threads).\\n- **Frontend routes.** Two complete route maps: a desktop-first Tier 1 portal (`/dashboard`, `/projects/[id]/sites/[sid]`, `/trades/[id]`, `/training`, `/incidents`, `/reports`, etc.) and a mobile-first trade portal (`/onboarding` multi-step wizard, `/profile`, `/training`, `/team`, `/requests`).\\n- **Deployment pipeline.** DEV (feature branches, auto-deploy), UAT (`develop`, full feature parity), PROD (`main`, PR approval gate). All on Azure Australia East.\\n- **Integrations.** ABR Web Services for ABN lookup (GUID auth, 5\u201310 business day lead time), NSW Fair Trading and SafeWork NSW for Phase 1 licence verification, KYC provider TBC, Stripe for subscriptions/invoicing/webhooks, Azure Blob with signed URLs (15-minute expiry) and Defender for Storage malware scanning.\\n- **Design tokens.** The embedded `` block defines CSS variables (`--electric`, `--carbon`, `--success`, etc.) whose hex values match the brand doc palette exactly.\\n\\n## Known conflicts and decision status\\n\\n`ASSUREWORX_PRODUCT.md` Section 15 is the authoritative register of conflicts and open questions. The most important for developers and product work:\\n\\n| Priority | Question | Current position / note |\\n| --- | --- | --- |\\n| Critical | Principal Contractor pricing | Public website promises `$0 cost for Principal Contractors`; the technical spec lists `$5,000` Standard, `$10,000` Enterprise, and a complimentary tier. Treat the website promise as current until commercial policy changes. |\\n| Critical | Scope of \\\"network-wide trust\\\" disclosure | Define consent, data minimisation, and whether verified status can be shared without underlying documents. |\\n| Critical | Verified status scope | Recommended: portable base-profile status plus engagement readiness, so a site induction gap does not invalidate every relationship. |\\n| High | KYC provider | Stripe Identity, Entrust/Onfido, or Veriff \u2014 compare Australian document support, liveness, residency, retention, and unit cost. |\\n| High | NSW licence APIs | Do not commit Phase 1 to an assumed public API until access and terms are verified. |\\n| High | ABR Web Services access | Application and production credentials are a potential MVP path blocker. |\\n| High | Manual review operating model | SLA and competency standard for reviewers must be defined before onboarding real trades. |\\n| Medium | Salaried employee billing | The spec charges every individual `$49/yr`; this conflicts with company-managed employee onboarding. |\\n| Medium | \\\"Hard blocks before trades reach site\\\" | If no access-control integration exists in Phase 1, describe as a readiness block in Assureworx, not a physical access guarantee. |\\n\\nThe product doc also includes claim guardrails: do not claim government approval, do not call self-declared results verified, do not use \\\"immutable\\\" for records that authorised users can alter, and always attach scope and last-checked time to verified status.\\n\\n## Connections to the rest of the codebase\\n\\nThis module is the specification source for work happening elsewhere in the repository:\\n\\n- **Design system.** The brand doc palette and the spec's CSS variables agree exactly. UI work should resolve to these tokens rather than introducing new colours. The spec defines status colours as semantic (success/warning/danger) and warns against using the brand gradient for compliance states.\\n- **Logo asset path.** The HTML spec references `../../FRONTEND/assureworks-crm/public/logo_assure_darkmode.png` in its sidebar and header, indicating the expected frontend location for the logo. Note two things: the referenced folder is spelled `assureworks-crm` (with an \\\"s\\\") while the brand is `assureworx`, and `ASSUREWORX_BRAND.md` notes that the standalone logo image was not actually included in the supplied source files.\\n- **Backend blueprint.** The spec's `src/modules/...` structure, per-module file pattern, named middleware, and API envelope are the contract for the Express.js backend implementation.\\n- **Data model.** The DDL in the spec is implementation-ready for Azure Database for PostgreSQL. The product doc's non-functional requirements add content-based file-type validation, malware scanning, short-lived signed file access, and tenant isolation enforced in the data-access layer.\\n- **Product behaviour.** The product doc governs verification rules, expiry monitoring, notification recipients, block types, and audit events \u2014 any feature implementation must align with it, and any change to those rules should be recorded there first.\\n\\n## Maintenance guidelines\\n\\n- Update the source documents **before** publishing new claims, pricing, or UI work \u2014 the brand doc explicitly requires this.\\n- Record confirmed decisions in `ASSUREWORX_PRODUCT.md` rather than letting them live only in code or conversation.\\n- When sources conflict, follow the product doc's approach: identify the conflict explicitly instead of silently choosing one position.\\n- The HTML spec is the oldest document (June 2026, v1.1) and is marked \\\"draft \u2014 awaiting dev review\\\"; the two Markdown files (July 2026, v1.0) are the current working source of truth. Prefer the Markdown files where they overlap with the spec, except where the product doc defers to the spec or flags an open decision.\",\"other-readme-md\":\"# Other \u2014 README.md\\n\\n# Other \u2014 README.md\\n\\n## Module purpose\\n\\nThe repository root `README.md` is the primary entry point for the Assureworx monorepo. It serves three functions:\\n\\n1. **Orientation** \u2014 describes the product and maps the top-level directory structure so a new contributor can locate the frontend, backend, shared packages, database migrations, and knowledge base.\\n2. **Contribution contract** \u2014 defines the git workflow, branch naming conventions, quality gates, and working agreements that govern all changes to the repository.\\n3. **Operational reference** \u2014 records the delivery environment path, the rollback strategy, and pointers to runbooks and architectural decision records.\\n\\nThis is a documentation-only module. It contains no executable code, has no runtime behavior, and exposes no call graph. Its connections to the rest of the codebase are structural: it names every top-level directory and links to the key wiki documents that contributors should read before working on the code.\\n\\n## Repository map\\n\\nThe README defines the following top-level layout:\\n\\n| Path | Purpose |\\n| --- | --- |\\n| `frontend/` | Next.js and TypeScript web application |\\n| `backend/` | Express and TypeScript API and workers |\\n| `packages/` | Shared workspace packages |\\n| `db/migrations/` | Ordered PostgreSQL schema migrations |\\n| `scripts/` | Development, operations and deployment helpers |\\n| `wiki/` | Product and engineering knowledge base |\\n| `isms/` | ISO 27001 and SOC 2 control/evidence workspace |\\n| `Raw/` | Immutable source material used by the wiki |\\n\\nFor a new contributor, the README's recommended reading order is:\\n\\n- `wiki/overview.md` \u2014 product overview\\n- `wiki/architecture/code-map.md` \u2014 architecture map\\n- `wiki/architecture/cicd-target-operating-model.md` \u2014 CI/CD target operating model\\n\\n## Local setup\\n\\nThe README documents the minimal prerequisites for working on the repository:\\n\\n- Node.js 20 or later (Node.js 22 is used by CI)\\n- npm\\n- PostgreSQL 16 for database work and database tests\\n- Docker Desktop for building deployment images locally\\n\\nThe standard commands, run from the repository root:\\n\\n```powershell\\nnpm ci\\nnpm run typecheck\\nnpm test\\nnpm run dev\\n```\\n\\nThe frontend runs at `http://localhost:3000` and the API at `http://localhost:4000`. Environment files are intentionally excluded from Git; developers copy the relevant `.env.example` file and obtain authorised values out of band. Committing secrets is explicitly forbidden.\\n\\n## Development workflow\\n\\n### Git workflow\\n\\n`main` is the protected, always-releasable branch. Nobody pushes application changes directly to it. The documented flow is:\\n\\n1. Update local `main`.\\n2. Create a short-lived branch such as `feature/project-search` or `fix/invite-expiry`.\\n3. Make small commits with clear messages.\\n4. Push the branch and open a pull request into `main`.\\n5. Resolve review comments and conflicts on the working branch.\\n6. Merge only after required checks and approval pass; delete the working branch afterward.\\n\\nBranch prefixes are strictly defined:\\n\\n| Prefix | Use |\\n| --- | --- |\\n| `feature/` | New product capability |\\n| `fix/` | Defect correction |\\n| `hotfix/` | Urgent production correction using the same review controls |\\n| `chore/` | Maintenance that does not change product behaviour |\\n| `docs/` | Documentation-only change |\\n\\n### Quality gates\\n\\nBefore opening a pull request, a contributor must run:\\n\\n```powershell\\nnpm run lint\\nnpm run typecheck\\nnpm test\\nnpm run build\\n```\\n\\nDatabase changes carry an additional requirement: migration and RLS/constraint tests must pass against a disposable PostgreSQL database. Testing migrations against production from a workstation is explicitly prohibited.\\n\\n### Delivery and promotion\\n\\nThe intended release path is:\\n\\n```text\\nPull request \u2192 protected main \u2192 DEV \u2192 approval \u2192 TEST \u2192 approval \u2192 PROD\\n```\\n\\nThe same immutable container image digest is promoted through every environment. DEV exists today; TEST and PROD, plus the automated promotion workflow, are still being established. Until that work is complete, the manual DEV runbook (`wiki/runbooks/azure-dev-deploy.md`) is an explicitly temporary process and must not be used as a laptop-to-production shortcut.\\n\\n```mermaid\\nflowchart LR\\n    PR[Pull request] --&gt; Main[protected main]\\n    Main --&gt; DEV[DEV]\\n    DEV --&gt;|approval| TEST[TEST]\\n    TEST --&gt;|approval| PROD[PROD]\\n    DEV -. temporary manual path .-&gt; RB[azure-dev-deploy runbook]\\n```\\n\\n### Rollback policy\\n\\nEvery release records its commit SHA and API/web image digests. Rollback means selecting a previous successful release and redeploying those exact images. The README explicitly notes that database migrations require a separate recovery plan, because reverting application code does not automatically undo a schema change.\\n\\n## Working agreements\\n\\nThe README encodes repository-wide rules that apply to every contributor:\\n\\n- Never commit `.env` files, credentials, tokens, certificates or customer data.\\n- Never commit local `.cursor`, `.agents`, `agent`, `.serena` or `.codex` state.\\n- Do not rewrite shared branch history without an agreed recovery point.\\n- Keep the data-classification catalogue (`wiki/architecture/data-classification-catalogue.md`) current when stored or exposed data changes.\\n- Update the wiki when architecture, product behaviour or an important decision changes.\\n- Raise uncertain or risky changes in a pull request before implementation rather than bypassing the review workflow.\\n\\nThese agreements are the \\\"constitution\\\" for contributions \u2014 they govern both code and documentation changes and are the primary subject a reviewer will hold a contributor to.\\n\\n## Connections to the rest of the codebase\\n\\nAs the repository's root documentation node, the README's integration points are the structural references it makes:\\n\\n- **Directories**: `frontend/`, `backend/`, `packages/`, `db/migrations/`, `scripts/`, `wiki/`, `isms/`, `Raw/`\\n- **Wiki entry points**: `wiki/overview.md`, `wiki/architecture/code-map.md`, `wiki/architecture/cicd-target-operating-model.md`, `wiki/architecture/data-classification-catalogue.md`\\n- **Runbooks**: `wiki/runbooks/azure-dev-deploy.md` (the temporary manual DEV deployment path), plus the broader `wiki/runbooks/` directory for operational instructions\\n- **Decision records**: `wiki/decisions/` for architectural decisions\\n\\nEvery other documentation module in the repository is reachable from the README, and the working agreements it lists apply to all of them. In practice, a change to the repository's structure, delivery process, or contribution rules will require a corresponding update to this module.\",\"other-scripts-backup\":\"# Other \u2014 scripts-backup\\n\\n# scripts/backup \u2014 Manual Backup &amp; Sync Scripts\\n\\n## Overview\\n\\nThe `scripts/backup` module contains five Windows batch scripts for manually backing up and syncing the repository. They are **not** wired into any scheduler, hook, or build step \u2014 each is run by a developer when a snapshot is needed.\\n\\nThe scripts fall into three functional groups:\\n\\n| Script | Purpose |\\n|---|---|\\n| `backup-to-d-drive.bat` | Full copy of the project (minus `node_modules`) to `D:\\\\assureworks` |\\n| `backup-to-e-drive.bat` | Same, to `E:\\\\assureworks` |\\n| `backup-to-folder.bat` | Same, to `D:\\\\Projects\\\\BackupFolder` |\\n| `sync-docs-to-e-drive.bat` | Selective sync of documentation folders and root markdown files to a local drive or custom folder |\\n| `sync-frontend-backend-to-wiki-raw.bat` | Clean snapshot of `FRONTEND` and `BACKEND` source into `wiki\\\\raw` |\\n\\nAll scripts use `robocopy` as the copy engine and rely on the same two-level directory layout: they live in `scripts/backup/`, so the project root is always two directories up.\\n\\n## Script Relationships\\n\\n```mermaid\\ngraph LR\\n    ROOT[\\\"Project root\\\"] --&gt; B[\\\"backup-to-*.bat(D:, E:, folder variants)\\\"]\\n    ROOT --&gt; D[\\\"sync-docs-to-e-drive.bat\\\"]\\n    ROOT --&gt; S[\\\"sync-frontend-backend-to-wiki-raw.bat\\\"]\\n    B --&gt; BDEST[\\\"External drive / folderyyyy-MM-dd\\\\\\\\vN \u2014 full copy\\\"]\\n    D --&gt; DDEST[\\\"E: drive / custom destdocs + root markdown only\\\"]\\n    S --&gt; SDEST[\\\"wiki\\\\\\\\raw\\\\\\\\FRONTEND + BACKENDclean snapshot\\\"]\\n```\\n\\n## Common Conventions\\n\\n### Project Root Resolution\\n\\nEvery script resolves the repository root the same way:\\n\\n```batch\\npushd \\\"%~dp0..\\\\..\\\"\\nset \\\"SOURCE=%CD%\\\"\\npopd\\n```\\n\\nBecause the script lives in `scripts/backup\\\\`, `..\\\\..` points at the repository root. `SOURCE` is always the absolute path to that root. All robocopy source paths are derived from `SOURCE`.\\n\\n### Date-Stamped, Versioned Destinations (backup scripts)\\n\\nThe three `backup-to-*.bat` scripts build a destination hierarchy:\\n\\n```\\n\\\\\\\\v\\n```\\n\\n- `BACKUP_ROOT` is the only difference between the three scripts (`D:\\\\assureworks`, `E:\\\\assureworks`, `D:\\\\Projects\\\\BackupFolder`).\\n- The current date is fetched from PowerShell: `Get-Date -Format yyyy-MM-dd`.\\n- The version directory is found by probing `v1`, `v2`, `v3`, \u2026 and using the first one that does **not** exist. No version is ever overwritten; repeated runs on the same day produce `v1`, `v2`, etc.\\n- The destination root and date directory are created on demand with `mkdir`.\\n\\n### Shared Robocopy Options\\n\\nAll scripts pass a near-identical flag set to robocopy:\\n\\n| Flag | Meaning |\\n|---|---|\\n| `/E` | Copy all subdirectories, including empty ones |\\n| `/XD node_modules` | Exclude `node_modules` |\\n| `/MT:8` | Use 8 parallel threads |\\n| `/R:2 /W:2` | Retry 2 times, wait 2 seconds |\\n| `/NP /NDL /NJH /NJS` | Suppress progress, directory list, job header, and job summary output |\\n\\n### Exit Code Handling\\n\\nRobocopy uses bit-coded exit codes. The `0x8` bit signals actual failures (access denied, source/dest unreachable, etc.); codes below `8` (e.g. `1` = files copied, `2` = extra files) are informational.\\n\\n- The backup scripts check `if !ERRORLEVEL! GEQ 8` immediately after the copy and abort with a non-zero exit code if the failure bit is set.\\n- `sync-docs-to-e-drive.bat` tracks the maximum exit code across its multiple robocopy calls in `RC_MAX` and checks it once at the end.\\n- `sync-frontend-backend-to-wiki-raw.bat` checks the exit code after each of its two robocopy calls and aborts on the first failure.\\n\\n## Full Project Backups: `backup-to-*.bat`\\n\\nThese three scripts are byte-for-byte identical except for the `BACKUP_ROOT` value. They produce a full snapshot of the repository with only `node_modules` excluded.\\n\\nExecution flow:\\n\\n1. Resolve `SOURCE` to the repository root.\\n2. Compute the date string and create `BACKUP_ROOT\\\\yyyy-MM-dd`.\\n3. Find the first free `v` directory.\\n4. Run robocopy: `SOURCE` \u2192 `DEST` with `/E` and the standard exclusions.\\n5. Print the destination path and wait for a keypress.\\n\\nBecause they never use `/MIR`, they are purely additive \u2014 they cannot delete anything at the destination. Each run is an independent, complete copy; there is no incremental or delta behavior.\\n\\n## Documentation Sync: `sync-docs-to-e-drive.bat`\\n\\nThis script is more targeted than the full backups. It copies only the knowledge/documentation portions of the repository, plus a fixed set of root markdown files.\\n\\n### Usage\\n\\n```\\nsync-docs-to-e-drive.bat\\nsync-docs-to-e-drive.bat \\\"E:\\\\some\\\\other\\\\folder\\\"\\nsync-docs-to-e-drive.bat \\\"E:\\\\some\\\\other\\\\folder\\\" /MIR\\n```\\n\\n- First argument: destination folder. If omitted, a hardcoded default is used.\\n- `/MIR`: switches from additive copy (`/E`) to mirror mode (`/MIR`), which **deletes files at the destination** that no longer exist in the source. It can appear as either argument.\\n\\n### Argument Parsing\\n\\nThe script has several quirks worth understanding before modifying it:\\n\\n- The default destination is `D:\\\\toShare`, despite the script name mentioning the E: drive. The header comment and the banner text also disagree with the actual default \u2014 the banner hardcodes `Sync docs to D:\\\\toShare` even when a custom destination is passed.\\n- `/MIR` is detected in either `%~1` or `%~2`. If `/MIR` is the first argument, the destination is **not** overwritten \u2014 it stays at the default.\\n- The desired copy mode (`/E` or `/MIR`) is stored in `COPY_MODE` and passed to each robocopy call.\\n\\n### Sync Scope\\n\\nThe script calls a local subroutine, `:sync_dir`, once for each top-level knowledge folder:\\n\\n```batch\\ncall :sync_dir \\\"wiki\\\" \\\"wiki\\\"\\ncall :sync_dir \\\"isms\\\" \\\"isms\\\"\\ncall :sync_dir \\\"Raw\\\" \\\"Raw\\\"\\ncall :sync_dir \\\"deliverables\\\" \\\"deliverables\\\"\\n```\\n\\nEach `:sync_dir` invocation maps a relative source folder to a same-named destination folder. Missing source folders are skipped with a `SKIP missing:` message rather than failing the run. After the four folders, these root markdown files are copied if they exist:\\n\\n- `AGENTS.md`\\n- `ARCHITECTURE.md`\\n- `DESIGN.md`\\n- `PRODUCT.md`\\n\\n### Exclusions\\n\\nIn addition to the standard `node_modules` exclusion, every `:sync_dir` call passes these extra filters:\\n\\n| Filter | Value |\\n|---|---|\\n| `/XD` | `node_modules`, `.git`, `.tmp`, `.next`, `dist`, `build`, `coverage`, `.data` |\\n| `/XF` | `*.log`, `.env`, `.env.*`, `*.pem`, `*.p12`, `*.cer` |\\n| `/XF` (in `ROBO_COMMON`) | `Thumbs.db`, `.DS_Store`, `desktop.ini` |\\n\\nThe intent is to keep application source, build artifacts, secrets, and local editor junk out of the synced destination. Note that the banner text claims to exclude `frontend`, `backend`, `packages`, `db`, `scripts`, and `public` as directories, but those are **not** actually in the `/XD` lists \u2014 they are simply not part of the four synced folders, so they are never copied anyway.\\n\\n### Error Accumulation\\n\\nEach robocopy call records its exit code in `RC` and updates `RC_MAX` to the highest value seen. After all folders are synced, `RC_MAX &gt;= 8` is treated as a failure. Because `8` is the failure bit in robocopy's code scheme, taking the maximum across calls is sufficient to detect any failed copy.\\n\\n## Source Snapshot: `sync-frontend-backend-to-wiki-raw.bat`\\n\\nThis script copies the application source code into `wiki\\\\raw` so the knowledge base can reference or import it.\\n\\n- Destination root is resolved to `\\\\wiki\\\\raw` (full path normalized via a `for %%I in (...)` loop).\\n- It expects top-level `FRONTEND` and `BACKEND` directories (note: uppercase). If either is missing, the script aborts immediately with an error.\\n- Unlike the other scripts, it is **destructive at the destination**: existing `wiki\\\\raw\\\\FRONTEND` and `wiki\\\\raw\\\\BACKEND` folders are deleted with `rmdir /s /q` before copying. This guarantees a clean snapshot with no stale files.\\n- Only `node_modules` is excluded \u2014 every other file, including build output and local configuration, is copied.\\n- Each copy is checked individually; the first robocopy failure aborts the script.\\n\\nCallers should understand that this is a replace-sync, not an incremental update. If the goal is to preserve history at the destination, this script is the wrong tool.\\n\\n## Integration with the Repository\\n\\nThe module has no programmatic callers \u2014 the call graph for these scripts is empty both for incoming and outgoing calls. They are standalone developer tools. Their connection to the rest of the codebase is purely through the directory layout they assume:\\n\\n| Assumed path | Used by | Purpose |\\n|---|---|---|\\n| `FRONTEND\\\\`, `BACKEND\\\\` | `sync-frontend-backend-to-wiki-raw.bat` | Application source snapshots |\\n| `wiki\\\\`, `isms\\\\`, `Raw\\\\`, `deliverables\\\\` | `sync-docs-to-e-drive.bat` | Knowledge base / documentation folders |\\n| `AGENTS.md`, `ARCHITECTURE.md`, `DESIGN.md`, `PRODUCT.md` | `sync-docs-to-e-drive.bat` | Root-level product documentation |\\n| `wiki\\\\raw\\\\` | `sync-frontend-backend-to-wiki-raw.bat` | Raw source import area for the wiki |\\n\\nThe three `backup-to-*.bat` scripts make no assumptions about the repo layout beyond the project root \u2014 they copy everything except `node_modules`.\\n\\n## Maintenance\\n\\n- **New backup destination**: copy any `backup-to-*.bat`, change `BACKUP_ROOT`, and (optionally) the banner text. No other edits are required.\\n- **Change the docs sync default**: edit `DEST` in `sync-docs-to-e-drive.bat`, and update the banner text which currently hardcodes `D:\\\\toShare`.\\n- **Change sync exclusions**: edit the `/XD` and `/XF` lists in the `:sync_dir` call site, or the shared `ROBO_COMMON` variable for the always-on file exclusions.\\n- **New knowledge folder**: add another `call :sync_dir \\\"folder\\\" \\\"folder\\\"` line in `sync-docs-to-e-drive.bat`.\\n\\nAll scripts require Windows with `robocopy` (included by default on modern Windows) and PowerShell (used only for date formatting). None of them perform any logging beyond console output.\",\"other-scripts-db-lifecycle\":\"# Other \u2014 scripts-db-lifecycle\\n\\n# scripts/db-lifecycle \u2014 Database Backup, Wipe &amp; Restore Tooling\\n\\n## Purpose\\n\\n`scripts/db-lifecycle` is the operational toolkit for the Assureworx Postgres databases: the local laptop Docker instance and the Azure DEV managed Postgres. It provides four PowerShell entry points (plus `.bat` wrappers) for:\\n\\n- **Snapshotting** the full database \u2014 schema, data, indexes, and RLS policies \u2014 into a timestamped backup folder\\n- **Wiping** the local database back to an empty, migrated schema with a single founder account\\n- **Restoring** a previous dump (typically the latest Azure DEV backup) onto the local machine\\n- **Bootstrapping** the founder (`tech@assureworx.com.au`) as a `platform_admin` with a `R01` role grant, in a form that local passwordless auth accepts\\n\\nBackups land in repo-root `backups/` (gitignored \u2014 they can contain customer PII). Azure DEV has intentional delete-scope: there is no `wipe-dev.ps1`.\\n\\n## Environment &amp; Role Model\\n\\n| Piece | What it is |\\n| --- | --- |\\n| Schemas | `core` (app tables) + `meta` (dictionary + `schema_migration`) |\\n| Runtime role | API connects as `app_login`, a member of group role `app_rt` |\\n| Admin role | Local: `postgres`. Azure DEV: the `DATABASE-URL` secret in Key Vault |\\n| RLS | Widely enabled, often `FORCE ROW LEVEL SECURITY` \u2014 dumps/restores always run as admin |\\n| Founder | `tech@assureworx.com.au` = platform_admin (`R01`). Local tooling forces `subject = dev\\\\|\u2026` so passwordless login works without Entra |\\n\\nLocal Postgres listens on `127.0.0.1:5433` (Docker container `assureworx-pg`). Azure DEV is reached via `az` + Key Vault credential lookup.\\n\\n## Script Architecture\\n\\n```mermaid\\nflowchart TB\\n    subgraph EntryPoints\\n        backup-local\\n        backup-dev\\n        wipe-local\\n        restore-to-local\\n    end\\n    common[_common.ps1]\\n    node[Node helpers:inventory.mjsensure-founder.mjs]\\n    local[(Local Postgres127.0.0.1:5433)]\\n    azure[(Azure DEV Postgres)]\\n    kv[(Key Vaultkv-aw-dev-aue)]\\n\\n    EntryPoints --&gt; common\\n    common --&gt; node\\n    backup-local --&gt; local\\n    wipe-local --&gt; local\\n    restore-to-local --&gt; local\\n    backup-dev --&gt; azure\\n    backup-dev --&gt; kv\\n```\\n\\nEvery PowerShell script dot-sources `_common.ps1` and runs from the repo root. `_common.ps1` owns connection-string resolution, Postgres client discovery, version-compatibility fallback (Docker), role/founder provisioning, and inventory generation via the two Node.js helpers.\\n\\n## Shared Helpers (`_common.ps1`)\\n\\nAll scripts set `$ErrorActionPreference = \\\"Stop\\\"` via the common file. Key functions:\\n\\n**Output &amp; paths**\\n- `Write-Step`, `Write-Ok` \u2014 consistent console formatting\\n- `New-BackupDir -source ` \u2014 creates `backups///` and returns the path\\n- `Write-Manifest -outDir  -fields ` \u2014 writes `MANIFEST.txt` with creation time, machine, repo, and caller-supplied metadata\\n\\n**Connection resolution**\\n- `Get-LocalAdminDatabaseUrl` \u2014 admin URL from `$env:ADMIN_DATABASE_URL`, else `backend/.data/local-infra-secrets.env` (`POSTGRES_PASSWORD`), else a hardcoded dev fallback\\n- `Get-LocalAppDatabaseUrl` \u2014 reads `DATABASE_URL` from `backend/.env`\\n- `Get-LocalAppLoginPassword` \u2014 pulls the `app_login` password from secrets or parses it out of the app URL\\n- `Get-AzureDevDatabaseUrl` \u2014 uses `$env:DATABASE_URL` if it points off-localhost, otherwise authenticates to the Startup Sponsorship tenant and reads the `DATABASE-URL` secret from `kv-aw-dev-aue`\\n- `ConvertTo-PgConnArgs` \u2014 passes connection strings through, appending `sslmode=require` for non-local hosts that don't already specify it\\n- `Get-DbHostLabel` \u2014 extracts the hostname for display and safety checks\\n\\n**Postgres client compatibility** (`Get-PgTool`, `Get-ServerMajor`, `Invoke-PgDumpCompatible`, `Invoke-PgRestoreCompatible`)\\n\\nAzure DEV runs Postgres 18 while host clients may be 16. The dump/restore wrappers detect server and client major versions; when the local client is older than the server they re-run the command through `docker run postgres:18`, rewriting `localhost`/`127.0.0.1` to `host.docker.internal` and mounting output/input directories. `Get-PgTool` searches PATH, then `C:\\\\Program Files\\\\PostgreSQL\\\\{18,17,16}\\\\bin`.\\n\\n**Provisioning &amp; destructive ops**\\n- `Drop-AndCreateLocalDb -adminUrl` \u2014 terminates connections to `assureworx`, drops and recreates it owned by `postgres`\\n- `Reset-LocalPostgres18` \u2014 nuclear: stops/removes `assureworx-pg`, deletes the old Docker volume, recreates container + volume on `postgres:18`, and waits for `pg_isready`\\n- `Ensure-LocalAppLogin -adminUrl` \u2014 creates `app_rt` if missing, creates/updates `app_login` with the password from `backend/.env`\\n- `Ensure-FounderLocal -adminUrl` \u2014 runs `ensure-founder.mjs` with `--dev-auth`\\n- `Invoke-Migrate -adminUrl` \u2014 runs `npm run migrate` with `DATABASE_URL` set\\n- `Invoke-SchemaInventory -connectionString -outDir` \u2014 runs `inventory.mjs` against the target DB\\n\\n## Backup Scripts\\n\\n### `backup-local.ps1`\\n\\nSnapshots the local Docker Postgres:\\n\\n1. Resolves the local admin URL and creates `backups/local//`\\n2. Custom-format dump: `pg_dump -Fc -b -v` \u2192 `assureworx.dump`\\n3. Schema-only plain SQL: `pg_dump -Fp -s -n core -n meta` \u2192 `schema.sql` (readable DDL + RLS)\\n4. Roles dump: `docker exec assureworx-pg pg_dumpall --roles-only` \u2192 `roles.sql` (skipped with a warning if the container isn't running)\\n5. `inventory.mjs` \u2192 `inventory.json` / `inventory.md`\\n6. `MANIFEST.txt` via `Write-Manifest`\\n\\n### `backup-dev.ps1`\\n\\nIdentical flow for Azure DEV, with two differences:\\n\\n- Connection string comes from `Get-AzureDevDatabaseUrl` (Key Vault lookup, requires `az login` on the Startup Sponsorship subscription `17065bb7-1e80-4ef0-a043-4302a7f37af8`)\\n- No `roles.sql` \u2014 cluster roles are platform-managed in Azure; the manifest records the Key Vault and secret name instead\\n\\nThe script prints a PII warning; dumps must be treated as confidential.\\n\\n## Wipe: `wipe-local.ps1`\\n\\nNuclear reset of the local DB. Parameters:\\n\\n| Parameter | Meaning |\\n| --- | --- |\\n| `-ConfirmWipe` | Required; exits with code 2 otherwise |\\n| `-SkipBackup` | Skips the automatic pre-wipe local safety backup |\\n| `-SeedCampbell` | Seeds the Campbell demo network after wiping (`npm run seed -w backend`), then re-asserts `tech@` as founder |\\n| `-RecreatePg18` | Destroys and recreates the Docker container + volume on `postgres:18` instead of just dropping the database |\\n\\nFlow:\\n\\n1. Refuses non-localhost targets\\n2. Safety backup via `backup-local.ps1` (unless `-SkipBackup`)\\n3. `Drop-AndCreateLocalDb` (or `Reset-LocalPostgres18` with `-RecreatePg18`)\\n4. `Invoke-Migrate` \u2014 applies all migrations to the empty DB\\n5. `Ensure-LocalAppLogin` \u2014 recreates `app_rt` / `app_login`\\n6. `Ensure-FounderLocal` \u2014 creates/updates the founder with `subject = dev|tech@assureworx.com.au`\\n7. If `-SeedCampbell`: runs the backend seed, then re-runs `Ensure-FounderLocal` because the seed uses a different founder email\\n\\nAfter the wipe, signing in at `http://localhost:3000` via **Development sign-in** as `tech@assureworx.com.au` lands on `/admin` with `R01 platform_admin`.\\n\\n## Restore: `restore-to-local.ps1`\\n\\nLoads a previous dump onto local Postgres. Parameters:\\n\\n| Parameter | Meaning |\\n| --- | --- |\\n| `-LatestDev` | Use the most recent `backups/azure-dev/*` dump |\\n| `-LatestLocal` | Use the most recent `backups/local/*` dump |\\n| `-DumpPath` | Point at a folder containing `assureworx.dump`, or a `.dump` file directly |\\n| `-ConfirmRestore` | Required; exits with code 2 otherwise |\\n| `-SkipBackup` | Skips the automatic pre-restore safety backup |\\n| `-RecreatePg18` | Force Docker volume recreation on `postgres:18` before restore |\\n\\nFlow:\\n\\n1. Resolves the dump file (`Resolve-DumpFile` / `Get-LatestDump`), refuses non-localhost targets\\n2. Safety backup (unless `-SkipBackup`)\\n3. If the dump came from `azure-dev` (or `-RecreatePg18` is set): `Reset-LocalPostgres18` \u2014 required because a Postgres 18 custom-format dump cannot restore onto a Postgres 16 volume. Otherwise just drop/recreate the database\\n4. `pg_restore` with `--no-owner --no-acl --exit-on-error` \u2014 local role ownership/grants are kept; failures exit with status \u2265 2\\n5. `Ensure-LocalAppLogin` \u2014 re-binds `app_login` password to `backend/.env`\\n6. `Ensure-FounderLocal` \u2014 guarantees passwordless local login as the founder\\n7. Post-restore inventory snapshot written to `backups/local/post-restore-/`\\n\\n## Node.js Helpers\\n\\n### `ensure-founder.mjs`\\n\\nIdempotently ensures the founder actor exists with the `R01` grant. Invocation:\\n\\n```powershell\\n$env:DATABASE_URL = \\nnode .\\\\scripts\\\\db-lifecycle\\\\ensure-founder.mjs tech@assureworx.com.au --dev-auth\\n```\\n\\nBehavior:\\n\\n- Runs in a transaction and sets `app.bypass = on` (the app-pool bypass GUC) before touching RLS-protected tables\\n- `--dev-auth` sets `subject_txt = dev|` so the local `ALLOW_DEV_AUTH=1` passwordless path accepts the login\\n- Without `--dev-auth`, a brand-new actor gets `pending|` \u2014 the first Entra sign-in rewrites the subject; existing Entra-linked subjects are left untouched\\n- Creates, when needed: the \\\"Assureworx Platform\\\" org party (`K01`/`E01`), a person party (`K03`/`M01`), a party identity (`D03`), the actor, the `R01` grant, and a 10-year `PL2` ledger entry\\n- If the actor already exists without an `R01` grant, it locates (or creates) the platform org and adds the grant\\n\\nUses `pgSslOptions` from `scripts/pg-ssl.mjs` to configure TLS.\\n\\n### `inventory.mjs`\\n\\nDumps a live catalogue of the target database into `inventory.json` and a human-readable `inventory.md`:\\n\\n- Schemas (`core`, `meta`, `public`)\\n- Relations (`r`, `p`, `v`, `m`) with RLS / force-RLS flags and comments\\n- Columns (type, nullability, defaults, max length) from `information_schema.columns`\\n- RLS policies from `pg_policies`\\n- Index definitions from `pg_index`\\n- Roles of interest (`postgres`, `app_rt`, `app_login`, `awdbadmin`, any `app_%`)\\n- Applied migrations from `meta.schema_migration`\\n- Row counts per table (null when uncountable)\\n\\nInternal helpers: `quoteIdent` (safe identifier quoting) and `renderMarkdown` (markdown generation). Also uses `pgSslOptions`.\\n\\n## Backup Folder Layout\\n\\n```\\nbackups/\\n  local//\\n    assureworx.dump   # pg_dump -Fc (restore with pg_restore)\\n    schema.sql        # DDL + RLS policies, human-readable\\n    roles.sql         # local only (docker pg_dumpall --roles-only)\\n    inventory.json    # tables / columns / policies / row counts\\n    inventory.md\\n    MANIFEST.txt\\n  azure-dev//\\n    \u2026 same, minus roles.sql\\n```\\n\\n## Postgres Version Compatibility\\n\\n- Azure DEV runs **Postgres 18**; the local Docker image was historically 16\\n- Custom-format dumps are not downward-compatible (PG18 dump cannot restore onto PG16)\\n- `backup-dev.ps1` automatically runs `pg_dump` through `docker run postgres:18` when the host client is older than the server\\n- `restore-to-local.ps1 -LatestDev` recreates the local Docker volume as `postgres:18` before restoring\\n- `wipe-local.ps1 -RecreatePg18` does the same for a clean local slate\\n\\n## Safety Model\\n\\n- `wipe-local.ps1` and `restore-to-local.ps1` **refuse non-localhost hosts** (checked via `Get-DbHostLabel`)\\n- Both require explicit confirmation flags (`-ConfirmWipe` / `-ConfirmRestore`) and fail with exit code 2 otherwise\\n- Destructive operations take a **safety backup of local first** unless `-SkipBackup` is passed\\n- `backups/` is gitignored and may contain customer PII \u2014 never commit it\\n- Azure DEV dumps are production-like and confidential\\n- There is intentionally no `wipe-dev.ps1`; nothing in this module can destroy Azure data\\n\\n## Integration with the Rest of the Repo\\n\\n| Dependency | Role |\\n| --- | --- |\\n| `scripts/pg-ssl.mjs` | Shared SSL option construction for both Node helpers |\\n| `backend/.env` | App `DATABASE_URL`; source of the `app_login` password binding |\\n| `backend/.data/local-infra-secrets.env` | Local `POSTGRES_PASSWORD` / `APP_LOGIN_PASSWORD` (created by `scripts/secure-local-infra.ps1`) |\\n| Root `npm run migrate` | Applies schema migrations via `Invoke-Migrate` |\\n| `npm run seed -w backend` | Seeds the Campbell demo network (`wipe-local -SeedCampbell`) |\\n| Azure Key Vault `kv-aw-dev-aue` | Holds `DATABASE-URL` for Azure DEV |\\n| Azure subscription `17065bb7-1e80-4ef0-a043-4302a7f37af8`, tenant `e0045fa5-8177-4976-a60a-892074203e28` | Target of `az login` / `az account set` in `Get-AzureDevDatabaseUrl` |\\n\\nThe `.bat` files (`backup-local.bat`, `backup-dev.bat`, `wipe-local.bat`, `restore-to-local.bat`) are thin wrappers that invoke the corresponding PowerShell scripts with `-NoProfile -ExecutionPolicy Bypass`.\",\"other-scripts\":\"# Other \u2014 scripts\\n\\n# Scripts Module\\n\\nThe `scripts/` directory contains the operational tooling for the Assureworx platform. These scripts automate Azure infrastructure deployment, Entra External ID (CIAM) tenant configuration, PostgreSQL database lifecycle tasks, ISMS document generation, and local developer utilities.\\n\\nThe module is deliberately heterogeneous: each script is a small, standalone entry point written in the language best suited to its job \u2014 PowerShell for Azure/Graph API orchestration, Node.js ESM for database work, Python for document generation, and batch files for trivial local conveniences. There is no shared runtime or package; the common thread is that every script assumes you are operating from a cloned repo against a known Azure subscription and, where relevant, the correct CIAM tenant.\\n\\n## Script inventory\\n\\n| Script | Language | Purpose |\\n|---|---|---|\\n| `deploy-dev.bat` / `deploy-dev.ps1` | Batch / PowerShell | Build and deploy DEV to Azure Container Apps, with smoke tests |\\n| `deploy-test.ps1` | PowerShell | Deploy TEST to Azure Container Apps |\\n| `bind-dev-domains.ps1` | PowerShell | Bind custom DEV hostnames, request managed certs, cut over env vars |\\n| `bind-test-domains.ps1` | PowerShell | Same for TEST (beta.assureworx.com.au) |\\n| `entra-app-reg.ps1` / `entra-app-reg-test.ps1` | PowerShell | Create CIAM app registration + client secret, optionally push to Key Vault |\\n| `enable-ciam-native-auth.ps1` | PowerShell | Enable Native Authentication and Graph delegated scopes on a CIAM app |\\n| `enable-ciam-otp-email.ps1` | PowerShell | Register `OnOtpSend` custom extension \u2192 ACS-branded OTP email |\\n| `enable-ciam-mfa-dev.ps1` | PowerShell | Create/update Conditional Access MFA policy for DEV web app |\\n| `apply-ciam-branding.ps1` | PowerShell | Apply company branding (colours, copy, logos) to CIAM tenant |\\n| `apply-004.mjs` | Node.js | Apply a single SQL migration inside a transaction |\\n| `migrate.mjs` | Node.js | Migration runner (uses `pgSslOptions`) |\\n| `bootstrap-platform-admin.mjs` | Node.js | Create a platform-admin actor linked by email |\\n| `gen-data-catalogue.mjs` | Node.js | Generate the data classification catalogue from schema metadata |\\n| `reset-test-identity.mjs` | Node.js | Wipe database and Entra state for a test identity |\\n| `wipe-test-app-data.mjs` | Node.js | Wipe TEST app data |\\n| `db-lifecycle/inventory.mjs` | Node.js | DB inventory (uses `pgSslOptions`) |\\n| `db-lifecycle/ensure-founder.mjs` | Node.js | Ensure founder actor exists (uses `pgSslOptions`) |\\n| `provision-pc.mjs` | Node.js | Machine provisioning bootstrap |\\n| `build-isms-starter-docs.py` | Python | Generate branded ISMS Word documents from Markdown sources |\\n| `slice-tradie-icons.py` | Python | Process/slice trade icon packs |\\n| `isms-progress.mjs` | Node.js | Render ISMS progress bars |\\n| `free-ports.ps1` | PowerShell | Kill processes occupying local TCP ports |\\n| `force-shutdown-10min.bat` / `cancel-shutdown.bat` | Batch | Schedule/cancel a forced local shutdown |\\n| `pg-ssl.mjs` | Node.js | Shared `pgSslOptions()` helper for all DB scripts |\\n| `wire-acs-managed-identity.ps1` | PowerShell | Wire ACS managed identity onto an API Container App (called by `deploy-dev.ps1`) |\\n\\n## Deployment pipeline\\n\\nThe deployment scripts are the heart of the module. They take local code, build it into container images, push those images to Azure Container Registry, update the Container Apps, sync secrets from Key Vault, and run post-deploy smoke checks.\\n\\n### DEV deployment flow\\n\\n```\\ndeploy-dev.bat \u2500\u2500\u25b6 deploy-dev.ps1 \u2500\u2500\u25b6 Build-ImageLocal / Build-ImageCloud\\n                                          \u2502\\n                                          \u25bc\\n                                     ACR (crawdevaue)\\n                                          \u2502\\n              \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\\n              \u25bc                           \u25bc                           \u25bc\\n      ca-aw-api-dev-aue           ca-aw-web-dev-aue           kv-aw-dev-aue\\n      (ENTRA, REDIS, ACS,        (API_ORIGIN, site gate,     (secrets for the\\n       Wallet, App Insights)      DEV auth secret)            above)\\n              \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n                                          \u2502\\n                                          \u25bc\\n                                     Invoke-Smoke\\n```\\n\\n```mermaid\\nflowchart LR\\n    BAT[\\\"deploy-dev.bat\\\"] --&gt; PS1[\\\"deploy-dev.ps1\\\"]\\n    PS1 --&gt;|local Docker| DOCKER[\\\"Build-ImageLocal\\\"]\\n    PS1 --&gt;|-CloudBuild| CLOUD[\\\"Build-ImageCloud / ACR\\\"]\\n    DOCKER --&gt; ACR[\\\"ACR crawdevaue\\\"]\\n    CLOUD --&gt; ACR\\n    ACR --&gt; API[\\\"ca-aw-api-dev-aue\\\"]\\n    ACR --&gt; WEB[\\\"ca-aw-web-dev-aue\\\"]\\n    KV[(\\\"kv-aw-dev-aue\\\")] --&gt; API\\n    KV --&gt; WEB\\n    API --&gt; SMOKE[\\\"Invoke-Smoke\\\"]\\n    WEB --&gt; SMOKE\\n```\\n\\n`deploy-dev.ps1` is the orchestrator. Its key functions:\\n\\n- **`Ensure-LoggedIn`** \u2014 verifies the Azure CLI is authenticated against the organisation tenant (`e0045fa5-...`) and sets the sponsorship subscription (`17065bb7-...`). Opens a browser login when needed.\\n- **`Build-ImageLocal`** / **`Build-ImageCloud`** \u2014 build `assureworx-api` from `backend/Dockerfile` and `assureworx-web` from `frontend/Dockerfile`. Local builds push to ACR; cloud builds use `az acr build` and wait on the resulting ACR run via `Wait-AcrRun`. Both stamp the image with build args `DEPLOY_COMMIT`, `DEPLOY_BUILT_AT`, and `DEPLOY_DIRTY`.\\n- **`Deploy-App`** \u2014 updates the Container App with the new image, a unique revision suffix, and deployment provenance env vars (`DEPLOY_COMMIT`, `DEPLOY_PROMOTABLE`, etc.). For the API it then applies the environment posture (Entra-only by default; `-EnableDevAuth` is an explicit founder-demo exception that wires `ALLOW_DEV_AUTH` + `DEV_AUTH_SECRET` from Key Vault). For the web app it sets `API_ORIGIN`.\\n- **`Sync-RedisUrlToApi`**, **`Sync-AcsEmailToApi`**, **`Sync-WalletSecretsToApi`**, **`Sync-AppInsightsToApi`** \u2014 copy the corresponding Key Vault secrets onto the API Container App as secretrefs, so secrets live in Key Vault and are referenced rather than embedded.\\n- **`Ensure-DevSiteGateSecrets`** + **`Sync-DevSiteGateToWeb`** \u2014 ensure `DEV-SITE-BASIC-USER` / `DEV-SITE-BASIC-PASSWORD` exist in Key Vault (generating a URL-safe random password on first run) and wire them onto the web app so Chrome Basic Auth gates the DEV site.\\n- **`Invoke-Smoke`** \u2014 after a 20-second settle, checks API `/health` (including commit match), the Entra auth posture endpoint, and the web app (401 without Basic Auth when the site gate is on, 200 with it). Uses `curl.exe` rather than `Invoke-WebRequest` to avoid Basic Auth caching false passes.\\n\\n`deploy-dev.bat` is a thin, double-clickable wrapper that forwards arguments (`Api`, `Web`, `All`, `cloud`) to the PowerShell script and pauses on failure.\\n\\n### TEST deployment\\n\\n`deploy-test.ps1` mirrors the DEV flow against `rg-assureworx-test-aue` (ACR `crawtestaue`, apps `ca-aw-api-test-aue` / `ca-aw-web-test-aue`, vault `kv-aw-test-aue`). Differences from DEV:\\n\\n- Image tag is always `test`, not a commit SHA.\\n- `ASSUREWORX_ENV=test` is forced and `ALLOW_DEV_AUTH` is explicitly removed \u2014 TEST is production-like.\\n- A fixed set of secrets (`DATABASE-URL`, `SESSION-SECRET`, `REDIS-URL`, Entra settings, ACS settings, etc.) is synced via the generic `Sync-SecretToApi` helper.\\n- Ingress target ports are forced to `4000` (API) and `3000` (web) after update.\\n- Smoke checks are simpler: API `/health` plus an unauthenticated `GET /auth/login`.\\n\\n### Custom domain binding\\n\\n`bind-dev-domains.ps1` and `bind-test-domains.ps1` bind the Assureworx hostnames (e.g. `dev.assureworx.com.au`, `api.dev.assureworx.com.au`) to the Azure Container Apps and cut over environment configuration:\\n\\n1. **`Assert-Dns`** \u2014 resolves the expected CNAMEs (`dev.assureworx.com.au` \u2192 the `*.azurecontainerapps.io` FQDN) and the `asuid.` domain-verification TXT records. Aborts with instructions if DNS is not ready.\\n2. **`az containerapp hostname bind`** \u2014 binds each hostname with CNAME validation.\\n3. **Env cutover** (skippable with `-SkipEnvCutover`) \u2014 writes `ENTRA-REDIRECT-URI` into Key Vault, sets the API containerapp secret, then updates env vars: `PUBLIC_APP_URL`, `CORS_ORIGIN`, `ENTRA_REDIRECT_URI=secretref:entra-redirect-uri`. TEST also sets `AUTH_JIT_PROVISION=0` and an `AUTH_EMAIL_ALLOWLIST` of `tech@assureworx.com.au`.\\n\\n## Entra External ID (CIAM) configuration\\n\\nA family of PowerShell scripts configures the Entra External ID (CIAM) tenants. They share a strict requirement: you must first log into the CIAM tenant with `az login --tenant  --allow-no-subscriptions`, because the CIAM tenant is a separate directory from the Azure sponsorship subscription.\\n\\n| Tenant | CIAM tenant ID | CIAM login domain | App ID |\\n|---|---|---|---|\\n| DEV | `0286fa4b-f51a-4a2c-9dfc-f7f6ca4c4513` | `assureworxdev1.ciamlogin.com` | `1f8042c7-0775-486c-9729-81055364242d` |\\n| TEST | `37fe079d-2892-4140-9de6-a744aba5beab` | `assureworxtest1.ciamlogin.com` | created by `entra-app-reg-test.ps1` |\\n\\n### App registration\\n\\n- `entra-app-reg.ps1` creates (or resets) the \\\"Assureworx Web DEV\\\" app with `AzureADMyOrg` sign-in audience, web redirect URIs for `localhost:3000` and the Azure FQDN, and ID token issuance. It creates the service principal, resets a client secret, prints the Entra env vars, and with `-PushToKeyVault` writes them to `kv-aw-dev-aue`.\\n- `entra-app-reg-test.ps1` is the TEST equivalent, including the `beta.assureworx.com.au` redirect URI and pushing to `kv-aw-test-aue`.\\n\\nThe scripts exit with a `Write-Error` if the active `az` tenant is not the expected CIAM tenant.\\n\\n### Native authentication\\n\\n`enable-ciam-native-auth.ps1` patches the app registration with `isFallbackPublicClient: true` and `nativeAuthenticationApisEnabled: \\\"all\\\"`, then ensures the Microsoft Graph delegated scopes (`openid`, `profile`, `email`, `offline_access`, `User.Read`) are in `requiredResourceAccess`, and creates or updates an `AllPrincipals` OAuth2 permission grant. The admin-consent grant is the fix for `AADSTS65001` / \\\"Email or password is incorrect\\\" on password sign-in. The script ends by reminding that Email OTP for SSPR is a portal step (Graph policy APIs are typically forbidden for this role).\\n\\n### Branded OTP email\\n\\n`enable-ciam-otp-email.ps1` wires CIAM's `OnOtpSend` event to the Express API endpoint `POST {ApiBaseUrl}/api/v1/auth/entra/otp-email`, so OTP emails are sent through Azure Communication Services with Assureworx branding instead of Microsoft's generic template. It:\\n\\n1. Creates/reuses an extension app registration and gives it an `api://` identifier URI \u2014 Entra will authenticate its call to the API with a bearer token for that audience.\\n2. Registers a `#microsoft.graph.OnOtpSendCustomExtension` (or reuses one by display name).\\n3. Registers an `onEmailOtpSendListener` scoped to the Assureworx web app.\\n4. Writes `ENTRA_OTP_EXTENSION_AUDIENCE` onto the DEV API Container App so the API can validate the incoming token.\\n\\nBecause Graph often lacks permission to create the extension under CIAM admin roles, the script prints exact portal steps and exits cleanly rather than failing when Graph rejects the POST.\\n\\n### MFA policy\\n\\n`enable-ciam-mfa-dev.ps1` creates or updates an enabled Conditional Access policy named \\\"Assureworx DEV \u2014 require MFA for Web app\\\", targeting the DEV enterprise application, all users, and all client app types, with `builtInControls: [\\\"mfa\\\"]`. It writes the policy body to a temp JSON file and uses `az rest` against the Graph v1.0 conditional access endpoint. It also prints the prerequisite portal step for enabling Email OTP as an authentication method (needed for Native Auth password reset).\\n\\n### Tenant branding\\n\\n`apply-ciam-branding.ps1` patches the `en-US` branding localization for the CIAM tenant with:\\n\\n- Dark theme colours (`#171A20` background/header)\\n- Sign-in page copy (\\\"Assureworx | Verify once. Trust everywhere.\\\")\\n- A default layout\\n\\nWith `-SkipLogos` absent, it resizes `frontend/public/logo-dark.png` (banner/header), `logo.png` (square), and `fav.png` (favicon) to the exact Entra size/byte limits using `System.Drawing`, then uploads them via Graph. The `Resize-UnderLimit` helper iteratively shrinks each image by 15% until it fits the byte budget.\\n\\n## Database lifecycle\\n\\nAll Node.js database scripts import `pgSslOptions` from `pg-ssl.mjs`, which derives the correct `ssl` option from the `DATABASE_URL` connection string (e.g. disabling TLS for localhost, requiring it for managed Postgres).\\n\\n### Migrations\\n\\n- `apply-004.mjs` reads `db/migrations/004_account_invites.sql`, applies it inside a `BEGIN`/`COMMIT` transaction, records the version in `public.schema_migrations`, and rolls back on error. It requires `DATABASE_URL` and exits non-zero on failure.\\n- `migrate.mjs` is the general migration runner used for the rest of the migration sequence.\\n\\n### Admin bootstrap\\n\\n`bootstrap-platform-admin.mjs` creates a single platform-admin actor that Entra can link by email \u2014 explicitly not the developer-seed path. It takes an email as the first CLI argument, bypasses RLS (`set_config('app.bypass_rls', 'on', true)`), and inside one transaction:\\n\\n1. Creates a platform party (`K01` / `E01`) and a person party (`K03` / `M01`).\\n2. Adds an email identifier (`D03`).\\n3. Creates an actor with a provisional subject `pending|` \u2014 the first Entra sign-in rewrites this to the real OID/sub.\\n4. Grants the `R01` (platform admin) role and creates a 10-year ledger plan.\\n\\nIt is idempotent: if an actor with that email already exists, it prints the existing actor ID and exits 0.\\n\\n### Data catalogue\\n\\n`gen-data-catalogue.mjs` emits the data classification catalogue. It declares classification constants (`C`, `CIC`, `PII`, `SPII`, `GP`) and lists every table/column with its class and an annotation. The legacy `public.*` tables carry a \\\"LEGACY \u2026 superseded by core.*\\\" note; newer `core.*` tables have fine-grained per-column notes (including JSONB subpaths like `attrs.geo.lat` and classification rationale such as \\\"PII if residential\\\").\\n\\n### Test data reset\\n\\n`reset-test-identity.mjs` (with `wipeDatabase`, `wipeEntra`, `purgeParty`, `softDeleteSql`, and `azBin` helpers) wipes both the database and the Entra side of a test identity, and `wipe-test-app-data.mjs` clears TEST application data. These are used to reset demo/tester accounts to a known state.\\n\\n## ISMS document generation\\n\\n`build-isms-starter-docs.py` generates the controlled ISMS Word deliverable pack from Markdown sources in `isms/`. It is a self-contained document pipeline:\\n\\n- **`configure_styles`** \u2014 sets up \\\"business\\\" and \\\"compact\\\" presets: fonts (Segoe UI), heading colours/sizes, list styles, and a custom \\\"Review Note\\\" paragraph style.\\n- **`set_page_and_furniture`** \u2014 forces A4 portrait, Australian header/footer margins, inserts the logo from `Raw/logo.png`, and adds a footer with document ID, version, status, and a live `PAGE` field.\\n- **`add_markdown`** \u2014 the Markdown-to-Word renderer. It handles headings (skipping the front-matter `---` block and H1s unless `include_h1` is set), tables, blockquote notes, checkboxes (`- [ ]`), bullets, numbered lists, `` directives, and inline `**bold**` / `` `code` `` / `[label](url)` tokens. Table column widths are inferred heuristically from header names via `column_widths`.\\n- **`build_document`** \u2014 ties it together: styles \u2192 furniture \u2192 title block \u2192 one or more Markdown sources, then saves to `deliverables/Assureworx_ISMS/...`.\\n\\nThe `main()` function produces six outputs, including the shareholder review pack, the 10-point implementation plan, information-security and data-classification policies, the NDA (flagged `legal_review=True`), and the personnel-security pack. Word's native numbering is restarted per Markdown ordinal via `new_decimal_numbering` so long lists survive page breaks.\\n\\n## Local developer utilities\\n\\n- `free-ports.ps1` \u2014 kills whatever is listening on the given ports (e.g. `free-ports.ps1 3000 4000`), retrying up to three times. Useful after crashed `npm run dev` processes.\\n- `force-shutdown-10min.bat` / `cancel-shutdown.bat` \u2014 schedule/cancel a forced Windows shutdown; a convenience for ending a dev session without hunting for orphaned processes.\\n- `provision-pc.mjs` \u2014 machine provisioning bootstrap (uses `pgSslOptions` and an `arg` helper).\\n- `slice-tradie-icons.py` \u2014 processes trade-icon packs: `slice_pack` \u2192 `key_out_green` / `trim_and_pad`, with `is_chroma_green` chroma-key detection.\\n- `isms-progress.mjs` \u2014 prints ISMS task progress using the `bar` helper.\\n\\n## Shared infrastructure and conventions\\n\\n### Key Vault as the secret source of truth\\n\\nSecrets live in `kv-aw-dev-aue` and `kv-aw-test-aue`; deployment scripts read them with `Get-KvSecret` and write them onto Container Apps as `secretref:`-based env vars. The known secret names are:\\n\\n| Group | Secret names |\\n|---|---|\\n| Entra | `ENTRA-TENANT-ID`, `ENTRA-CLIENT-ID`, `ENTRA-CLIENT-SECRET`, `ENTRA-AUTHORITY`, `ENTRA-REDIRECT-URI` |\\n| Data | `DATABASE-URL`, `SESSION-SECRET`, `REDIS-URL` |\\n| Telemetry | `APPLICATIONINSIGHTS-CONNECTION-STRING` |\\n| ACS email | `ACS-CONNECTION-STRING`, `ACS-EMAIL-SENDER` |\\n| Apple Wallet | `WALLET-APPLE-PASS-TYPE-ID`, `WALLET-APPLE-TEAM-ID`, `WALLET-APPLE-WWDR-PEM`, `WALLET-APPLE-SIGNER-CERT-PEM`, `WALLET-APPLE-SIGNER-KEY-PEM`, `WALLET-PASS-ID-SECRET` |\\n| DEV gate/auth | `DEV-SITE-BASIC-USER`, `DEV-SITE-BASIC-PASSWORD`, `DEV-AUTH-SECRET` |\\n\\n### Error-handling patterns\\n\\nThe PowerShell scripts consistently set `$ErrorActionPreference = \\\"Stop\\\"` but are careful around Azure CLI because `az` writes WARNING lines to stderr, which would otherwise become terminating `NativeCommandError`s under `Stop`. The established pattern is to temporarily switch to `Continue` around `az` invocations and inspect `$LASTEXITCODE` instead (see `Assert-Docker`, `Get-KvSecret`, and the ingress-port update in `deploy-test.ps1`).\\n\\n### Idempotency\\n\\nMost scripts are safe to re-run: hostname binds succeed if already bound, branding patches in place, Conditional Access policies are found by display name before creation, Key Vault secrets are only generated when absent, and `bootstrap-platform-admin.mjs` exits early when the actor exists.\\n\\n## Relationship to the codebase\\n\\nThe scripts sit at the edge of the repository, bridging source code and Azure:\\n\\n- **`backend/Dockerfile`** and **`frontend/Dockerfile`** are the build inputs for `deploy-dev.ps1` / `deploy-test.ps1`. The web build receives `API_ORIGIN` as a build arg.\\n- **`db/migrations/*.sql`** are consumed by `apply-004.mjs` / `migrate.mjs`; running the full migration history is what makes `core.*` and `public.*` schemas exist for `gen-data-catalogue.mjs` and the DB lifecycle scripts.\\n- **`frontend/public/logo-dark.png`, `logo.png`, `fav.png`** feed `apply-ciam-branding.ps1`.\\n- **`isms/**/*.md`** are the controlled sources for `build-isms-starter-docs.py`; outputs go to **`deliverables/Assureworx_ISMS/`**.\\n- **`wiki/runbooks/`** contains the operational runbooks referenced by the domain-binding and CIAM scripts (e.g. `dev-custom-domains.md`, `azure-test-deploy.md`, `entra-external-id-dev.md`).\\n- The API's `/health` and `/api/v1/auth/entra/status` endpoints are smoke-test contracts relied on by `Invoke-Smoke`; the OTP email extension target `/api/v1/auth/entra/otp-email` is a backend callback surface configured by `enable-ciam-otp-email.ps1`.\",\"other-untitled-base\":\"# Other \u2014 Untitled.base\\n\\n# Other \u2014 Untitled.base Module\\n\\n## Overview\\n\\n`Untitled.base` is a placeholder module with no current implementation. The module contains no source code, exports no symbols, and has no registered relationships with any other part of the codebase \u2014 it does not call other modules, is not called by other modules, and is not part of any execution flow.\\n\\nAt present, this module is an empty shell. It does not contribute any functionality, types, or side effects to the wider system.\\n\\n## Current State\\n\\n| Aspect | Status |\\n|---|---|\\n| Source code | None |\\n| Public API | None |\\n| Internal calls | None |\\n| Outgoing calls | None |\\n| Incoming calls | None |\\n| Execution flows | None |\\n\\n## Integration\\n\\nThis module has no integration points with the rest of the codebase. There are no imports to resolve, no dependencies to satisfy, and no extension points that reference it. Because nothing references `Untitled.base`, removing the module would have no observable effect on the system.\\n\\nIf the module was intended to be populated, it will only become part of the codebase's architecture once it exports something that other modules import, or once it begins importing from elsewhere.\\n\\n## Intent and Expected Direction\\n\\nThe name `Untitled.base` suggests the module was created as a starting point for a new component \u2014 likely a base class, foundation layer, or core library \u2014 but the actual implementation was never written. Developers encountering this module have two options:\\n\\n1. **Implement the module** if the intent is still to build the foundation that the name implies. In that case, begin by defining the public API surface, then wire up imports/exports and register any calls in the existing call graph.\\n2. **Remove the module** if it is a stale artifact. Since it has no dependencies or dependents, deletion is safe and will not break any existing behavior.\\n\\n## Contributing Notes\\n\\nIf you plan to implement `Untitled.base`, consider the following before adding code:\\n\\n- **Define the contract first** \u2014 what functions or classes will this module expose, and what consumers are expected to use them?\\n- **Establish relationships deliberately** \u2014 any incoming or outgoing calls should be justified and documented so the module does not remain an isolated island.\\n- **Update the module documentation** \u2014 once implementation lands, this page should be rewritten to describe the actual API, architecture, and integration points rather than its empty state.\\n\\nUntil such changes are made, treat `Untitled.base` as a non-functional placeholder: it is safe to ignore, safe to remove, and has no bearing on the behavior of the rest of the codebase.\",\"other-wiki-architecture\":\"# Other \u2014 wiki-architecture\\n\\n# wiki/architecture \u2014 Architecture Knowledge Base\\n\\n## Purpose\\n\\n`wiki/architecture/` is the system-of-record for **how Assureworx is built, deployed, secured, and operated**. It is a documentation module, not application code: eight Markdown pages that give a developer an accurate mental model of the platform before touching source, and that capture live-state facts and design intent that are not obvious from reading the repository.\\n\\nThe module sits inside the monorepo knowledge wiki alongside:\\n\\n- `wiki/decisions/` \u2014 ADRs (ADR-0001 through ADR-0032 are referenced throughout)\\n- `wiki/runbooks/` \u2014 operational procedures (Azure deploys, domain binding, Entra setup, monitoring)\\n- `isms/` \u2014 ISO 27001/SOC 2 management system (policies, data inventory, supplier postures)\\n\\nEvery page carries YAML frontmatter (`type: architecture`, `status`, `updated`, `tags`) and cross-links outward to those sibling modules. `AGENTS.md` at the repository root defines the wiki maintenance schema that governs edits.\\n\\n## Module layout\\n\\n| Page | Status | Updated | Answers |\\n| --- | --- | --- | --- |\\n| `code-map.md` | current | 2026-08-01 | Where does code live? Repo layout, path index, backend module boundaries, dev commands |\\n| `backend.md` | current | 2026-07-20 | How is the API structured? Module file pattern, middleware order, response envelope, background jobs |\\n| `data-model.md` | current | 2026-07-30 | What is the logical DB shape? Party model, conventions, RLS summary |\\n| `document-extraction.md` | current | 2026-08-08 | How are compliance documents read and validated? Tiers, rule packs, cloud tier, cost controls |\\n| `data-classification-catalogue.md` | current | 2026-08-08 | What data do we hold and how sensitive is each element? Column + endpoint catalogue |\\n| `azure-dev-inventory.md` | current | 2026-08-04 | What is actually running in Azure DEV? Resources, tenancy, security/cost posture |\\n| `azure-test-inventory.md` | current | 2026-08-01 | Same for Azure TEST \u2014 a fully separate cheap twin of DEV |\\n| `cicd-target-operating-model.md` | draft | 2026-08-01 | How CI/CD *should* work. Explicitly a design for approval, **not** evidence that the pipeline exists |\\n\\n`status: draft` is significant: the CI/CD page is the only draft and says so itself. Treat it as a proposal, while the inventories and data pages are claims about the current system.\\n\\n## The architecture as documented\\n\\nAcross the pages, the module records a consistent picture of the platform.\\n\\n### Repository and deploy topology\\n\\n`code-map.md` describes a **modular monorepo** with two separate deployables and one shared contract package:\\n\\n```\\nfrontend/               Next.js App Router (CRM UI) \u2014 deployable\\nbackend/                Express.js 5 + TypeScript modular monolith \u2014 deployable\\npackages/shared-types/  API envelope + shared TS types\\ndb/migrations/          SQL migrations (source of truth for Postgres)\\nscripts/                launchers, deploy, migrate, ISMS helpers\\nwiki/                   this knowledge wiki\\nisms/                   ISO 27001 / SOC 2 management system\\n```\\n\\nThe backend is a **modular monolith**: each feature module owns its routes, service, and repository so it can later be extracted to its own Container App without rewriting domain logic. Frontend and backend are deployed as separate Azure Container Apps; the frontend proxies `/api/v1/*` to the API (BFF session-auth pattern, ADR-0008).\\n\\n```mermaid\\nflowchart LR\\n    ACR[\\\"crawdevaue (ACR)\\\"] --&gt; WEB[\\\"ca-aw-web-dev-aue (Next.js)\\\"]\\n    ACR --&gt; API[\\\"ca-aw-api-dev-aue (Express /api/v1)\\\"]\\n    WEB -- \\\"BFF proxy /api/v1\\\" --&gt; API\\n    API --&gt; PG[(\\\"psql-aw-dev-aue (Postgres: core.* + meta.*)\\\")]\\n    API --&gt; KV[\\\"kv-aw-dev-aue (secrets)\\\"]\\n    API --&gt; RD[(\\\"redis-aw-dev-aue (BullMQ + rate limits)\\\")]\\n    API --&gt; DI[\\\"di-aw-dev-aue (Doc Intelligence tier 3)\\\"]\\n    API --&gt; ST[\\\"stawdevaue (artifact blobs)\\\"]\\n    API --&gt; ID[\\\"assureworxdev1 (Entra External ID CIAM)\\\"]\\n```\\n\\n*DEV topology per `azure-dev-inventory.md` and `code-map.md`. TEST is a separate resource group (`rg-assureworx-test-aue`) with its own twin of every service.*\\n\\n### Backend architecture\\n\\n`backend.md` defines the per-module file pattern that `code-map.md` confirms is implemented:\\n\\n| File | Responsibility |\\n| --- | --- |\\n| `routes.ts` | Express router + middleware attachment \u2014 zero business logic |\\n| `controller.ts` | Request/response handling; calls service; returns envelope |\\n| `validation.ts` | Zod schemas for all inputs |\\n| `service.ts` | Business logic, orchestration, audit-log calls |\\n| `repository.ts` | All DB queries \u2014 **`tenant_id` enforced here**, not in routes |\\n| `types.ts` | TypeScript interfaces for the domain |\\n\\nMiddleware order matters on business-logic routes: **`require-auth` \u2192 `resolve-tenant-context` \u2192 `require-subscription`** \u2192 handler. This ordering lets suspended users still authenticate and view status. All responses use a consistent envelope:\\n\\n```json\\n{ \\\"success\\\": true, \\\"data\\\": {}, \\\"meta\\\": { \\\"page\\\": 1, \\\"total\\\": 100 } }\\n{ \\\"success\\\": false, \\\"error\\\": { \\\"code\\\": \\\"DOCUMENT_EXPIRED\\\", \\\"message\\\": \\\"...\\\", \\\"details\\\": [] } }\\n```\\n\\nBackground work runs on BullMQ over Redis (`redis-aw-dev-aue`, Azure Managed Redis `Balanced_B0`, TLS port 10000). Documented jobs: `document.expiry.check`, `document.verify.api`, `notification.send.email`, `training.overdue.check`, `subscription.expiry.check`, `kyc.result.webhook`, `report.generate`. The `azure-dev-inventory.md` page additionally records the Redis job set in production use: `email.send`, `sms.send`, `artifact.extract`, `geocode.site`, `payment.event`, `payment.reconcile`, plus rate-limit keys, membership cache, and a DLQ.\\n\\n### Data model and security\\n\\n`data-model.md` describes the logical model; the physical schema is deliberately **obfuscated** (ADR-0007). Physical names live in `wiki/architecture/schema-mapping.md`, mirrored in-DB by `meta.map_dict` / `meta.code_dict`. Key points:\\n\\n- One unified party table `core.t_party` for Tier 1 orgs, trade orgs, and individuals, carrying the 4-state Verified lifecycle.\\n- UUID PKs, `TIMESTAMPTZ`, soft delete via `deleted_at`, partial unique indexes (`WHERE deleted_at IS NULL`), coded status/kind columns (`*_cd`), `attrs JSONB` extension bags.\\n- Core entities: `t_party_ident` (ABN/ACN/email), `t_actor` (login), `t_grant` (membership/role), `t_rel` (employment/network), `t_node` (project/site), `t_link` (engagement readiness), `t_request` (onboarding chain), `t_artifact` / `t_artifact_class` (evidence), `t_check` (verification), `t_event` (append-only audit), `t_idem` (idempotency).\\n- RLS is defence-in-depth: runtime role `app_rt`, tenant scope via `SET LOCAL app.scope`, explicit audited `app.bypass` for Platform Admin. The repository layer remains the primary enforcement (ADR-0003).\\n\\n### Document extraction\\n\\n`document-extraction.md` is the deepest single page. It documents a **tiered extraction ladder** that turns an uploaded compliance document into proposed form fields and an auto-accept/review decision:\\n\\n| Tier | Text source | Where it runs | Status |\\n| --- | --- | --- | --- |\\n| 1 | PDF text layer (`unpdf`, bundled PDF.js) | In the request | Built |\\n| 2 | Local raster OCR | Needs a job runner | Not implemented |\\n| 3 | Azure AI Document Intelligence `prebuilt-read` | Preview request or `artifact.extract` job | Built |\\n\\nRule packs in `backend/src/shared/services/extraction/rules.ts` know one family of Australian compliance document each (Insurance A004/A005/A110, Licence A101\u2013A109/A111/A115, Certification A001/A112\u2013A114/A116, ABN A003, Generic). Primitive parsers in `parse.ts` handle dates (day-first, ambiguity scoring), money, ABN/ACN modulus checks, and fuzzy name matching. Validation in `validate.ts` produces ordered findings (`EXPIRED`, `COVER_BELOW_MINIMUM`, `CLASS_MISMATCH`, plus warnings/info) and a verdict of `clean` / `check` / `hold`.\\n\\nThe cloud tier (`backend/src/shared/services/extraction/cloud-read.ts`) is notable for its cost discipline:\\n\\n- Model id is a hard-coded `const` because the model id *is* the meter \u2014 `prebuilt-read` bills US$1.50/1,000 pages vs US$10 for `prebuilt-layout` and US$200 for query fields.\\n- `disableLocalAuth: true`; the API's system identity holds **Cognitive Services User**.\\n- Five independent cost caps: `ARTIFACT_MAX_PAGES` (10), `DOC_INTEL_MAX_PAGES` (4), `DOC_INTEL_MONTHLY_PAGE_CAP` (2,000, reserved in `core.t_meter` via `reserveUnits` / `releaseUnits`), `DOC_INTEL_PARTY_DAILY_PAGES` (30, read from the audit log), `DOC_INTEL_MAX_CONCURRENT` (4, in-process semaphore).\\n- Every billable read writes an `extraction.cloud_read` audit event with **no document content**.\\n\\nExtraction is advisory except one hard reject: expired + high confidence (`isExpiredHighConfidenceReject`) fails upload with `DOCUMENT_EXPIRED`. Photo ID (A002) is rejected with `IDENTITY_VIA_PROVIDER` \u2014 identity is a Didit check, never an uploadable artifact.\\n\\n### Environments and live state\\n\\nThe two inventory pages record the *actual* Azure estate. Both live in the `Assureworx - Startup Sponsorship` subscription (`17065bb7-1e80-4ef0-a043-4302a7f37af8`), Australia East.\\n\\n- **DEV** (`rg-assureworx-dev-aue`): the working environment. Container Apps for API + web, Postgres Flexible Server, ACR `crawdevaue`, Key Vault, Azure Managed Redis, Document Intelligence, App Insights, Log Analytics, ACS Email, and Entra External ID `assureworxdev1.onmicrosoft.com`. Security posture includes scale-to-zero (`minReplicas=0`, max 2), 1 GB/day App Insights cap, `ALLOW_DEV_AUTH` forbidden in Azure (boot throws when set), blob versioning/soft-delete/change-feed on, Key Vault purge protection on, Defender for Storage on-upload malware scanning, Document Intelligence local auth disabled. Cost budgets are **blocked** by Startup Sponsorship RBAC, so billing control relies on the App Insights cap and replica alerts.\\n- **TEST** (`rg-assureworx-test-aue`): provisioned 2026-08-01 as a cheap, fully separate twin of DEV \u2014 separate Postgres (`Standard_B1ms`), Redis, Key Vault, ACR, Doc Intelligence, App Insights, and CIAM directory `assureworxtest1.onmicrosoft.com`. `ASSUREWORX_ENV=test`, `ALLOW_DEV_AUTH` unset. Note the CIAM caveat: Azure required `location: United States` + `countryCode: US` to create the TEST directory; the application data plane remains Australia East.\\n\\n### CI/CD: target vs current\\n\\n`cicd-target-operating-model.md` is the draft proposal for moving from PC-originated deploys to controlled GitHub + Azure promotion. Its central rule is **build once, promote the same immutable artifact**: DEV, TEST and PROD receive the same image digest; environment-specific configuration comes from separate Azure resources and Key Vaults. The documented branching model is a protected, always-releasable `main` with short-lived `feature/*`, `fix/*`, and `hotfix/*` branches.\\n\\nThe page's \\\"current state versus target\\\" table is candid: as of its writing, GitHub Actions CI exists in the clean baseline (`.github/workflows/ci.yml`), deploy is a manual PowerShell script (`scripts/deploy-dev.ps1`), and there is no IaC. It also lists open decisions \u2014 GitHub as provider, named TEST/PROD approvers, separate subscriptions vs resource groups, and Bicep vs Terraform.\\n\\n## How the pages connect\\n\\nThe module is a web, not a sequence. The main reading paths a developer will follow:\\n\\n- **\\\"Where is the code?\\\"** \u2192 `code-map.md`, which is the entry point; it links to `backend.md`, `schema-mapping.md`, and ADR-0007.\\n- **\\\"How does the API work?\\\"** \u2192 `backend.md`, which links to `data-model.md`, `security.md`, `integrations.md`, `frontend.md`.\\n- **\\\"What does the database look like?\\\"** \u2192 `data-model.md`, which defers physical names to `schema-mapping.md` and security details to `security.md`.\\n- **\\\"What data do we hold and how sensitive?\\\"** \u2192 `data-classification-catalogue.md` (the authoritative per-column/per-endpoint inventory), which links to the ISMS data inventory and POL 002 policy.\\n- **\\\"What is actually deployed?\\\"** \u2192 `azure-dev-inventory.md` / `azure-test-inventory.md`, which link to the deploy runbooks and ADR-0001.\\n- **\\\"How do we get to PROD?\\\"** \u2192 `cicd-target-operating-model.md`, which cross-checks against the DEV inventory and the manual deploy runbook.\\n\\nOutward links are dense and deliberate: ADRs (0001, 0007, 0008, 0014, 0019, 0020, 0021, 0022, 0026, 0027, 0028, 0029, 0030, 0032), runbooks (`azure-dev-deploy.md`, `dev-custom-domains.md`, `acs-email-dev.md`, `entra-monitoring-and-cost-dev.md`, `entra-external-id-dev.md`, `azure-test-deploy.md`), and ISMS documents (`environments.md`, `policies/data-classification-and-handling-policy.md`, `data-inventory.md`, `didit-posture.md`, `doc-intel-posture.md`).\\n\\n## Relationship to the codebase\\n\\nThe wiki pages are not documentation of a separate system \u2014 they index and explain the monorepo directly. The main mappings:\\n\\n| Documentation claim | Source of truth in code |\\n| --- | --- |\\n| Module boundaries and responsibilities | `backend/src/modules/*` (auth, parties, nodes, links, requests, artifacts, checks, notifications, \u2026); router registration in `backend/src/app.ts` |\\n| Per-module file pattern | `backend/src/modules/_module.template.md` |\\n| Physical table names | `backend/src/shared/schema/tables.ts`; `db/migrations/` is the DB source of truth |\\n| RLS / transaction scoping | `backend/src/shared/db/pool.ts` \u2014 `withScope` (RLS `app.scope`) and `withBypass` transactions |\\n| Code translation | `backend/src/shared/codes.ts` \u2014 codes never leave repositories |\\n| Session cookies | `backend/src/shared/auth/session.ts` (HS256, `__Host-awx_session`) |\\n| Extraction pipeline | `backend/src/shared/services/extraction/` \u2014 `sources.ts` (ladder &amp; escalation), `rules.ts`, `parse.ts`, `validate.ts`, `cloud-read.ts`, `preprocess.ts`, `preview-cache.ts` |\\n| Spend metering shared with Didit | `backend/src/shared/services/meter.ts` (`readMeter`, `reserveUnits`, `releaseUnits`) |\\n| Synthetic extraction corpus | `backend/scripts/probe-extraction.ts` (offline) and `backend/scripts/probe-cloud-read.ts` (live) |\\n| Seed data | `backend/src/seed.ts` \u2014 idempotent Campbell Constructions network |\\n\\nThe documented dev commands are real and current: `npm install` (root workspaces), `npm run dev:frontend` (:3000), `npm run dev:backend` (:4000), `scripts/start-dev.bat` (Windows local launcher), `scripts/deploy-dev.bat` (wrapper around `scripts/deploy-dev.ps1` for Azure DEV).\\n\\nThree \\\"rules\\\" from `code-map.md` encode architectural invariants a contributor must respect:\\n\\n1. No domain table names in business code \u2014 use `backend/src/shared/schema/tables.ts`.\\n2. Frontend never talks to Postgres; only `backend` `/api/v1`.\\n3. Migrations stay in `db/migrations/` and are applied by ops/agent \u2014 not at app boot.\\n4. New feature = new (or existing) module folder; register router in `app.ts`.\\n\\n## Maintenance protocol\\n\\nThe module has explicit maintenance obligations, strongest in `data-classification-catalogue.md`:\\n\\n&gt; Any new column, JSONB key, cookie, blob, email/wallet field, or API route **must** update this page in the same change. Agents: `.cursor/rules/data-classification-catalogue.mdc`.\\n\\nThe catalogue's protocol (\u00a77) requires updating in the same change when you: add/rename/drop a migration column; add a JSON key under `attrs` / `result` / `delta` holding personal or commercial data; add or change an API route in `backend/src/modules/**`; add cookies, tokens, blobs, email templates, wallet fields, or telemetry that may log PII; or integrate a new supplier. The checklist is: amend the relevant section \u2192 bump `updated:` \u2192 append to `wiki/log.md` \u2192 touch `isms/data-inventory.md` if SPII or a new personal category is involved.\\n\\nThe catalogue's classification levels (GP, CIC, C, PII, SPII) are the module's shared vocabulary; `data-model.md`, `document-extraction.md`, and the inventories all use them. A collection inherits the highest class of any element.\\n\\n## Known inconsistencies\\n\\nThe module is a living wiki with pages updated on different dates, and a few edges are rough. Worth knowing before you rely on a single page:\\n\\n- `backend.md` (updated 2026-07-20) says \\\"Azure App Service / Container Apps\\\" and \\\"Azure Cache for Redis\\\"; the inventories confirm **Container Apps only** and **Azure Managed Redis** (classic Cache for Redis creates are now blocked). Prefer the inventories for current hosting facts.\\n- `data-model.md` says the current deployed migration is `002_enterprise_core.sql`, while the TEST inventory records migrations through 025 and the classification catalogue cites evidence through 035. `data-model.md` lags the migration history.\\n- `cicd-target-operating-model.md`'s current-state table says \\\"TEST: No environment evidenced\\\" even though `azure-test-inventory.md` (same date, 2026-08-01) documents TEST as provisioned. The CI/CD page is a draft; the inventory is the record of live resources.\\n\\nWhen these conflict, the inventories and `data-classification-catalogue.md` (both more recent and explicitly maintained) are the better evidence base.\",\"other-wiki-decisions\":\"# Other \u2014 wiki-decisions\\n\\n# Other \u2014 Wiki Decisions (ADR Library)\\n\\n## Overview\\n\\n`wiki/decisions/` is Assureworx's Architecture Decision Record (ADR) library. Each file captures one significant architectural, commercial, or operational decision, along with its context, the alternatives considered, and its consequences. The library is the authoritative record of *why* the platform is built the way it is \u2014 the answers to \\\"why Azure?\\\", \\\"why repository-layer isolation?\\\", \\\"why Didit?\\\", \\\"why pay-per-verification?\\\" all live here.\\n\\nThis module contains no executable code. The call graph and execution-flow analysis confirm it is a static knowledge base: there are no runtime calls into or out of these files. Its integration surface is the hyperlink graph to the rest of the wiki (`architecture/`, `product/`, `runbooks/`, `isms/`) and the specific code locations named inside the decisions. Decisions routinely cite exact modules, functions, tables, migrations, and environment variables, and they impose binding obligations on implementation \u2014 a new migration, a lint rule, a Key Vault secret, a UI copy change.\\n\\nThe library is consumed by:\\n\\n- Engineers onboarding or changing the platform, who need to know which decision a piece of code implements.\\n- Code reviewers, who check new work against recorded decisions.\\n- Product and founder conversations, especially where commercial decisions (pricing, verification scope) are concerned.\\n\\n## How the library works\\n\\nEach ADR is a Markdown file named `NNNN-slug.md` (e.g., `0001-full-azure-stack.md`), with YAML frontmatter:\\n\\n| Field | Meaning |\\n| --- | --- |\\n| `type` | Always `decision`. |\\n| `status` | `proposed`, `accepted`, or `superseded`. |\\n| `updated` | ISO date of the last substantive change. |\\n| `sources` | Source documents the decision derives from (`assureworx-platform-spec`, `assureworx-product-definition`), or `[]` when the decision comes from internal review or a founder conversation. |\\n| `tags` | Kebab-case topic index (`azure`, `identity`, `pricing`, `compliance`, \u2026). |\\n\\nThe body follows a fixed shape: `## Context` (the problem and options), `## Decision` (the chosen path, usually numbered), `## Consequences` (positives `+`, negatives `\u2212`, open questions `\u2753`, `Action:` items), and `## Related` (links to other wiki pages and ADRs). Some records add `## Alternatives considered`, and several embed code snippets or API sketches (e.g., ADR-0009's verification-run endpoints, ADR-0018's `READINESS_BY_STATUS` mapping).\\n\\n### Status lifecycle and cross-reference vocabulary\\n\\nDecisions move through a small, consistent set of relationships:\\n\\n- **Proposed \u2192 accepted.** Most records start as `proposed` and become `accepted` once confirmed by review or the founder. The body's status line records who confirmed and when (e.g., \\\"confirmed by founder, 20 Jul 2026\\\").\\n- **Superseded.** A later decision fully replaces an earlier one. Both sides carry the pointer: ADR-0004 declares \\\"Superseded by ADR-0023\\\" in its status line, and ADR-0023 opens with \\\"Supersedes: ADR-0004 on the revenue mechanism.\\\"\\n- **Amended.** A later decision modifies one specific part of an earlier one while the rest stands. The forward pointer lives at the top of the amended record: ADR-0019 notes \\\"Decision 7 amended 29 Jul 2026 by ADR-0020\\\" and \\\"Auto-verify ban amended 8 Aug 2026 by ADR-0032\\\"; ADR-0021 notes \\\"Amended 2026-08-04: ADR-0028 adds transactional (non-OTP) SMS.\\\"\\n- **Implements.** A decision makes an earlier one concrete. ADR-0018 is the clearest example: it \\\"implements ADR-0005 layers 4\u20135, which were specified in July and never built.\\\"\\n- **Resolves / informs / partially addresses.** Decisions answer open questions from `open-questions.md` by Q-number (ADR-0014 \\\"Resolves Q1\\\", ADR-0015 \\\"Partially addresses Q8 and Q11\\\"). Decisions also file new questions: ADR-0012 opens Q24 and Q25, ADR-0018 opens Q30 and Q31.\\n\\n## Decision clusters\\n\\nThe 28+ decisions in this excerpt (the library continues past ADR-0028, at least through ADR-0032) group into six thematic clusters.\\n\\n### Infrastructure &amp; hosting\\n\\n- **ADR-0001 \u2014 Full Microsoft Azure stack (Australia East).** The foundation decision: all services in Australia East to satisfy data-residency obligations; Static Web Apps, App Service / Container Apps, Postgres Flexible Server, Entra External ID, Blob, Redis, Key Vault, Front Door, App Insights, GitHub Actions. Data residency must be verified per service before deployment.\\n- **ADR-0002 \u2014 Azure Database for PostgreSQL Flexible Server.** The single primary database. Establishes schema conventions: UUID PKs, soft delete via `deleted_at`, `TIMESTAMPTZ`, and RLS confined to the immutable `audit_log`.\\n- **ADR-0006 \u2014 Transactional email via Azure Communication Services.** Keeps email inside the Azure stack; requires SPF/DKIM/DMARC on the sender domain; delivery runs through the `notification.send.email` background job.\\n- **ADR-0020 \u2014 Azure AI Document Intelligence as extraction tier 3.** Provisions `prebuilt-read` (pinned in source at API version `2024-11-30`) with managed-identity auth and layered spend caps (`t_meter` reservations, per-party daily limits, in-flight semaphore). Amends ADR-0019 decision 7.\\n- **ADR-0021 / ADR-0028 \u2014 SMS.** ADR-0021 defers all SMS for Phase 1 DEV (Microsoft discontinued alphanumeric sender IDs to AU numbers; ACMA branded-ID registration is out of scope). ADR-0028 later adds *transactional* (non-OTP) SMS via Twilio with an outbox and BullMQ.\\n\\n### Identity, auth &amp; sessions\\n\\n- **ADR-0008 \u2014 BFF proxy + server-issued session cookie.** The browser only talks to the Next.js origin; `next.config.ts` rewrites `/api/v1/*` to the Express API. Express signs a 12-hour HS256 `awx_session` cookie containing identity only (`actorId`, IdP `sub`, `email`, `activeOrgId`). `require-auth.ts` validates it; `resolve-tenant-context.ts` reloads memberships from `t_grant` per request. Dev personas log in via `POST /api/v1/auth/login`.\\n- **ADR-0025 \u2014 Entra Native Authentication via BFF proxy.** Custom Assureworx screens for sign-up, sign-in, OTP, and password reset; `backend/src/shared/auth/native-auth.ts` proxies Native Auth (which does not support CORS). Continuation tokens live in short-lived httpOnly `awx_native_*` cookies. After sign-up, no session is minted \u2014 the user lands on `/auth/created` and proceeds to login. Hosted Entra remains only as a `challenge_type=redirect` fallback.\\n- **ADR-0013 \u2014 Login email change.** The `actor_id` is the durable principal; email change requires dual verification (old inbox, then new inbox) with hashed-at-rest tokens, one open request per actor, and Entra sync via Microsoft Graph when the subject is a real Entra `sub`. Schema: `core.t_email_change` (migration `007_email_change.sql`).\\n- **ADR-0012 \u2014 Assureworx ID and identity resolution ladder.** Adds an immutable public `public_ref` (`AWX-XXXX-XXXX`, Crockford base32) to `t_party`, and a resolution ladder: `D04` KYC subject reference (strongest), `D01` ABN, `D02` ACN, `D03` email (weakest \u2014 prompt, never auto-merge). Splits \\\"onboard\\\" from \\\"connect\\\" so an existing party gets a connection request, not a re-onboarding wizard. Currently `proposed`.\\n- **ADR-0014 / 0015 / 0026 \u2014 Identity provider and lifecycle.** ADR-0014 selects Didit for Phase 1 KYC, stores the *outcome only* (never identity media), and defines the `IdentityVerifier` interface whose `start()` takes a `consentId`. ADR-0015 makes re-verification trigger-driven (document expiry via `t_check.doc_expires_on`, 24-month consent expiry in `core.t_consent`, withdrawal destroys the outcome). ADR-0026 extends the boundary with a whitelisted diagnostics set in `t_check.diag` and a first-class `session_expires_at` column.\\n\\n### Verification &amp; compliance\\n\\n- **ADR-0005 \u2014 \\\"Assureworx Verified\\\" is a hybrid.** The pivotal product decision: a portable base-profile status (requirement layers 1\u20133: identity, ABN, licences, insurance, qualifications) plus a per-engagement readiness state (layers 4\u20135: PC/project/site requirements). A trade can be Verified yet not ready for a particular site.\\n- **ADR-0018 \u2014 Engagement-scoped requirements and computed readiness.** Implements layers 4\u20135 of ADR-0005. Adds `core.t_req_set` / `core.t_req_item` (requirement sets bound by `node_id` or `link_id` column scope) and `core.t_link_tag` (what the principal engaged the trade *for*). Readiness is computed as a union of requirements with the harshest block winning; `t_link.state_cd` is derived, never copied. A gap produces a `Q05` top-up request, not a refusal. Also adds `D05` (trade licence `jurisdiction:number`) as an identity anchor and makes the wallet pass a projection keyed on `public_ref` with `pass_ver` bumping.\\n- **ADR-0009 \u2014 Real-time verification orchestration.** Currently `proposed`. Would replace the client-side demo (`frontend/src/lib/verify-demo.ts`) with a server-owned Verification Run: `POST /api/v1/verification/runs`, per-check `t_check` rows, provider adapters (`IAbnVerifier`, `IKycProvider`, `ILicenceVerifier`), signed webhooks, and honest Phase 1 checks (licence is \\\"In review\\\", never a fake Passed).\\n- **ADR-0019 / 0020 \u2014 Document extraction over registry verification.** The compliance-evidence route for licences, insurance, and certificates is upload \u2192 extract \u2192 validate \u2192 human confirm, not registry APIs. Extraction is tiered (PDF text layer \u2192 local raster OCR \u2192 Azure Document Intelligence), with a confidence score (`scoreExtraction`, 0.7 escalation threshold) deciding when to escalate. Extraction proposes and never verifies: it cannot set `status_cd` or reach `F04`. Photo ID (A002) is never sent through OCR.\\n- **ADR-0022 \u2014 Invitation verification templates use immutable snapshots.** Each requester org owns a reusable template (`core.t_vfy_tpl`); sending a request copies the selected keys into `t_request.verification_keys`. Later template edits don't alter sent invitations. Existing portable profile results count toward the snapshot.\\n\\n### Data model, tenancy &amp; isolation\\n\\n- **ADR-0003 \u2014 Repository-layer multi-tenant isolation.** Every tenant-scoped query carries a `tier1_org_id` / `trade_org_id` filter applied in the repository layer (`repository.ts`), never in routes. Platform Admin bypass is explicit, flagged, and written to the immutable audit log. Tenant context is resolved once per request by `resolve-tenant-context.ts`.\\n- **ADR-0007 \u2014 Obfuscated, party-model physical schema.** Generic physical naming (`t_party`, `t_rel`, `t_node`, `t_link`, `t_artifact`, `t_check`, `t_event`; opaque codes like `K01`, `S02`, `G07`) with no domain terms in the database. Logical\u2194physical mapping is authoritative in `schema-mapping.md` and mirrored in `meta.map_dict` / `meta.code_dict`; the `app_rt` runtime role is revoked from `meta`. Business code uses generated TypeScript constants, never raw table names.\\n- **ADR-0016 \u2014 Job state is derived from the project.** A trade's active/completed jobs are computed from project lifecycle (`t_node.starts_on`, `ends_on`, `status_cd`) and placement liveness (`t_link.deleted_at`), not from a new engagement state machine. Required widening RLS so trades can read their own placements (`fn_employs`, `fn_node_engages`, `fn_in_network` SECURITY DEFINER helpers).\\n- **ADR-0017 \u2014 De-registering an employee decouples, it does not delete.** Removal retires the `L01` employment edge (soft delete + `RS2`) and the person's live site placements, but never touches the party, evidence, identity check, or consent. Writes an `employee.deregistered` audit event.\\n\\n### Commercial &amp; payments\\n\\n- **ADR-0004 \u2014 Pricing: free Principal Contractors, per-trade revenue.** Now `superseded` on the revenue mechanism, but its core principle survives: Principal Contractors are permanently free.\\n- **ADR-0023 \u2014 Pay per verification.** Supersedes ADR-0004's $49/yr trade subscription. One purchase funds one verification cycle for one person; re-verification is a new chargeable purchase (`t_cycle.cycle_n`). Three payer shapes: the individual, their trade org, or a sponsoring Principal Contractor. Price is **$55.00 AUD including GST** (`TX1`), pinned as price version `2026-08-01` in `backend/src/modules/payments/pricing.ts` and snapshotted onto every order item.\\n- **ADR-0024 \u2014 Stripe behind a payment provider boundary.** `PaymentProvider` interface in `modules/payments/provider/types.ts` with four methods (create/retrieve checkout session, create refund, verify webhook); `stripe.ts` is the only file permitted to import `stripe`. Uses the official SDK (not `fetch`) for webhook signature verification, pins the API version in source, and has **no mock reachable from configuration** \u2014 when `STRIPE_SECRET_KEY` is unset, every payment surface reports `PAYMENTS_NOT_CONFIGURED`. `paymentsStatus()` returns `ready` / `degraded` / `unavailable`.\\n\\n### Operations, privacy &amp; legal\\n\\n- **ADR-0010 \u2014 Phase 1 PC cold-start is Platform Admin provisioned.** No public `/auth/register` for Principal Contractors; the founder provisions org + admin via `POST /admin/principal-contractors` and emails an ACS set-up link.\\n- **ADR-0011 \u2014 In-app support desk.** `core.t_sr` + `core.t_sr_note` (migration 005), refs like `AWX-SR-{n}`, P1\u2013P4 priority SLA clocks, `GET /support/monitor` for Platform Admin. No ServiceNow.\\n- **ADR-0027 \u2014 Data retention schedule and public Privacy / Terms.** Publishes `/privacy` and `/terms` for Assureworx Labs Pty Ltd, adopts an interim retention schedule (identity outcomes: 24 months from consent; evidence: 7 years; audit events: 24 months rolling; blobs: 30 days), and ships the enforcement machinery: migration `030_retention_and_account_closure.sql`, the `backend/src/modules/retention/` sweeper (gated by `RETENTION_SWEEP_ENABLED`), and `POST /parties/me/closure`. Migration `031_rel_attrs_holder_erasure.sql` keeps inactive network edges with a `holder_erasure` label so withdrawn users display as \\\"Withdrew their information\\\" rather than vanishing.\\n\\n## Dependency map\\n\\nThe diagram below shows the most load-bearing dependency chains. Arrows point from the derived decision to the one it builds on, amends, or implements.\\n\\n```mermaid\\nflowchart LR\\n    A20[\\\"ADR-0020Doc Intelligence tier 3\\\"] --&gt;|amends decision 7| A19[\\\"ADR-0019Document extraction\\\"]\\n    A20 --&gt;|respects region/stack| A1[\\\"ADR-0001Full Azure stack\\\"]\\n    A25[\\\"ADR-0025Native Auth via BFF\\\"] --&gt;|builds on session/BFF| A8[\\\"ADR-0008BFF proxy + session\\\"]\\n    A18[\\\"ADR-0018Engagement requirements\\\"] --&gt;|implements layers 4\u20135| A5[\\\"ADR-0005Verified scope hybrid\\\"]\\n    A14[\\\"ADR-0014Didit identity\\\"] --&gt;|feeds C02 into portable status| A5\\n    A23[\\\"ADR-0023Pay-per-verification\\\"] -.-&gt;|supersedes revenue model| A4[\\\"ADR-0004Pricing model\\\"]\\n```\\n\\nTwo chains not shown here are worth knowing: the identity lifecycle continues from ADR-0014 through ADR-0015 (re-verification triggers) to ADR-0026 (diagnostics allowlist), and ADR-0007's obfuscated-schema obligation applies to *every* later decision that touches the database \u2014 new tables and columns must be registered in the mapping and `meta.*` in the same migration.\\n\\n## Connections to the codebase\\n\\nAlthough the decisions contain no code, they are densely wired to specific implementation locations:\\n\\n- **Backend services.** `backend/src/shared/services/verified-status.ts` (`computeStatus`, `recomputeAndPersist`, `READINESS_BY_STATUS`), `backend/src/shared/services/evidence.ts` (`deriveEvidenceStatus()`), `backend/src/shared/auth/native-auth.ts`, `backend/src/modules/identity/didit.ts` (`IdentityVerifier`, `extractDiagnostics`, `parseWebhook`), `backend/src/modules/payments/provider/types.ts` and `stripe.ts`, `backend/src/modules/payments/pricing.ts`, `backend/src/modules/retention/`.\\n- **Frontend.** `frontend/src/lib/verify-demo.ts` (the client-only demo ADR-0009 and ADR-0020 require removing), `frontend/src/lib/identity.ts` (`identityCoaching()`), `frontend/src/app/(legal)/privacy/page.tsx` and `terms/page.tsx`.\\n- **Migrations.** Decisions name their migrations explicitly: `002_enterprise_core.sql`, `005`, `007_email_change.sql`, `011`, `015_trade_engagement_visibility.sql`, `017`, `022_invitation_verification_templates.sql`, `029_check_diagnostics.sql`, `030_retention_and_account_closure.sql`, `031_rel_attrs_holder_erasure.sql`.\\n- **Schema objects.** `core.t_party`, `t_rel`, `t_link`, `t_node`, `t_check`, `t_grant`, `t_party_ident`, `t_consent`, `t_req_set`, `t_req_item`, `t_link_tag`, `t_vfy_tpl`, `t_request`, `t_sr`, `t_sr_note`, `t_email_change`, `t_ledger`, `t_cycle`, `t_meter`, and the `meta.map_dict` / `meta.code_dict` dictionary tables.\\n- **Configuration and secrets.** `SESSION_SECRET`, `API_ORIGIN`, `DIDIT_API_KEY`, `DIDIT_WEBHOOK_SECRET`, `DIDIT_WORKFLOW_ID`, `DIDIT_BASE_URL`, `STRIPE_SECRET_KEY`, `ARTIFACT_MAX_PAGES`, `RETENTION_SWEEP_ENABLED`, plus the `scripts/enable-ciam-native-auth.ps1` enablement script.\\n- **Rest of the wiki.** Every ADR ends with `Related` links into `architecture/` (data model, security, backend, integrations, schema mapping), `product/` (compliance-and-verification, account journeys, billing, identity-and-portability), `runbooks/`, and `isms/`. The `open-questions.md` page is the two-way interface: decisions resolve Q-numbers and file new ones.\\n\\n## Contributing a new ADR\\n\\n1. **Number sequentially.** Use the next free four-digit number (`NNNN-`) and a kebab-case slug matching the title.\\n2. **Copy the frontmatter shape** of an existing accepted ADR. `type: decision` is constant; `status` defaults to `proposed` until confirmed; `updated` is the current date; `sources` names the source spec/doc or is `[]` for internal decisions; `tags` index the topic.\\n3. **Use the cross-reference vocabulary precisely.** If you replace a decision, mark the old one `superseded` and add a forward pointer at the top of the old record. If you modify one part of an earlier decision, use the `Amends`/`Amended by` pattern \u2014 the amended record carries the note (see ADR-0019 and ADR-0021). If you resolve an open question, say \\\"Resolves Q#\\\".\\n4. **Name the implementation surface.** Reference the migration file, module path, function, table, env var, or UI route the decision affects, so the record stays traceable.\\n5. **If the decision touches the physical schema**, register new tables/columns/codes in `schema-mapping.md` and `meta.*` in the same migration \u2014 ADR-0007 makes this a review gate.\\n6. **Keep the consequences notation.** `+` for upsides, `\u2212` for costs/risks, `\u2753 Open` for open questions, `Action:` / `Follow-up:` for binding follow-ups.\",\"other-wiki-product\":\"# Other \u2014 wiki-product\\n\\n# wiki-product \u2014 Product Documentation\\n\\n## Overview\\n\\n`wiki-product` is the product documentation layer of the Assureworx wiki: fourteen Markdown pages under `wiki/product/` that define what the platform does and why it is shaped that way, from the product's point of view rather than the implementation's.\\n\\nAssureworx is a compliance, verification, and workforce-onboarding platform for the construction industry. Principal Contractors onboard trade companies and sole traders, verify their compliance (ABN, identity, licences, insurance), and track readiness per project or site. The product docs describe that domain end to end: how accounts and invites enter the platform, how a trade reaches *Assureworx Verified*, how a profile stays portable across Principal Contractors, how engagement-specific requirements layer on top, and how verification is sold.\\n\\nThe module contains no executable code \u2014 its call graph is empty by construction. Its structure is a **reference graph**: every page links to decision records, architecture pages, runbooks, and concrete source locations. The module has two jobs:\\n\\n1. **Record product truth** \u2014 the journeys, rules, statuses, and commercial terms the product team has committed to.\\n2. **Track the gap to implementation** \u2014 mark what is shipped, what is designed but not built, and where DEV behavior still deviates from product intent.\\n\\n## Role in the wiki\\n\\nThe wiki is layered. Product docs sit on top: they state *what* the product does, cite ADRs for *why*, reference architecture pages for *how*, and point into `backend/src` and `frontend/src` for *where*.\\n\\n```mermaid\\nflowchart LR\\n    P[\\\"wiki/product \u2014 product docs(what &amp; why)\\\"] --&gt; D[\\\"wiki/decisionsADRs \u2014 recorded rationale\\\"]\\n    P --&gt; A[\\\"wiki/architecturehow it's built\\\"]\\n    P --&gt; R[\\\"wiki/runbookshow to operate it\\\"]\\n    P --&gt; S[\\\"backend/src \u00b7 frontend/srcwhere it lives\\\"]\\n```\\n\\nEach product page ends with a Related section of relative links. The most heavily cited neighbors:\\n\\n- **Decisions:** ADR-0005 (verified-scope hybrid), ADR-0008 (BFF session auth), ADR-0010 (PC cold-start), ADR-0012 (Assureworx ID), ADR-0018 (engagement-scoped requirements), ADR-0023 (pay-per-verification), ADR-0025 (native auth BFF), ADR-0029 (employer-sponsored bulk verification), ADR-0030 (employee-check selection), ADR-0031 (refund reconciliation).\\n- **Architecture:** `data-model.md`, `schema-mapping.md`, `backend.md`, `frontend.md`, `security.md`, `integrations.md`, `document-extraction.md`, `identity-verification.md`, `payments-design-2026-08-01.md`.\\n- **Runbooks:** `stripe-setup.md`, `entra-external-id-dev.md`, `wallet-passes.md`.\\n- **Sources:** `wiki/sources/assureworx-product-definition.md` and `assureworx-platform-spec.md`, cited in each page's `sources` frontmatter.\\n- **Shared:** `wiki/glossary.md`, `wiki/brand.md`, `wiki/overview.md`, `wiki/decisions/open-questions.md`.\\n\\n## Document anatomy\\n\\nEvery page is Markdown with YAML frontmatter. There is no build tooling \u2014 the wiki is read directly from the repository.\\n\\n```yaml\\n---\\ntype: product\\nstatus: current\\nupdated: 2026-08-08\\nsources: [assureworx-platform-spec, assureworx-product-definition]\\ntags: [billing, payments, stripe, pricing, conflict]\\n---\\n```\\n\\n| Field | Meaning |\\n|---|---|\\n| `type: product` | Distinguishes these pages from architecture, decision, and runbook content. |\\n| `status` | Currently `current` on every page \u2014 the page describes live product truth, not a proposal. |\\n| `updated` | ISO date of the last substantive review. Pages are actively maintained (dates range 2026-07-20 to 2026-08-08). |\\n| `sources` | The source spec(s) the page derives from. Pages written from implementation experience leave this empty. |\\n| `tags` | Topic tags used for discovery. |\\n\\n### Callouts\\n\\nThe docs use a small set of visual markers that carry meaning:\\n\\n- **`&gt; **\u26a0 ...**`** \u2014 a known discrepancy between product truth and implementation, or a contradiction inside the codebase. Examples: the DEV bootstrap of Campbell via `backend/src/seed.ts`, and `require-subscription.ts` still describing the retired subscription model after ADR-0023.\\n- **`&gt; **\u2753 Open:** ...`** \u2014 an unresolved commercial or policy question, keyed to an entry in `wiki/decisions/open-questions.md` (e.g., Q7 second-company registration fee, Q8 consent gate, Q24 KYC-subject identity anchor).\\n- **`&gt; **Definition:** ...`** \u2014 precise terminology that must not drift, such as the definition of *Assureworx Verified*.\\n- **Dated status banners** \u2014 e.g., `Status (2026-07-26)`, `Resolved (2026-07-26)`, `Shipped console (24 Jul 2026)` \u2014 record when a state changed, so a reader can tell whether a statement reflects the past or the present.\\n\\n### Evidence style\\n\\nProduct claims are pinned to code with file paths, file:line ranges, route paths, table names, and migration numbers. `identity-and-portability.md` is the strongest example: it cites `002_enterprise_core.sql:64-65` for the party key, `requests/index.ts:192-198` for email resolution, and `frontend/src/app/onboarding/invite/[token]/page.tsx:138-144` for a UI promise that used to contradict the backend. This is what makes the docs verifiable rather than descriptive.\\n\\n## Content map\\n\\n| Page | Focus | How it connects |\\n|---|---|---|\\n| `onboarding-and-request-chain.md` | Request types, entry flows, invitation template snapshots, portability promise | Spine of the module; linked from most other pages |\\n| `account-and-invite-journeys.md` | PC cold start (A), colleague seats (B), trade invites (C), workforce (D), day-2 account admin (E) | Complements onboarding; cites ADR-0010, ADR-0025 |\\n| `identity-and-portability.md` | Assureworx ID, the four defects, identity resolution ladder, what shipped | Deep-dive on the portability promise; cites ADR-0012 |\\n| `compliance-and-verification.md` | Verified lifecycle, five core checks, requirement layers, expiry CRON, block types | The heart of the product; feeds engagement readiness |\\n| `engagement-requirements.md` | Layers 4\u20135, computed readiness, the \\\"Tom Delaney\\\" scenario | Implements ADR-0005 / ADR-0018; depends on compliance + identity |\\n| `billing.md` | Pay-per-verification, price, Stripe integration, payments data model | Commercial layer; cites ADR-0023, ADR-0029, ADR-0031 |\\n| `employer-pays-for-crew.md` | Sponsored bulk checkout, employer controls and limits | Payment journey for trade companies |\\n| `employee-verification-journey.md` | Salaried employee onboarding, employee checks | Mirrors the employer journey; cites ADR-0030 |\\n| `pc-create-project-journey.md` | Four-step guided project creation wizard | PC side of projects; pairs with the request journey |\\n| `pc-project-request-journey.md` | Project-scoped request composer, contractor workforce branching | PC side of onboarding; pairs with create-project |\\n| `entity-hierarchy.md` | Org tree, tenancy boundaries, hierarchy rules | Shapes the data model and security boundaries |\\n| `roles-and-permissions.md` | Roles, capabilities, tenant isolation | Access control; cites ADR-0003 |\\n| `trade-classifications.md` | Seed trade types and their mandatory documents | Feeds compliance requirements |\\n| `modules.md` | Catalog of functional modules 5.1\u20135.10 | Index page; maps modules to backend architecture |\\n\\n## The product spine\\n\\nThe pages are not independent. They describe one connected domain, and the dependency order matters for reading them:\\n\\n```mermaid\\nflowchart TD\\n    O[\\\"Onboarding &amp; Request Chain\\\"] --&gt; I[\\\"Identity &amp; Portability\\\"]\\n    O --&gt; C[\\\"Compliance &amp; Verification\\\"]\\n    I --&gt; E[\\\"Engagement Requirements\\\"]\\n    C --&gt; E\\n    C --&gt; B[\\\"Billing &amp; Payments\\\"]\\n    B --&gt; W[\\\"Employer Pays for a Crew\\\"]\\n    O --&gt; A[\\\"Account &amp; Invite Journeys\\\"]\\n    P[\\\"PC Create Project / Request Journeys\\\"] --&gt; O\\n```\\n\\nThe recurring concepts that tie the module together:\\n\\n**1. Every trade enters via a request.** The request chain (`t_request`, with `parent_request_id` and `chain_root_id`) is the audit trail linking every individual back to the Principal Contractor that required them. Five request types exist: `direct_company`, `direct_individual`, `sub_request`, `connection` (`Q04`), and `top_up` (`Q05`). The invitation verification template is snapshotted onto the request (`verification_keys`) so a later edit to an org's defaults never mutates an invitation already sent (ADR-0022).\\n\\n**2. Verified is a computed, revocable state.** *Assureworx Verified* means the mandatory checks for the relevant profile and context passed at the recorded time. It is not permanent. The lifecycle runs PENDING \u2192 VERIFIED \u21c4 ACTION REQUIRED \u2192 SUSPENDED, re-evaluated on every document upload, verification result, or expiry event (`document.expiry.check`, daily at midnight AEST). The five public core checks are ABN validated, identity confirmed, licence current, insurance current, qualifications verified or on record.\\n\\n**3. The profile is portable; readiness is not.** A party's portable status comes from requirement layers 1\u20133 (universal, business, trade-class) and travels with them to any Principal Contractor. Layers 4\u20135 (PC-specific, project/site) are per-engagement and computed on `t_link.state_cd` from the union of all applicable requirements. A trade can be Verified and blocked \u2014 the \\\"Tom Delaney problem\\\" in `engagement-requirements.md` exists precisely to keep those two statuses separate.\\n\\n**4. Identity is a first-class platform fact.** The Assureworx ID (`public_ref`, generated by `core.fn_public_ref()`) is the stable public identifier. Recognition of an existing party walks a ladder \u2014 `public_ref \u2192 D04 \u2192 D01 \u2192 D02 \u2192 D03` \u2014 and a second Principal Contractor connects rather than re-onboards (`POST /onboarding/invite/:token/connect`, request kind `Q04`).\\n\\n**5. Verification is sold per case.** One purchase funds one verification cycle for one person. Three parties can pay (self, trade org, Principal Contractor); the beneficiary is always the individual. Principal Contractors remain free to use the platform \u2014 sponsoring is a purchase, not a subscription (ADR-0023, which superseded ADR-0004).\\n\\n## Connections to the codebase\\n\\nThe product docs are written against the actual implementation. The most important anchors:\\n\\n### Backend\\n\\n- `backend/src/modules/requests/index.ts` \u2014 request lifecycle; resolves an invited email to an existing party via a `D03` ident lookup before creating the request.\\n- `backend/src/modules/onboarding/index.ts` \u2014 invite accept, and the connect path at `POST /onboarding/invite/:token/connect` that replaced the old `ACCOUNT_EXISTS` hard-fail. `POST /auth/register` (`auth/index.ts`) made orphan trades possible.\\n- `backend/src/modules/payments/` \u2014 the billing module: `pricing.ts` (the pinned $55.00 AUD price constant), `state.ts` (server-derived funding labels the browser cannot invent), `checkout.ts` (`startSponsoredCheckout`), `views.ts` (`workforceFundingFor`), `index.ts` (route wiring, including `POST /workforce/checkout`), and `bulk.dbtest.ts` (bulk-order tests).\\n- `backend/src/modules/memberships/index.ts` \u2014 seats and employee-check selection; `GET`/`PUT /memberships/employee-checks`.\\n- `backend/src/shared/services/verification-template.ts` \u2014 the employee-check catalogue. Server-enforced rules: no ABN for salaried employees, identity cannot be switched off.\\n- `backend/src/seed.ts` \u2014 the DEV bootstrap that still seeds Campbell Constructions. The account-journeys page flags this as a known deviation from product truth.\\n- `scripts/provision-pc.mjs` \u2014 programmatic Principal Contractor cold start.\\n- Background jobs: `document.expiry.check` (expiry CRON), `report.generate` (async reporting).\\n\\n### Frontend\\n\\n- `frontend/src/app/(app)/billing/BillingClient.tsx` \u2014 the billing status panel; maps server labels to status images only, never derives payment state in the browser.\\n- `frontend/src/app/(app)/team/TeamClient.tsx` and `EmployeeChecksPanel.tsx` \u2014 Team page, bulk selection, employee-check editing.\\n- `frontend/src/app/(app)/projects/CreateProjectWizard.tsx` \u2014 the four-step create-project flow.\\n- `frontend/src/app/(app)/projects/[id]/ProjectInviteComposer.tsx` \u2014 the inline five-step project request composer.\\n- `frontend/src/app/onboarding/invite/[token]/page.tsx` \u2014 invite acceptance UI.\\n- `frontend/src/lib/nav.ts` \u2014 role-based navigation such as `employeeNav` for `trade_individual`.\\n\\n### Data model &amp; migrations\\n\\nThe docs map product concepts onto specific tables: `t_party.status_cd` (S01\u2013S04) for the portable status, `t_link.state_cd` (J01\u2013J04) for engagement readiness, `t_party_ident` (kinds `D01` ABN, `D02` ACN, `D03` login email, `D04` KYC subject, `D05` trade licence) for recognition, `t_request`/`t_req_item` for the chain, `t_artifact`/`t_artifact_ack` for documents and per-PC acknowledgement, and the payments set (`t_cycle`, `t_order`/`t_order_item`, `t_pay_try`, `t_pay_evt`, `t_fin_txn`, `t_refund`, `t_dispute`) with DB-enforced guarantees (`ux_item_funded`, append-only `t_fin_txn`).\\n\\nMigration files are cited as historical evidence: `006_identity_portability.sql` (Assureworx ID, connect path), `017` (`D05`), `038_abn_identity_not_upload_universal.sql` (ABN/identity are checks, not uploads).\\n\\n### Verification tooling\\n\\nDocumented behavior is testable. `npm run probe:requirements -w @assureworx/backend` walks the engagement-requirements scenario against seeded data and rolls back: place a verified trade, add a site rule scoped to electrical work, confirm it does not apply until the placement is engaged for that classification, confirm the placement blocks with exactly one gap while the portable profile stays Verified, then satisfy it and confirm ready.\\n\\n## Maintaining these docs\\n\\nThe module's value depends on its honesty. When product behavior or the codebase changes:\\n\\n- **Update the page and its `updated` date.** Add a dated status marker when a state change matters \u2014 the existing pages model this with `Resolved (2026-07-26)`, `Status (2026-08-02)`, and `Shipped console (24 Jul 2026)` banners.\\n- **Cite the ADR.** If a decision drove the change, link it (e.g., `../decisions/0018-engagement-scoped-requirements.md`). Open questions belong in `wiki/decisions/open-questions.md`, referenced from the page with a `\u2753` marker.\\n- **Pin code references.** Use file:line ranges when citing a specific behavior (`requests/index.ts:192-198`), and re-verify them \u2014 the docs already demonstrate that a UI promise and a backend 409 can drift apart.\\n- **Mark implementation gaps explicitly.** If DEV still deviates (seed bootstrap, ACS unset, Graph force-reset unwired), say so with a `\u26a0` callout rather than silently describing the target state as reality.\\n- **Call out contradictions, don't smooth them over.** The billing page explicitly flags `require-subscription.ts` and the 14-day grace table as still describing the retired subscription model. When code lags product truth, both sides stay visible.\\n- **Run the probes** before claiming a behavior works, and update the probe documentation if the scenario changes.\",\"other-wiki-runbooks\":\"# Other \u2014 wiki-runbooks\\n\\n# Wiki runbooks \u2014 operations documentation\\n\\n## Overview\\n\\nThe `wiki/runbooks/` module is the operational knowledge base for Assureworx. Where architecture docs explain *what* the system is and ADRs explain *why* decisions were made, these runbooks document *how to operate* the running system: deploying to Azure, wiring identity providers and email, taking payments, rotating secrets, resetting test data, and responding to failures.\\n\\nThe runbooks are written to be followed by a first-time operator. They are deliberately concrete \u2014 they name the exact resource groups, Key Vault secrets, CLI commands, and scripts involved \u2014 and most procedures are mirrored by an executable script in `scripts/` so a documented step can be run rather than transcribed.\\n\\n## Module layout\\n\\nThe module contains 14 runbooks, grouped into six operational areas.\\n\\n### Deployment and environments\\n\\n| Runbook | Purpose |\\n| --- | --- |\\n| `azure-dev-deploy.md` | Ship the monorepo to the DEV Azure stack (`rg-assureworx-dev-aue`). Covers `scripts/deploy-dev.ps1`, required API env, the web site gate, migrations, smoke checks, and first-deploy gotchas. |\\n| `azure-test-deploy.md` | Build and push images to `crawtestaue`, update `ca-aw-api-test-aue` / `ca-aw-web-test-aue`, stand up the TEST CIAM app registration, and bind `beta.assureworx.com.au`. |\\n| `dev-custom-domains.md` | Bind `dev.assureworx.com.au` and `api.dev.assureworx.com.au` to the DEV Container Apps, then cut over Entra redirect URIs and `CORS_ORIGIN` via `scripts/bind-dev-domains.ps1`. |\\n\\n### Identity, email, and monitoring\\n\\n| Runbook | Purpose |\\n| --- | --- |\\n| `entra-external-id-dev.md` | Wire customer login via Entra External ID on Directory B (CIAM). App registration, user flows, Native Auth, branded OTP email, MFA, Container App env wiring, and the full smoke-test walkthrough. |\\n| `acs-email-dev.md` | Transactional email via Azure Communication Services: DNS records (ownership TXT, SPF, DKIM, DMARC), sender switching, delivery-status tracking, and smoke sends. |\\n| `ciam-company-branding.md` | Brand the Entra hosted sign-in pages (carbon background, logos, copy) via `scripts/apply-ciam-branding.ps1`. |\\n| `entra-monitoring-and-cost-dev.md` | What is monitored for CIAM, what the cross-tenant split blocks, the compensating manual sign-in/audit export cadence, and the monthly MAU/cost review. |\\n\\n### Payments and reliability\\n\\n| Runbook | Purpose |\\n| --- | --- |\\n| `stripe-setup.md` | End-to-end Stripe operator guide: sandbox creation, restricted keys, the eleven webhook events, Key Vault storage, boot-time guards, the twelve-scenario test run-through, key rotation, go-live checklist, and troubleshooting. |\\n| `redis-and-payment-reliability.md` | How jobs and payments survive Redis blips, worker crashes, and duplicate webhooks. The \\\"Postgres is truth; Redis/BullMQ is a pump\\\" principle, the payment inbox, reconciliation, and dead-letter replay playbooks. |\\n\\n### Data, privacy, and test hygiene\\n\\n| Runbook | Purpose |\\n| --- | --- |\\n| `didit-retention-30-days.md` | Set per-application Didit retention to 30 days (Q26) in the Business Console, with evidence capture and ISMS register updates. |\\n| `privacy-counsel-brief.md` | Assemble the briefing pack for Australian privacy counsel (Privacy Act / NDB applicability, controller/processor split, Didit biometrics). |\\n| `reset-test-identity.md` | Wipe a test email from local Postgres, Azure DEV Postgres, and Entra CIAM so invite / tradie sign-up journeys can be re-walked. |\\n\\n### Developer tooling\\n\\n| Runbook | Purpose |\\n| --- | --- |\\n| `vscode-postgres-setup.md` | Connect VS Code / Cursor to the Azure DEV Postgres via Entra auth (no password), run queries, and verify migration state. |\\n\\n### Feature operations\\n\\n| Runbook | Purpose |\\n| --- | --- |\\n| `wallet-passes.md` | Issue Apple Wallet and Google Wallet compliance passes from `/verify`, including certificate/issuer setup, DEV configuration via Key Vault PEMs, and the gate-resolve model. |\\n\\n## Environment topology\\n\\nThe runbooks operate on a deliberately split Azure landscape. Understanding the split is the most important context for reading them:\\n\\n- **Directory A** (`e0045fa5-8177-4976-a60a-892074203e28`) is the Startup Sponsorship subscription (`17065bb7-\u2026`) that hosts all infrastructure: resource groups, Container Apps, ACR, Postgres, Redis, Key Vault, App Insights.\\n- **Directory B** (`0286fa4b-f51a-4a2c-9dfc-f7f6ca4c4513`, `assureworxdev1.onmicrosoft.com`) is the CIAM external tenant, \\\"Assureworx DEV Customers\\\", which owns customer identities. It has no Azure subscription of its own.\\n- External providers (Stripe, Didit, Apple, Google) are configured per environment and never shared between tiers.\\n\\n```mermaid\\ngraph TD\\n    subgraph DirA[\\\"Directory A \u2014 Startup Sponsorship\\\"]\\n        DEV[\\\"DEV rg-assureworx-dev-aueACA \u00b7 ACR \u00b7 Postgres \u00b7 Redis \u00b7 Key Vault\\\"]\\n        TEST[\\\"TEST rg-assureworx-test-aueACA \u00b7 ACR (partially provisioned)\\\"]\\n    end\\n    subgraph DirB[\\\"Directory B \u2014 CIAM tenant\\\"]\\n        CIAM[\\\"Assureworx DEV CustomersEntra External ID \u00b7 user flows \u00b7 branding\\\"]\\n    end\\n    subgraph Ext[\\\"External providers\\\"]\\n        STRIPE[\\\"Stripe sandboxes\\\"]\\n        DIDIT[\\\"Didit Business Console\\\"]\\n        WALLET[\\\"Apple Developer \u00b7 Google Wallet API\\\"]\\n    end\\n    DEV -- \\\"ENTRA_* secrets \u00b7 OTP email\\\" --&gt; CIAM\\n    DEV -- \\\"STRIPE_* secrets \u00b7 webhooks\\\" --&gt; STRIPE\\n    DEV -- \\\"DIDIT_* secrets\\\" --&gt; DIDIT\\n    DEV -- \\\"WALLET_* secrets \u00b7 pass signing\\\" --&gt; WALLET\\n```\\n\\nEnvironments are gated by `ASSUREWORX_ENV` (`local`, `dev`, `test`, `prod`). DEV is the only fully provisioned environment. TEST has a resource group, ACR, and a deploy path, but its CIAM user flow, Key Vault secrets, and domain binding are still pending. PROD is placeholder-only \u2014 `app.assureworx.com.au` carries a TXT marker saying do-not-bind until the PROD resource group exists.\\n\\n## Recurring operational patterns\\n\\n### Key Vault is the secret source of truth\\n\\nEach environment has its own Key Vault (`kv-aw-dev-aue` for DEV, `kv-aw-test-aue` for TEST). Secrets use SCREAMING-KEBAB names (`DATABASE-URL`, `SESSION-SECRET`, `ENTRA-CLIENT-SECRET`, `STRIPE-SECRET-KEY`, `WALLET-APPLE-SIGNER-CERT-PEM`). Container Apps reference them via `secretref:` backed by managed identity, so secret values never appear in container definitions, pipeline logs, or commits. Several runbooks include the exact `az containerapp secret set` / `az containerapp update --set-env-vars` commands for wiring a new secret, and `deploy-dev.ps1` re-applies secretrefs (wallet PEMs, site-gate credentials) on every deploy.\\n\\n### Fail closed; never simulate success\\n\\nA recurring principle across the module: when a provider is not configured, the system is honest rather than faking success.\\n\\n- Stripe key unset \u2192 payments are *off*: `PAYMENTS_NOT_CONFIGURED`, routes return `503`, and there is deliberately no mock provider.\\n- Wallet credentials missing \u2192 `503 WALLET_NOT_CONFIGURED`; the UI does not show a fake \\\"added\\\" state.\\n- Webhook signing secret missing \u2192 `PAYMENTS_WEBHOOK_NOT_CONFIGURED` (degraded, not unavailable): payments are confirmed by reconciliation instead of the webhook.\\n- A `pk_` publishable key, a malformed key, or a **live** Stripe key on a non-prod tier fails boot.\\n- `ALLOW_DEV_AUTH` must be unset on Azure DEV. Boot throws if it is set on a deployed environment; passwordless personas are local-only.\\n\\n### Environment guards\\n\\nBoot-time and deploy-time guards are what make tier separation safe:\\n\\n| Guard | Mechanism |\\n| --- | --- |\\n| Tier identity | `ASSUREWORX_ENV` must be `dev` / `test` / `prod`; `NODE_ENV=production` alone does not imply the prod tier |\\n| No dev auth on deployed tiers | `ALLOW_DEV_AUTH` refused on Azure DEV and TEST |\\n| TEST login allowlist | `AUTH_EMAIL_ALLOWLIST=tech@assureworx.com.au`, `AUTH_JIT_PROVISION=0` |\\n| Stripe key tier | Live keys refuse to boot outside `prod`; test keys on `prod` boot with a console warning |\\n| Domain placeholders | `beta` / `app` hosts carry TXT markers saying do-not-bind until the tier RG exists |\\n| CIAM consent | Native Auth requires an `oauth2PermissionGrants` row with `consentType=AllPrincipals`, or token mint fails for new users |\\n\\n### Postgres is truth; Redis is a pump\\n\\nThe payment runbooks encode a single reliability principle. The Stripe webhook first persists the raw event to `core.t_pay_evt` with an idempotent insert and returns `200` *before* enqueuing any job. If Redis is flushed, the row stays `EV1` and the reconcile path (`POST /api/v1/payments/admin/reconcile` or the `payment.reconcile` job) re-drives it. Stripe success redirects never mark anything paid \u2014 the UI only reads order status in Postgres. Non-money jobs use `core.t_idem` (`idemKey`) so duplicate email deliveries are no-ops, and the ledger (`core.t_fin_txn`) is unique on `(provider_cd, provider_ref_txt, kind_cd)`.\\n\\n### Executable evidence\\n\\nThe compliance-adjacent runbooks (`didit-retention-30-days.md`, `privacy-counsel-brief.md`, `entra-monitoring-and-cost-dev.md`) follow the same shape: perform the action in the provider console, capture evidence (screenshot, sign-in log sample, written legal advice), store it in the restricted evidence repository \u2014 never public git \u2014 and update the relevant ISMS register or plan.\\n\\n## How the runbooks connect to the codebase\\n\\nRunbooks are the operational face of the code in `backend/`, `frontend/`, `db/`, and `scripts/`. The connections are explicit and verifiable:\\n\\n- **Scripts.** Most procedures map to an executable in `scripts/`: `deploy-dev.ps1`, `deploy-test.ps1`, `bind-dev-domains.ps1`, `entra-app-reg.ps1`, `enable-ciam-native-auth.ps1`, `enable-ciam-otp-email.ps1`, `enable-ciam-mfa-dev.ps1`, `apply-ciam-branding.ps1`, `reset-test-identity.ps1`, `provision-redis-dev.ps1`, `wipe-test-app-data.mjs`, `bootstrap-platform-admin.mjs`, `switch-acs-sender-info.ps1`, `wire-acs-managed-identity.ps1`. The runbook documents when to run the script and what failure modes look like; the script is the executable form of the procedure.\\n- **API endpoints.** Operational behaviour is documented against real routes: `POST /api/v1/payments/webhook` (raw body, before `express.json`), `POST /api/v1/email/webhook`, `GET /api/v1/auth/entra/status`, `POST /api/v1/auth/native/*`, `POST /api/v1/auth/entra/exchange`, `GET /api/v1/wallet/status`, `GET /api/v1/wallet/gate/resolve`, `POST /api/v1/payments/admin/reconcile`.\\n- **Database tables.** Stateful flows name the tables that hold truth: `core.t_pay_evt` (payment inbox), `core.t_fin_txn` (ledger), `core.t_email_delivery` (email delivery status), `core.t_idem` (idempotency), `core.t_pass_device` / `core.t_pass_dl` (wallet device registrations / download-token burn counts), and `meta.schema_migration` (migration tracking with SHA-256 drift detection).\\n- **Source modules.** Runbooks cite the implementation so an operator can jump from a procedure to code: `backend/src/modules/payments/process-event.ts`, `backend/src/modules/payments/inbox.ts`, `backend/src/modules/payments/pricing.ts`, `backend/src/shared/services/email-brand.ts`, `backend/src/modules/auth/otp-email.ts`, `backend/src/modules/wallet/`, and `frontend/gate-proxy.mjs`.\\n- **Other wiki areas.** Runbooks cross-link to the ADRs that justify the operational choices (e.g. ADR-0006 email, ADR-0008 BFF session auth, ADR-0023 per-verification pricing, ADR-0024 provider boundary, ADR-0027 retention), to the AZURE inventory docs, and to ISMS policies, plans, and evidence registers.\\n\\n## A typical flow: deploying to Azure DEV\\n\\n`azure-dev-deploy.md` is the entry point most operators hit first:\\n\\n1. `az login --tenant e0045fa5-\u2026` into Directory A and set the subscription.\\n2. Run `scripts\\\\deploy-dev.bat` (or `deploy-dev.ps1`). The script builds Docker images locally, pushes them to ACR (`crawdevaue`), updates both Container Apps (`ca-aw-api-dev-aue`, `ca-aw-web-dev-aue`), re-applies Key Vault secretrefs, and smoke-tests `/health` plus BFF personas.\\n3. `deploy-dev.ps1` preserves the current web site gate state (`DEV_SITE_GATE_ENABLED`), creates the gate secrets (`DEV-SITE-BASIC-USER` / `DEV-SITE-BASIC-PASSWORD`) if missing, and defaults the gate **on** when the flag is unset.\\n4. Required API env is `DATABASE_URL`, `SESSION_SECRET`, `CORS_ORIGIN`, `ASSUREWORX_ENV=dev`, `NODE_ENV=production`, `PORT=4000`. `ALLOW_DEV_AUTH` must **not** be present.\\n5. Smoke checks: `Invoke-RestMethod \\\"$API/health\\\"`, then a browser pass through the Chrome Basic Auth gate to `/auth/login` with Entra or a persona.\\n\\nSibling runbooks extend this core flow: `dev-custom-domains.md` replaces raw FQDNs with `dev.assureworx.com.au` / `api.dev.\u2026`, `entra-external-id-dev.md` wires the identity layer, `acs-email-dev.md` adds outbound email, and `stripe-setup.md` turns on payments.\\n\\n## Contributing a runbook\\n\\nRunbooks follow a consistent front-matter and style convention:\\n\\n- **Front matter**: `type` (`concept`, `runbook`, or `architecture`), `status` (`current` / `draft`), `updated` (ISO date), optional `tags` and `sources`.\\n- **Actionable by a first-time operator**: name the exact resource, command, and expected output. Tables for resource inventories and env vars, fenced blocks for CLI commands, and an explicit expected observation where success is non-obvious.\\n- **Cross-link liberally**: to the relevant ADR, architecture doc, inventory, and sibling runbooks.\\n- **Automate what repeats**: if a procedure is worth running more than twice, it belongs in `scripts/` with the runbook documenting when to run it and what can go wrong.\",\"other-wiki-sources\":\"# Other \u2014 wiki-sources\\n\\n# Module: wiki-sources\\n\\n## Purpose\\n\\nThe `wiki/sources/` directory holds the three **ingested source documents** that the Assureworx wiki is built from. Each file is a distilled, annotated record of an external raw document \u2014 it captures what the source says, which parts are authoritative, where it conflicts with other sources, and how those conflicts were resolved in the wiki.\\n\\nThese are not living design docs. They exist to:\\n\\n- Preserve provenance \u2014 every wiki claim can be traced back to a specific source version.\\n- Record supersession \u2014 when a newer source contradicts an older one, the conflict is explicitly logged and a resolution is stated.\\n- Surface gaps \u2014 missing raw files and unresolved questions are tracked inline and mirrored in the decisions pages.\\n\\n## Source hierarchy and precedence\\n\\nThe three sources do not carry equal weight. The Product Definition and Brand Guidelines are both dated 20 July 2026 and explicitly supersede the June 2026 Platform Spec on specific matters. The Platform Spec remains authoritative for everything it covers that the newer sources do not.\\n\\n```mermaid\\ngraph TD\\n    BRAND[\\\"Brand Guidelines v1.020 Jul 2026\\\"] --&gt;|authoritative for| TOPICS1[\\\"Spelling, voice,visual identity,terminology\\\"]\\n    PROD[\\\"Product Definition v1.020 Jul 2026\\\"] --&gt;|authoritative for| TOPICS2[\\\"Behaviour, scope,commercial policy,public-facing terminology\\\"]\\n    SPEC[\\\"Platform Spec v1.1Jun 2026\\\"] --&gt;|authoritative for| TOPICS3[\\\"Data model, architecture,infrastructure, security,API integrations\\\"]\\n    PROD -.-&gt;|supersedes on product / commercial / terminology| SPEC\\n    BRAND -.-&gt;|supersedes on spelling / voice / terminology| SPEC\\n```\\n\\n**Precedence rule:** where Product Definition v1.0 or Brand Guidelines v1.0 conflict with Platform Spec v1.1, the newer document wins on its own domain, and the conflict is recorded in the wiki rather than silently corrected.\\n\\n## Module contents\\n\\n### `assureworx-brand-guidelines.md`\\n\\nSource record for `Raw/ASSUREWORX_BRAND.md`, version v1.0 (20 July 2026), status \\\"Working source of truth\\\".\\n\\n| Aspect | Detail |\\n| --- | --- |\\n| `source_id` | `assureworx-brand-guidelines` |\\n| Domain | Brand, visual identity, voice, messaging |\\n| Legal entity | Assureworx Labs Pty Ltd, ACN 699 745 876, Australia |\\n\\n**What it enforces:**\\n\\n- **Canonical spelling is `Assureworx`.** `AssureWorx`, `Assure Works`, `Assurework`, and `Assureworx AI` are all explicitly incorrect. The wiki notes that an earlier draft used the wrong spelling and was corrected.\\n- **Public term is \\\"Principal Contractor\\\".** \\\"Tier 1 organisation\\\" is permitted only where technical hierarchy requires precision. \\\"Trade\\\" is the broad audience term.\\n- **Never brand as an \\\"AI platform\\\".** The trust/evidence/monitoring story leads.\\n- **Australian English** throughout \u2014 organisation, licence, authorised, enrolment, fulfil.\\n- **Status must never be communicated by colour alone.** Colour must be paired with an icon and an explicit label.\\n- **UI status labels:** Verified, Current, Pending verification, Expiring soon, Expired, Suspended, Rejected, Manual review required.\\n\\nThe file also extracts the palette (Electric Blue `#3E6AE1` primary, plus semantic status colours), type scale (Segoe UI product, Cascadia Code / Fira Code mono), voice principles, and the messaging promise \\\"Verify once. Trust everywhere.\\\"\\n\\n### `assureworx-platform-spec.md`\\n\\nSource record for `Raw/assureworx-platform-spec.html`, version v1.1 (June 2026), status \\\"Draft \u2014 Awaiting Dev Review\\\".\\n\\n| Aspect | Detail |\\n| --- | --- |\\n| `source_id` | `assureworx-platform-spec` |\\n| Domain | Technical specification \u2014 data model, backend/frontend architecture, infrastructure |\\n| Status | Superseded on product/commercial/terminology matters; authoritative for technical content until code exists |\\n\\nThe key content is a **section-by-section mapping** from the 13 sections of the Platform Spec to the wiki pages that consume them. For example:\\n\\n- Spec \u00a72 (Entity Hierarchy) \u2192 `product/entity-hierarchy.md`\\n- Spec \u00a74 (Data Model) \u2192 `architecture/data-model.md`\\n- Spec \u00a77 (Frontend Architecture) \u2192 `architecture/frontend.md`\\n- Spec \u00a710 (Security &amp; Compliance) \u2192 `architecture/security.md`\\n\\n**Core technical facts extracted:**\\n\\n- Multi-tenant B2B SaaS; two user groups: Tier 1 orgs and Trades.\\n- Full Azure stack, Australia East region, mandatory data residency.\\n- Next.js frontend, Express.js 5 backend, PostgreSQL Flexible Server.\\n- Repository-layer tenant isolation; immutable audit log via RLS.\\n- Stripe billing; complimentary Tier 1 grants as a growth lever.\\n\\nThe file also raises **open items** originating from the spec: KYC provider (TBC), email provider (TBC), ABR GUID (apply now), and undocumented `users` / `projects` / `sites` tables.\\n\\n### `assureworx-product-definition.md`\\n\\nSource record for `Raw/ASSUREWORX_PRODUCT.md`, version v1.0 (20 July 2026), status \\\"Working product source of truth\\\". This is the **highest-authority product document** in the module.\\n\\n| Aspect | Detail |\\n| --- | --- |\\n| `source_id` | `assureworx-product-definition` |\\n| Domain | Product behaviour, scope, commercial policy |\\n| Consolidates | Website copy (20 Jul 2026), Platform Spec v1.1 (Jun 2026), Trade Onboarding Flow v1.0 (Jun 2026) |\\n\\n**Key product facts introduced:**\\n\\n- Product promise: **\\\"Verify once. Trust everywhere.\\\"** Proposition: complete trade compliance visibility.\\n- **10 product principles** \u2014 evidence over declaration, current over collected, portable by design, trace every tier, separate private/shared, block precisely, audit material actions, mobile-first trades, desktop-first PC control, Australia first.\\n- **Verified lifecycle is 4 states** \u2014 Pending, Verified, Suspended, plus new **Action required** (the older spec had only 3).\\n- **5 named public checks** \u2014 ABN, identity, licence, insurance, qualifications.\\n- **5 requirement layers** \u2014 profile \u2192 business \u2192 trade-class \u2192 PC \u2192 site, plus the engagement-vs-profile block distinction.\\n- **No enforced subcontracting depth limit.**\\n- **Phase 1 MVP target: September 2026.**\\n- Privacy/access model: access derives from participation + role, not mere knowledge of an email/ABN.\\n- Product claims guardrails \u2014 never call self-declared facts \\\"verified\\\", don't claim government endorsement, \\\"immutable\\\" only for protected audit records.\\n\\n## Conflict resolution\\n\\nBecause the Product Definition and Brand Guidelines deliberately supersede the Platform Spec, conflicts are expected and are recorded rather than hidden. The module's pattern for each conflict is: state the two positions, state the wiki resolution, and link to the tracking question if the conflict is still open.\\n\\n| Area | Platform Spec v1.1 | Product Definition v1.0 | Wiki resolution |\\n| --- | --- | --- | --- |\\n| Public term for buyer | \\\"Tier 1 organisation\\\" | \\\"Principal Contractor\\\" (public); Tier 1 = technical only | Use Principal Contractor in product prose; keep `tier1_org_id` in the data model |\\n| Tier 1 pricing | $5,000 / $10,000 / complimentary | $0 for Principal Contractors (website) | Conflict recorded as an open question; billing page marked conflicted |\\n| Verified statuses | 3 states (Pending \u2192 Verified \u2192 Suspended) | 4 states (adds Action required) | Compliance page updated to 4 states |\\n| Subcontracting depth | Chain implied | No enforced depth limit | Onboarding page updated |\\n\\n## How the module connects to the wiki\\n\\nThe `wiki/sources/` files are **upstream inputs**. They are not consumed programmatically \u2014 there are no internal or external calls, and no execution flows. Instead, wiki pages in `product/`, `architecture/`, and `decisions/` reference these sources via relative links, and the source pages link back to the pages they feed.\\n\\nThe consumption pattern:\\n\\n1. A raw document arrives in `Raw/`.\\n2. A source record is created in `wiki/sources/` with frontmatter (`type: source`, `status`, `source_id`, `source_version`, `source_date`).\\n3. The record distills the raw document into essentials, maps its sections to wiki pages, and records conflicts.\\n4. Wiki pages (overview, product pages, architecture pages, decisions) link back to the source records.\\n\\nThe `decisions/open-questions.md` page acts as the aggregation point for every unresolved item raised by these sources \u2014 including the missing Trade Onboarding Flow raw file.\\n\\n## Open items\\n\\nTwo gaps are tracked at module level:\\n\\n- **Missing raw file:** the Product Definition references \\\"Assureworx Trade Onboarding Flow v1.0\\\" (June 2026), but the file is not present in `Raw/`. It needs to be obtained and ingested.\\n- **Possible orphaned codebase:** the Platform Spec HTML references a logo at `../../FRONTEND/assureworks-crm/public/logo_assure_darkmode.png`, implying a sibling frontend project (\\\"assureworks-crm\\\") exists outside this repo. Whether prior code should be ingested as an additional source needs human confirmation.\\n\\n## Maintenance notes\\n\\n- **Update the `updated` frontmatter field** whenever the record changes.\\n- **Do not silently correct a source.** If a source contains an error (e.g. the earlier `AssureWorx` spelling), record the correction in the wiki page and note it in the source record.\\n- **When a newer source supersedes an older one**, update the older record's status line and the newer record's \\\"Key changes\\\" table so the precedence chain stays visible.\\n- **Every open item must be mirrored** in `decisions/open-questions.md`; the source record links there rather than tracking questions in isolation.\",\"other-wiki\":\"# Other \u2014 wiki\\n\\n# Wiki Module (`wiki/`)\\n\\n## Overview\\n\\nThe `wiki/` directory is the Assureworx knowledge wiki: a Markdown-based, agent-maintainable reference covering what the platform does (product), how it is built (architecture), what has been decided (ADRs), and how to operate it (runbooks). It is the canonical place to start when answering any query about the codebase.\\n\\nThe wiki follows a simple \\\"LLM Wiki\\\" pattern: `index.md` is a content catalog that the reader consults first, then drills into the relevant page. Pages are flat Markdown with YAML frontmatter, cross-linked by relative paths, and kept honest by an append-only activity log and dated audit reports.\\n\\nTwo properties define the module:\\n\\n- **Catalog-first navigation.** `wiki/index.md` is the single entry point. Every page is listed there with a one-line description of its scope and when to read it.\\n- **Append-only history.** `wiki/log.md` is a chronological, append-only record of wiki activity. History is never rewritten; corrections are added as new entries.\\n\\nThe wiki also maintains a strict separation from the **ISMS** (ISO 27001 / SOC 2 management system), which lives in `isms/` with its own schema and `AGENTS.md`. Security *architecture* is documented in `wiki/architecture/`; security *governance, controls, and evidence* live in `isms/`.\\n\\n## Purpose\\n\\nThe wiki exists to answer three questions:\\n\\n1. **What does the platform do?** \u2014 `product/` pages describe the domain model, journeys, roles, verification lifecycle, and commercial model.\\n2. **How is it built?** \u2014 `architecture/` pages map the codebase: stack, frontend/backend structure, data model, security, integrations, infrastructure, plus dated audits of the system's real state.\\n3. **What has been decided and why?** \u2014 `decisions/` holds the ADR register (ADR-0001 through ADR-0032+) and a living open-questions list that tracks unresolved product and technical questions (Q1\u2013Q31+).\\n\\nBecause the code evolves faster than documentation, many architecture pages are written as **dated evidence reports** (e.g., `security-audit-2026-07-25.md`, `platform-audit-2026-07-25.md`, `full-codebase-audit-2026-07-28.md`). These record what was checked, what was found, and what was remediated at a point in time, rather than presenting an idealized \\\"current state.\\\"\\n\\n## Layout\\n\\n```text\\nwiki/\\n\u251c\u2500\u2500 index.md                  # Catalog \u2014 READ THIS FIRST\\n\u251c\u2500\u2500 overview.md               # What Assureworx is + current build state\\n\u251c\u2500\u2500 brand.md                  # Brand &amp; design system\\n\u251c\u2500\u2500 glossary.md               # Domain terms and acronyms\\n\u251c\u2500\u2500 log.md                    # Append-only chronological activity log\\n\u251c\u2500\u2500 ways-of-working.md        # Collaborator context, tooling, Azure tenancy\\n\u2502\\n\u251c\u2500\u2500 product/                  # What the platform does\\n\u2502   \u251c\u2500\u2500 entity-hierarchy.md\\n\u2502   \u251c\u2500\u2500 compliance-and-verification.md\\n\u2502   \u251c\u2500\u2500 engagement-requirements.md\\n\u2502   \u251c\u2500\u2500 billing.md\\n\u2502   \u2514\u2500\u2500 ... (14+ pages)\\n\u2502\\n\u251c\u2500\u2500 architecture/             # How it is built \u2014 code maps, audits, inventories\\n\u2502   \u251c\u2500\u2500 code-map.md\\n\u2502   \u251c\u2500\u2500 frontend.md / backend.md / data-model.md\\n\u2502   \u251c\u2500\u2500 schema-mapping.md     # physical\u2192logical dictionary (\\\"the brain\\\")\\n\u2502   \u251c\u2500\u2500 security.md / infrastructure.md / integrations.md\\n\u2502   \u251c\u2500\u2500 data-classification-catalogue.md   # living column/endpoint classifier\\n\u2502   \u251c\u2500\u2500 document-extraction.md\\n\u2502   \u251c\u2500\u2500 identity-verification.md\\n\u2502   \u2514\u2500\u2500 ... (dated audit reports, inventories, plans)\\n\u2502\\n\u251c\u2500\u2500 decisions/                # ADR register + open questions\\n\u2502   \u251c\u2500\u2500 index.md\\n\u2502   \u251c\u2500\u2500 0001-*.md \u2026 0032-*.md\\n\u2502   \u2514\u2500\u2500 open-questions.md\\n\u2502\\n\u251c\u2500\u2500 sources/                  # Distilled source-of-truth docs (from Raw/)\\n\u251c\u2500\u2500 runbooks/                 # Operational guides (Stripe, Azure, Entra, email)\\n\u2514\u2500\u2500 (sibling) isms/           # ISO 27001 / SOC 2 management system, own schema\\n```\\n\\n## Content model\\n\\n### Frontmatter\\n\\nEvery page starts with YAML frontmatter used for classification and freshness:\\n\\n| Field | Meaning | Observed values |\\n| --- | --- | --- |\\n| `type` | Page category | `index`, `product`, `concept`, `log`; pages under `architecture/`, `decisions/`, and `runbooks/` follow those categories |\\n| `status` | Lifecycle state | `current` on wiki pages; ADR pages use `proposed` / `accepted` |\\n| `updated` | Last-modified date | `YYYY-MM-DD` |\\n| `sources` | Source docs the page distills | e.g., `[assureworx-brand-guidelines]`, `[assureworx-platform-spec]` |\\n| `tags` | Free-form classification | e.g., `[brand, design-system, voice, tokens]` |\\n\\n### Activity log format\\n\\n`log.md` is append-only, newest at the bottom. Entries use a greppable heading format:\\n\\n```text\\n## [YYYY-MM-DD] verb | Title\\n- bullet points describing the change\\n```\\n\\nVerbs in use: `init`, `ingest`, `note`, `decision`, `build`, `query`, `lint`, `ops`. Entries that touch code conventionally end with a `Touched:` line listing the files and wiki pages changed, which makes each entry self-auditing.\\n\\nThe log is explicitly greppable:\\n\\n```bash\\ngrep \\\"^## \\\\[\\\" wiki/log.md | tail -5\\n```\\n\\n### Cross-linking\\n\\nPages link to each other and to code via relative paths. Examples from the wiki itself:\\n\\n- `brand.md` links to `../frontend/src/app/(legal)/privacy/page.tsx` and `decisions/0027-data-retention-and-public-legal.md`\\n- `glossary.md` links to `architecture/data-model.md`, `product/entity-hierarchy.md`, and `decisions/`\\n- `index.md` links to `../AGENTS.md` (maintenance schema) and `../isms/index.md`\\n\\n**Contradiction handling:** when a page is found to contradict the code or a newer decision, a callout is added to the stale page rather than silently rewriting it, and the finding is recorded in the log. This keeps the wiki auditable.\\n\\n## Key components\\n\\n### `index.md` \u2014 the catalog\\n\\nThe entry point. Lists every page grouped by category (Root, Product, Architecture, Decisions, Sources, Runbooks), each with a one-line description of what it covers. It states the navigation rule \u2014 read this first, then drill into the relevant page \u2014 and points to the maintenance schema (`AGENTS.md`), the timeline (`log.md`), and the sibling ISMS wiki.\\n\\n### `overview.md` \u2014 product + build state\\n\\nThe \\\"start here\\\" page: what Assureworx is, the promise/proposition, legal entity, and current build state. The log shows it is updated whenever significant code or decisions land.\\n\\n### `brand.md` \u2014 brand &amp; design system\\n\\nThe working brand reference distilled from the Brand Guidelines source. It is normative for the frontend design system and all product copy. Contents:\\n\\n- **Non-negotiables:** spelling (`Assureworx`, never `AssureWorx`), the promise (\\\"Verify once. Trust everywhere.\\\"), public vs technical terminology (Principal Contractor vs Tier 1), Australian English, never status-by-colour alone.\\n- **Colour tokens:** core palette, neutrals, semantic state colours, and a separate RAG indicator ramp for fills (donut arcs, bars, map pins) that must never carry meaning alone.\\n- **Typography:** font stacks and scale (hero 56\u201372px down to metadata 11\u201313px).\\n- **Voice &amp; language:** prefer/avoid copy table.\\n- **Interface conventions:** status labels, buttons, cards, data/evidence display rules.\\n- **Claims guardrails:** what product copy may not claim (e.g., \\\"immutable\\\" only for protected audit records, \\\"real-time\\\" only where integration frequency matches).\\n\\nIt also tracks an asset gap (missing logo) in `decisions/open-questions.md`.\\n\\n### `glossary.md` \u2014 domain vocabulary\\n\\nSingle source for domain terms: Principal Contractor vs Tier 1, Trade, engagement, Assureworx Verified, the request chain, ABN/ABR/KYC/HRWL/White Card, hard/soft blocks, tenant context, and the Azure/Entra terms. The glossary records the brand terminology note (public vs technical) and links each term to the relevant product or architecture page.\\n\\n### `log.md` \u2014 the timeline\\n\\nThe append-only chronological record of everything done to the wiki and, through it, the codebase. Reading it gives the full project story: bootstrapping, source ingestion, ADR decisions, builds, audits, and remediations. It is the wiki's equivalent of a database migration ledger: history is never edited, only appended.\\n\\n### `decisions/` \u2014 ADRs and open questions\\n\\n- `index.md` \u2014 register of all ADRs.\\n- `0001-*.md` \u2026 `0032-*.md` \u2014 each ADR records a decision, rationale, and any amendments. The register tracks the lifecycle: proposed \u2192 accepted, with later decisions able to supersede or amend earlier ones (e.g., ADR-0023 supersedes ADR-0004's revenue mechanism; ADR-0032 amends ADR-0019; ADR-0021 is amended by ADR-0028).\\n- `open-questions.md` \u2014 the living list of unresolved questions (Q1\u2013Q31+). The log shows a recurring pattern: questions are opened during audits, answered by the founder or by vendor fact-checking, and closed with a decision or an ADR.\\n\\n### `sources/` \u2014 distilled source-of-truth documents\\n\\nOne-page distillations of the original source documents (Product Definition v1.0, Brand Guidelines v1.0, Platform Technical Spec v1.1) that were dropped into `Raw/`. These are the governing references on conflict.\\n\\n### `runbooks/` \u2014 operational guides\\n\\nStep-by-step operating procedures: Stripe setup, Azure DEV/TEST deploys, Entra External ID config, ACS email, wallet passes, Didit retention, custom domains, and test-identity resets. Runbooks are created as infrastructure and external services are wired in.\\n\\n## Maintenance workflow\\n\\nThe maintenance schema lives in `AGENTS.md` at the repo root (referenced by `index.md`). Observed rules:\\n\\n1. **Read `index.md` first** when answering a query, then drill into the relevant page.\\n2. **Frontmatter on every page.** New pages start with `type`, `status`, `updated`, `sources`, and `tags`.\\n3. **Append, never rewrite, `log.md`.** Every significant change gets a new entry at the bottom \u2014 `## [YYYY-MM-DD] verb | Title` \u2014 with bullets and a `Touched:` list.\\n4. **Dated audits for volatile truths.** Implementation state, security posture, and infrastructure inventories are written as dated reports (e.g., `*-2026-07-25.md`) so readers can see when the observation was made.\\n5. **Living catalogs must be kept in sync.** `architecture/data-classification-catalogue.md` (every column/endpoint classed GP/CIC/C/PII/SPII) and `architecture/schema-mapping.md` (physical\u2192logical dictionary) are explicitly annotated: agents must update them on any schema or API change. The schema mapping is mirrored live in the Platform Admin console's ERD tab.\\n6. **Contradictions are surfaced, not hidden.** When code contradicts a page, a callout is added to the page and the finding is logged.\\n7. **Open questions drive decisions.** Unresolved questions are filed in `decisions/open-questions.md` with IDs; answering one closes it and typically produces an ADR.\\n\\n## Relationship to the codebase\\n\\nThe wiki documents the code but is tightly coupled to specific paths:\\n\\n| Wiki area | Coupled to |\\n| --- | --- |\\n| `architecture/frontend.md` | `frontend/src/app/**`, route maps, `frontend/next.config.ts` |\\n| `architecture/backend.md`, `code-map.md` | `backend/src/modules/**`, `backend/src/shared/**`, `backend/scripts/*` |\\n| `architecture/data-model.md`, `schema-mapping.md` | `db/migrations/*.sql`, `meta.map_dict`, `meta.code_dict` |\\n| `architecture/security.md` | RLS policies, auth middleware, audit event classes |\\n| `decisions/*.md` | Implemented in migrations and modules (e.g., ADR-0024 \u2192 `PaymentProvider` interface; ADR-0019/0020 \u2192 `backend/src/shared/services/extraction/`) |\\n| `brand.md` | `frontend/src/styles/tokens.css`, legal pages under `frontend/src/app/(legal)/` |\\n| `runbooks/*.md` | `scripts/*.ps1`, `scripts/*.mjs`, live Azure resources |\\n\\nThe log is the connective tissue: every build entry lists the migrations, backend modules, frontend files, and wiki pages it touched, so the wiki can be reconstructed from the log plus the current tree.\\n\\n### Sibling wiki: `isms/`\\n\\n`isms/` is a separate wiki with its own `AGENTS.md` and schema, covering the ISO 27001 / SOC 2 Type II management system: Statement of Applicability, risk register, data inventory, evidence register, policies, and workstream plans. The split is deliberate and documented in `index.md`:\\n\\n- `wiki/` \u2014 security **architecture** (how isolation, auth, and audit work in code)\\n- `isms/` \u2014 security **governance, controls, and evidence** (how the company operates)\\n\\nProgress in `isms/` is computed by `scripts/isms-progress.mjs` from task checkboxes; the wiki log records when ISMS material is updated.\\n\\n```mermaid\\nflowchart TD\\n    Q[Query about the platform] --&gt; IDX[wiki/index.md \u2014 catalog]\\n    IDX --&gt; ROOT[Root: overview \u00b7 brand \u00b7 glossary \u00b7 log]\\n    IDX --&gt; PROD[product/ \u2014 what the platform does]\\n    IDX --&gt; ARCH[architecture/ \u2014 audits, maps, inventories]\\n    IDX --&gt; DEC[decisions/ \u2014 ADR register \u00b7 open questions]\\n    IDX --&gt; RUN[runbooks/ \u00b7 sources/]\\n    PROD --&gt; CODE[frontend/ \u00b7 backend/ \u00b7 db/migrations]\\n    ARCH --&gt; CODE\\n    DEC --&gt; CODE\\n    ROOT --&gt; ISMS[isms/ \u2014 ISO 27001 / SOC 2, own schema]\\n```\",\"other\":\"# Other\\n\\n# `employee-checks.dbtest.ts` \u2014 Employer Employee-Check Selection (DB Integration Test)\\n\\n## Module Overview\\n\\n`backend/src/modules/memberships/employee-checks.dbtest.ts` is a database-backed integration test for the employer-side of the verification template system. It exercises `verification-template.ts` against a real PostgreSQL instance to verify two invariants that cannot be proven in unit tests:\\n\\n1. **An ABN can never be asked of somebody on wages** \u2014 regardless of how the request arrives (legitimate UI flow or hand-crafted API call).\\n2. **Editing the employee-check selection is forward-only** \u2014 employees already mid-onboarding keep the selection they were originally given; only newly hired staff see the updated requirements.\\n\\nThe second invariant is the \\\"money-adjacent\\\" one: if an employer could retroactively add a document requirement to staff they had already paid to verify, they would be changing what was bought after the charge (see ADR-0030 \u00a74).\\n\\n## Test Environment &amp; Prerequisites\\n\\nThis file is not a unit test and cannot run in a mocked environment. It requires:\\n\\n- A live PostgreSQL database reachable via `DATABASE_URL`\\n- The schema (tables `core.t_party`, `core.t_actor`, `core.t_rel`) to be migrated\\n\\nRun with:\\n\\n```bash\\nnpm run test:db -w backend\\n```\\n\\nThe file fails fast if `DATABASE_URL` is not set, and closes the connection pool in an `after` hook once all tests complete.\\n\\n## Key Components\\n\\n### Local Helpers\\n\\nTwo small factory functions construct the database rows the tests depend on.\\n\\n#### `makeEmployer(tx, label): Promise`\\n\\nInserts a party of kind `K02` (organisation) / class `E01` (employer) into `core.t_party`, then creates a corresponding actor in `core.t_actor`. Returns `{ partyId, actorId }`.\\n\\n#### `makeEmployee(tx, employer, keys): Promise`\\n\\nInserts a party of kind `K03` (person) / class `M01` (staff) into `core.t_party`, then creates an employment relationship edge in `core.t_rel`:\\n\\n- `from_party_id` \u2014 the employee's party ID\\n- `to_party_id` \u2014 the employer's party ID\\n- `kind_cd` \u2014 `'L01'` (employment)\\n- `attrs` \u2014 a JSONB object containing `{ verificationKeys: keys }` when keys are supplied, or `{}` when not\\n\\nThe employee's ID is returned. This relationship edge is the carrier of what a given employee was asked for \u2014 the test's way of simulating a person already part-way through onboarding.\\n\\n### Services Under Test\\n\\nAll verification-template logic comes from `shared/services/verification-template.ts`:\\n\\n| Function | Role in the tests |\\n|---|---|\\n| `getVerificationTemplate(tx, partyId, templateType)` | Reads the employer's current selection for a given template type (e.g. `\\\"employee_onboarding\\\"`, `\\\"trade_invitation\\\"`) |\\n| `saveVerificationTemplate(tx, partyId, actorId, keys, ...)` | Persists a new selection; throws on unknown requirement keys |\\n| `employmentRequirementKeys(tx, employeePartyId)` | Resolves the effective requirement keys for a specific employee \u2014 either the snapshot on their employment edge or the platform default |\\n| `DEFAULT_EMPLOYEE_KEYS` | The platform baseline selection for employees |\\n\\nDatabase access is wrapped in `withBypass` from `shared/db/pool.ts`, which runs the callback inside a transaction with row-level security bypassed \u2014 appropriate here because the tests are setting up their own fixtures and asserting on policy rules, not testing RLS itself.\\n\\n## Test Cases\\n\\n### 1. Starts at the platform baseline for a salaried employee\\n\\nCreates a fresh employer with no customisation and asserts that:\\n\\n- `getVerificationTemplate(..., \\\"employee_onboarding\\\")` returns exactly `DEFAULT_EMPLOYEE_KEYS`\\n- The template catalogue does not include an `\\\"abn\\\"` item \u2014 a salaried employee is never offered an ABN check\\n\\n### 2. Keeps the trade-invitation template separate from the employee one\\n\\nSaves a custom employee_onboarding selection, then asserts:\\n\\n- The `\\\"trade_invitation\\\"` template still contains `\\\"abn\\\"` \u2014 editing employee checks must not disturb what subcontractors are asked\\n- The employee template reflects the new selection (`identity`, `artifact:A001`, `artifact:A005`)\\n\\nThis guards against cross-template contamination when both selections live under the same employer party.\\n\\n### 3. Refuses an ABN however the request arrives\\n\\nAttempts to save `[\\\"identity\\\", \\\"abn\\\"]` as an employee_onboarding template. The call is expected to reject with `/Unknown verification requirement: abn/`. The explicit point of this test is that a hand-crafted request must not slip past the browser's narrower UI list \u2014 the service layer itself must reject the ABN for wage earners.\\n\\n### 4. Leaves staff already onboarding on the selection they were given\\n\\nThe forward-only test. It:\\n\\n1. Creates an employee with a light selection: `[\\\"identity\\\", \\\"artifact:A001\\\"]`\\n2. Saves a broader selection for the employer: `[\\\"identity\\\", \\\"artifact:A001\\\", \\\"artifact:A110\\\"]`\\n3. Asserts the existing employee still resolves to `[\\\"artifact:A001\\\", \\\"identity\\\"]` \u2014 the later edit does not reach someone already part-way through\\n4. Creates a *new* employee and asserts they *do* get `\\\"artifact:A110\\\"` \u2014 the next hire sees the new selection\\n\\n### 5. Reports nothing for a person no employer has scoped\\n\\nCreates an employee whose employment edge has no `verificationKeys` snapshot (`attrs = {}`), then asserts `employmentRequirementKeys` returns `null` for them. The comment in the test clarifies the semantics: a missing snapshot means the platform rules apply in full \u2014 `null` is the signal for \\\"not scoped by any employer,\\\" not \\\"no requirements.\\\"\\n\\n## Relationship to the Codebase\\n\\n```mermaid\\ngraph LR\\n    Test[employee-checks.dbtest.ts] --&gt; withBypass[withBypassshared/db/pool.ts]\\n    Test --&gt; getTemplate[getVerificationTemplateshared/services/verification-template.ts]\\n    Test --&gt; saveTemplate[saveVerificationTemplateshared/services/verification-template.ts]\\n    Test --&gt; empKeys[employmentRequirementKeysshared/services/verification-template.ts]\\n    Test --&gt; makeEmployer[makeEmployerlocal helper]\\n    Test --&gt; makeEmployee[makeEmployeelocal helper]\\n    makeEmployer --&gt; DB[(core.t_party /core.t_actor)]\\n    makeEmployee --&gt; DB[(core.t_party /core.t_rel)]\\n    withBypass --&gt; DB\\n    getTemplate --&gt; DB\\n    saveTemplate --&gt; DB\\n    empKeys --&gt; DB\\n```\\n\\n- **Shared service layer**: The test is the primary consumer-facing specification for `saveVerificationTemplate` and `employmentRequirementKeys` in the employee-onboarding context. Any behavioural change to those functions should be reflected here.\\n- **DB pool**: `withBypass` is the standard transaction wrapper for seed/test operations that need to work around RLS; this file is one of several `*.dbtest.ts` modules that follow the same pattern.\\n- **Domain model**: The fixtures exercise the party/actor/relationship schema (`core.t_party`, `core.t_actor`, `core.t_rel`) with the exact kind/class codes used in production (`K02`/`E01` employer, `K03`/`M01` staff, `L01` employment edge). Drift in those codes will surface here first.\\n- **ADR-0030**: The forward-only test is the executable expression of the billing-boundary rule in ADR-0030 \u00a74 \u2014 an employer cannot alter what was verified after the fact for already-paid staff.\\n\\n## Maintenance Notes\\n\\n- Keep the `describe` blocks aligned with the invariants they protect. If the product ever needs to allow ABN checks for certain wage-earner categories, test 3 and the catalogue assertion in test 1 are the places that will need to change first.\\n- If the verification requirement catalogue grows, the hard-coded keys (`artifact:A001`, `artifact:A005`, `artifact:A110`) remain valid as long as those catalogue entries exist. Prefer adding new tests over editing existing keys so historical behaviour stays documented.\\n- This file intentionally has no incoming callers and is never imported by production code \u2014 it is discovered and run by the test runner only.\",\"overview\":\"# assureworx_app \u2014 Wiki\\n\\n# Assureworx\\n\\nWelcome to the Assureworx engineering wiki \u2014 the entry point to the `assureworx_app` monorepo. Assureworx is a multi-tenant B2B SaaS compliance network for the Australian construction and trades industry. Principal Contractors use it to know whether every company and worker across their projects and sites is ready to work; trades use it to carry one portable compliance profile \u2014 one verified identity, one evidence dossier, one digital pass \u2014 from site to site. This page gives you the big picture: what the platform does, how the monorepo is organised, and the end-to-end flows that tie the modules together.\\n\\n## What Assureworx does\\n\\nThe compliance backbone of the platform is the [Evidence, Checks &amp; Compliance](evidence-checks-compliance.md) module, which answers three questions for every party \u2014 company or person \u2014 in the network:\\n\\n1. **What evidence must this party provide, and in what state is it?**\\n2. **Is this party compliant enough to submit, be placed on a site, or be shown as verified?**\\n3. **When a document is uploaded, can the platform read it, extract the fields, and accept it automatically \u2014 or does a human need to review it?**\\n\\nThe answers are produced by a chain of modules \u2014 identity verification, evidence management, compliance decisions, payment for verification cycles, and wallet pass issuance \u2014 and that chain is what this wiki documents.\\n\\n## Architecture at a glance\\n\\nAssureworx is an npm-workspaces monorepo with two deployable applications \u2014 `@assureworx/frontend` (Next.js 15, App Router) and `@assureworx/backend` (Express 5 API and background workers) \u2014 plus a shared package, `@assureworx/shared-types`. The relationship between them is a backends-for-frontends (BFF) pattern (ADR-0008): the browser talks only to the Next.js origin, and `/api/v1/*` is proxied from there to the Express API. This keeps session handling and server-side rendering in one process, while the API stays a clean, tenant-scoped surface.\\n\\n```mermaid\\nflowchart TB\\n    Browser[\\\"Browser\\\"]\\n    Next[\\\"UI Components &amp; App Shell(Next.js 15 \u2014 BFF origin)\\\"]\\n    FE[\\\"Frontend domain modulesIdentity \u00b7 Projects \u00b7 Payments \u00b7 Wallet\\\"]\\n    API[\\\"API &amp; Backend Infrastructure(Express 5 API + workers)\\\"]\\n    BE[\\\"Backend domain modulesAuth \u00b7 Identity \u00b7 Evidence \u00b7 Payments \u00b7 Notifications\\\"]\\n    DB[(\\\"PostgreSQLcore schema + migrations\\\")]\\n    EXT[\\\"External servicesEntra ID \u00b7 Didit \u00b7 ACS \u00b7 Twilio \u00b7 Apple/Google Wallet\\\"]\\n\\n    Browser --&gt;|HTTPS| Next\\n    Next --&gt;|renders pages| FE\\n    FE --&gt;|\\\"/api/v1 via BFF\\\"| API\\n    API --&gt;|tenant-scoped routes| BE\\n    BE --&gt;|persistence| DB\\n    BE --&gt;|provider calls| EXT\\n```\\n\\nConcretely, the repository is laid out as `frontend/` (Next.js and TypeScript), `backend/` (Express and TypeScript API and workers), `packages/` (shared workspace packages), `db/migrations/` (ordered PostgreSQL schema migrations), `scripts/` (development, operations and deployment helpers), `wiki/` (this knowledge base), `isms/` (the ISO 27001 and SOC 2 control/evidence workspace), and `Raw/` (immutable source material used by the wiki).\\n\\n### Frontend: one shell, many pages\\n\\nEvery authenticated page renders inside the [UI Components &amp; App Shell](ui-components-app-shell.md). That module owns the server-rendered session gate, error recovery, the persistent application chrome (`Sidebar`, `TopBar`, workspace container), and the **Dashboard** \u2014 the principal contractor landing page, which combines KPI metrics, compliance status bars, an evidence attention queue, a trade network rail, and a site map preview under a single cross-filtering surface.\\n\\nThe page-level modules plug into that shell: [Identity Verification &amp; Onboarding](identity-verification-onboarding.md), [Projects, Sites &amp; Requests](projects-sites-requests.md), [Payments &amp; Billing](payments-billing.md), [Wallet &amp; Digital Passes](wallet-digital-passes.md), [Trades &amp; Company Directory](trades-company-directory.md), [Workforce &amp; Team Management](workforce-team-management.md), and [Admin, Support &amp; Reporting](admin-support-reporting.md).\\n\\n### Backend: a serving layer plus domain modules\\n\\nThe [API &amp; Backend Infrastructure](api-backend-infrastructure.md) module is the Express serving layer and background-job host behind the BFF. Payments &amp; Billing and Authentication &amp; Session Management are its heaviest users, but every backend domain module \u2014 Identity, Evidence, Wallet, Projects, Directory, Notifications \u2014 sits behind the same tenant-scoped surface. These domain modules are organised as microservice-ready units, each owning its workflows, persistence, and provider integrations, so the boundaries you see here are the boundaries a future split would follow.\\n\\nA quick tour of who owns what:\\n\\n- [Authentication &amp; Session Management](authentication-session-management.md) handles every way into the platform \u2014 Entra External ID hosted OAuth, branded Entra Native Authentication, a DEV-only passwordless path, and trade self-registration \u2014 and issues the signed `awx_session` HTTP-only cookie that gates every subsequent request. No passwords are stored, and IdP tokens never reach the browser.\\n- [Identity Verification &amp; Onboarding](identity-verification-onboarding.md) verifies trade users through the Didit provider under explicit versioned consent. The platform deliberately never persists identity documents, face photos, biometrics, or document numbers.\\n- [Evidence, Checks &amp; Compliance](evidence-checks-compliance.md) owns artifact storage, field extraction, human review, and the compliance decision that feeds the `workReady` state seen across the platform.\\n- [Payments &amp; Billing](payments-billing.md) sells verification cycles and enforces a single invariant: nobody is ever charged twice for the same verification.\\n- [Wallet &amp; Digital Passes](wallet-digital-passes.md) signs and serves `.pkpass` files for Apple Wallet and produces Google \\\"Add to Wallet\\\" save URLs.\\n- [Notifications &amp; Communication](notifications-communication.md) delivers transactional email via ACS, SMS via Twilio, and in-app notifications surfaced in the app chrome \u2014 all on durable outbox/queue infrastructure.\\n- [Retention &amp; Data Governance](retention-data-governance.md) enforces the ADR-0027 erasure and retention schedule.\\n\\n### Data and cross-cutting concerns\\n\\nUnderneath everything sits PostgreSQL. [Database Migrations &amp; Schema](database-migrations-schema.md) keeps three indirection layers \u2014 what the API calls things, what application code names them, and the obfuscated physical table names in the `core` schema \u2014 so HTTP contracts, business logic, and the physical database can evolve independently.\\n\\nAround the applications sit the operational and knowledge modules: [Infrastructure &amp; Deployment](infrastructure-deployment.md) (multi-stage Docker images, separate Azure workloads), [Development Tooling &amp; Scripts](development-tooling-scripts.md) (local infrastructure, database lifecycle, probes, deployment helpers), [Performance &amp; Reliability Testing](performance-reliability-testing.md) (frontend instrumentation, cross-browser regression suites, k6 load tests, ZAP reports), the written record in [Product &amp; Architecture Documentation](product-architecture-documentation.md) and [Security &amp; Compliance Documentation](security-compliance-documentation.md), and [Legacy &amp; Experimental Artifacts](legacy-experimental-artifacts.md), which archives superseded page components you may still see linked from older flows.\\n\\n## Key end-to-end flows\\n\\n### 1. From invitation to gate pass\\n\\nThe journey of a trade worker is the platform's core loop. It starts with an invitation from a principal contractor's project ([Projects, Sites &amp; Requests](projects-sites-requests.md)) or with trade self-registration; either way, [Authentication &amp; Session Management](authentication-session-management.md) runs registration through Entra and issues the signed `awx_session` cookie, then safely returns the user to their intended destination after credential setup.\\n\\n[Identity Verification &amp; Onboarding](identity-verification-onboarding.md) then verifies the person through Didit under explicit, versioned consent. The trade uploads evidence \u2014 licences, tickets, inductions \u2014 into [Evidence, Checks &amp; Compliance](evidence-checks-compliance.md), which extracts fields automatically where possible and routes the rest to human review. Each verification consumes a cycle sold by [Payments &amp; Billing](payments-billing.md), and the whole experience converges on the single `workReady` decision. Once verified, [Wallet &amp; Digital Passes](wallet-digital-passes.md) issues the digital pass the trade presents at site gates, with [Notifications &amp; Communication](notifications-communication.md) keeping everyone informed at each step.\\n\\n### 2. Running a principal contractor network\\n\\nPrincipal contractor admins provision their organisation's seats ([Workforce &amp; Team Management](workforce-team-management.md)), then create projects and sites, batch-geocode addresses, assign trade parties, and manage invitations ([Projects, Sites &amp; Requests](projects-sites-requests.md)). The Dashboard in [UI Components &amp; App Shell](ui-components-app-shell.md) is the operational cockpit \u2014 KPIs, compliance status bars, an evidence attention queue, a trade network rail, and a site map preview, all cross-filtering. [Trades &amp; Company Directory](trades-company-directory.md) gives Tier 1 principals read-only network visibility, while each trade manages its own compliance dossier.\\n\\n### 3. Retention, audit, and support\\n\\n[Retention &amp; Data Governance](retention-data-governance.md) implements ADR-0027. Account closure requests stop reliance on personal data immediately and de-identify the record after a 90-day grace window; timed sweeps purge or retire data that has outlived its retention period \u2014 stale evidence, expired identity checks, old audit events. The immutable audit-event stream those sweeps depend on is owned by [Admin, Support &amp; Reporting](admin-support-reporting.md), which also hosts the `/admin`, `/support`, `/settings`, and `/settings/account` surfaces.\\n\\n## Running the project\\n\\nAssureworx is an npm-workspaces monorepo, so everything installs from the repository root:\\n\\n```bash\\nnpm install\\nnpm run dev            # frontend + backend together\\nnpm run dev:frontend   # Next.js app only\\nnpm run dev:backend    # Express API only\\n```\\n\\nThe API needs a PostgreSQL instance. Schema changes are applied as ordered migrations from `db/migrations/` with `npm run migrate`; `npm run migrate:status` shows what has been applied. Tests run with `npm run test`, and the DB-backed integration tests (which need a real Postgres) run via `npm run test:db`. Standard quality gates are `npm run lint`, `npm run typecheck`, and `npm run build`. For local infrastructure and database lifecycle details, see [Development Tooling &amp; Scripts](development-tooling-scripts.md); for the production deployment model, see [Infrastructure &amp; Deployment](infrastructure-deployment.md).\\n\\n## Where to start\\n\\nIf you are new to the product, begin with [Identity Verification &amp; Onboarding](identity-verification-onboarding.md) and [Evidence, Checks &amp; Compliance](evidence-checks-compliance.md) \u2014 together they define what \\\"ready to work\\\" means. If you are new to the code, [UI Components &amp; App Shell](ui-components-app-shell.md) and [API &amp; Backend Infrastructure](api-backend-infrastructure.md) are the two layers you will touch every day. For the reasoning behind architectural decisions, the ADRs live in [Product &amp; Architecture Documentation](product-architecture-documentation.md); for the compliance posture, see [Security &amp; Compliance Documentation](security-compliance-documentation.md).\\n\\n&gt; **Private and confidential.** This repository contains proprietary Assureworx source code. Access does not grant permission to copy, redistribute, or reuse it outside authorised work.\",\"payments-billing-backend-src\":\"# Payments &amp; Billing \u2014 backend-src\\n\\n# Payments &amp; Billing \u2014 backend-src\\n\\n## Overview\\n\\nThe Payments &amp; Billing module sells verification cycles. It handles two purchase journeys, confirms payment through two independent paths, and can give money back through an operator-approved refund workflow. It is built around one non-negotiable requirement: **nobody is ever charged twice for the same verification, and nobody is charged at all unless their purchase was explicitly authorised.**\\n\\nThe module lives in `backend/src/modules/payments/`; a small `billing/` router exists only as a wiring health check (`GET /billing/health` returns `{ success: true, data: { module: \\\"billing\\\", status: \\\"wired\\\" } }`). All real functionality is in `payments/`.\\n\\nThe product surface is deliberately small:\\n\\n- **Self-pay** \u2014 a person buys their own verification.\\n- **Sponsored pay** \u2014 an employer buys verification for up to `MAX_SEATS_PER_ORDER = 50` employees in one payment.\\n- **Refunds** \u2014 an operator approves returning money for a seat.\\n- **Every environment sells at the same price**: $55.00 AUD including GST ($50.00 + $5.00 tax), set by `quote()` / `activePrice()` in `pricing.ts`. There is no separate \\\"development amount\\\" \u2014 the figure on screen while testing must be the figure a customer sees.\\n\\n## Domain model\\n\\nFive tables carry the payment state machine:\\n\\n| Entity | Table | Role |\\n|---|---|---|\\n| Verification case | `core.t_cycle` | The thing being bought. One row per person per attempt at verification (a \\\"cycle\\\"). Tracks status, eligibility, reservation holds, and funding. |\\n| Order | `core.t_order` | A purchase. One row per payer per checkout, containing one or more order items (\\\"seats\\\"). |\\n| Order item | \u2014 | A seat; links an order to a beneficiary's case and carries allocation (`reserved` \u2192 `allocated` / `released`). |\\n| Payment attempt | `core.t_pay_try` | One genuine try at paying an order. Holds the idempotency key and provider references. |\\n| Financial ledger | `core.t_fin_txn` | Append-only money movements: `FT1` charge, `FT2` refund. |\\n| Refund | `core.t_refund` | An operator-approved money return, settled against the provider. |\\n| Provider event inbox | \u2014 | Raw webhooks stored before processing, so delivery is at-least-once but processing is exactly-once. |\\n\\nThe central concept is the **verification case** (a \\\"cycle\\\"). It is not a party and not an order \u2014 it is the unit of work being paid for. A case moves through `VC1` (open) \u2192 `VC2` (funded, verification work can proceed), and back to `VC1` if a refund unfunds it. Payment attempts move through `AT1` (created) \u2192 `AT2` (redirected to the provider) \u2192 a terminal state (`failed`, `cancelled`, `expired`, or settled). Orders move through `draft` \u2192 `awaiting_payment` \u2192 `processing` \u2192 `paid`, plus the terminal failure states. All transitions are validated by `assertAttemptTransition`, `assertCaseTransition`, and `assertOrderTransition` in `state.ts`.\\n\\n## The checkout flow: two transactions and a durable idempotency key\\n\\nThis design is the heart of the module; it is worth understanding before touching any code.\\n\\nCreating a payment is deliberately split across **two database transactions with the provider call between them**:\\n\\n```mermaid\\nflowchart TB\\n    subgraph Checkout[\\\"Checkout \u2014 tx1, provider call, tx2\\\"]\\n        A[\\\"startSelfCheckout / startSponsoredCheckout\\\"]\\n        B[\\\"tx1: lock case \u00b7 create order + attempt \u00b7 persist idempotency key\\\"]\\n        C[\\\"provider.createCheckoutSession \u2014 same key on retry\\\"]\\n        D[\\\"tx2: recordSession \u00b7 order \u2192 awaiting_payment\\\"]\\n        A --&gt; B --&gt; C --&gt; D\\n    end\\n\\n    subgraph Confirm[\\\"Confirmation paths\\\"]\\n        E[\\\"Webhook: insertInboxEvent \u2192 processPayEvent\\\"]\\n        F[\\\"Reconcile: runPaymentReconcile \u2192 settleFromProvider\\\"]\\n        G[\\\"settlePaid: fundCase + appendLedger (idempotent)\\\"]\\n    end\\n\\n    D --&gt; E\\n    D --&gt; F\\n    E --&gt; G\\n    F --&gt; G\\n    G --&gt; H[\\\"Refund: requestRefund \u2192 decideRefund \u2192 sendRefundToProvider \u2192 reconcileRefunds\\\"]\\n    D -.-&gt; I[\\\"cancelOrder / sweepExpiredCheckouts \u2192 release reservations\\\"]\\n```\\n\\nThe dangerous window is the provider call itself. A single transaction spanning it would either hold locks across a network round trip or \u2014 worse \u2014 roll back a session Stripe has already created, leaving no record that it exists.\\n\\nThe idempotency key is written **before** the call, in `openAttempt()`:\\n\\n```\\naw:order:{orderId}:{attemptN}\\n```\\n\\nand is deterministic, so a crashed retry regenerates the identical key. If the process dies mid-call, the next request replays the same key, the provider returns the session it already made, and the payer continues \u2014 no second session, no second charge. A key generated at call time could not do this.\\n\\nAll failure modes land somewhere recoverable:\\n\\n| Failure | What happens |\\n|---|---|\\n| Crash before tx1 commits | Nothing persisted; the request simply failed. |\\n| Crash between tx1 and tx2 | Order sits in `draft` holding its reservation. The next request finds it via `openOrderForCase` and re-calls the provider with the same key. `sweepExpiredCheckouts` releases it if the payer never comes back. |\\n| Provider rejects | `closeAttempt` \u2192 `failed`, order \u2192 `failed`, reservation released. The payer can start again cleanly. |\\n| Provider times out | Same as the crash case: same key, same session, recovered on retry. |\\n\\nThe reuse path is also the double-click answer. Two POSTs a millisecond apart serialize on the advisory lock (`lockCase`); the second finds the first's order and is handed the identical Checkout URL. The database backs this up: the `ux_try_order_attempt` unique constraint on `(order_id, attempt_n)` makes two concurrent attempts for the same order impossible, and `reserveCase` only succeeds when the case is unfunded with no unexpired competing reservation.\\n\\n### Entry points\\n\\n- `startSelfCheckout({ actorId, personPartyId, journey? })` \u2014 a person buys their own verification. The actor, payer, and beneficiary are the same person, and nothing in the request body influences who is charged or how much.\\n- `startSponsoredCheckout({ actorId, orgPartyId, beneficiaryPartyIds, journey? })` \u2014 an employer (trade organisation `K02` or principal contractor `K01`) buys verification for their employees.\\n\\n`startSponsoredCheckout` is the **only path that accepts money before the beneficiary is eligible**. Self-checkout refuses until evidence is complete, because the person is spending their own money on work about to run. An employer is doing the opposite: paying up front so the invitation that reaches the employee is already funded. Eligibility is still computed and persisted on every case (`persistEligibility`) \u2014 the position is worth having \u2014 but it blocks nothing on this path.\\n\\nAuthorisation in the sponsored path is all-or-nothing and entirely server-derived. Every named beneficiary must be a live employee of the paying organisation (an `L01` employment edge with status `RS1`, not suspended, no holder erasure, and a person rather than an organisation), or the whole order is refused with 403. A request that half-succeeded would be the worst outcome, because the payer would have been charged for a set they did not choose. The beneficiary list is deduplicated and sorted up front \u2014 the sorted order is what makes the per-person advisory locks deadlock-free when two employers pay for overlapping crews.\\n\\nThe `journey` parameter is a closed set of keys (`billing`, `verify`, `team`) used only to build Stripe return URLs via `returnUrls()`. A URL from the request is never accepted; that would be an open redirect on a page the payer reaches straight from a payment provider.\\n\\n### Rebuilding the provider call\\n\\n`completeCheckout()` runs after tx1 and is safe to run twice. The provider call parameters are rebuilt **entirely from persisted rows** \u2014 the attempt's `expiresAt`, the order's currency, the item's unit amount \u2014 never from a fresh clock reading or a recomputed price, because the provider rejects a reused idempotency key whose parameters differ. The function also checks the order arithmetic before calling out: if the line items do not agree with the order total, it throws rather than quietly misprice the order. This matters because the provider sees a single line with a quantity; the invariant \\\"one product at one price\\\" is assumed and therefore asserted.\\n\\n## Eligibility: what can be sold\\n\\n`evaluateEligibility(db, partyId)` recomputes from the database on every call \u2014 eligibility is never accepted from a browser, and the checkout path recomputes immediately before creating a session so a tab left open across an evidence expiry cannot buy anything. It returns:\\n\\n- `state` \u2014 `eligible_for_payment`, `not_started`, `in_progress`, `manual_review_required`, `action_required`, or `ineligible`\\n- `reasons` \u2014 machine-readable codes with subjects (e.g. `evidence_expired:artifact:A001`), sorted by significance so the UI can lead with the worst\\n- `nextAction` \u2014 the one thing to tell the person to do next\\n- `requirementKeys` \u2014 the snapshot of what the person was assessed against, in `artifact:` shape\\n\\nThe rules themselves are **not written in this module**. They come from the same version-controlled compliance catalogue the rest of the platform uses \u2014 `buildChecklist` in `shared/services/checklist.ts`. This module's job is to turn that checklist into one of six positions and a list of reasons. Founding company admins are assessed against their org dossier with identity attached (`buildChecklist(db, adminOrgId, { identityPartyId })`); everyone else is assessed directly.\\n\\nTwo properties matter more than the mapping:\\n\\n1. **`eligible_for_payment` is not `verified`.** It means only that there is a reasonable basis to charge for the work. The verification can still come back rejected, and paying does not change that.\\n2. **Reasons are stored as codes and subjects, never free text.** A payer can read this row for a worker they are considering funding, so a human's opinion about why someone failed a check does not belong in it.\\n\\nThe requirement snapshot is written onto the case at reservation time (`snapshotRequirements`), so a later change to the requirement catalogue cannot retrospectively alter what somebody bought.\\n\\n## Confirmation: two independent paths to \\\"paid\\\"\\n\\nA redirect to the success URL proves nothing \u2014 no route consumes it, and the order stays `awaiting_payment` until the provider confirms. Settlement happens only through one of two independent paths:\\n\\n### 1. Webhooks \u2192 inbox \u2192 `processPayEvent`\\n\\nProvider events (`checkout.session.completed`, `payment_intent.succeeded`, refund events) are inserted verbatim into the inbox by `insertInboxEvent`, then processed by `processPayEvent`. The inbox makes at-least-once delivery safe: replaying the same event is a no-op, and claims (`claimInboxEvent`) stop concurrent workers from double-processing. `settlePaid` then funds each case via `fundCase`, appends the ledger line via `appendLedger`, and re-evaluates the beneficiary's party status via `evaluateAndPersistStatus` \u2014 since ADR-0025, funding is a precondition of Verified, so the badge must not lag the payment.\\n\\n`fundCase` is itself idempotent: if the funding item ID matches what is already recorded, it is a replayed event and returns the current case. Two differently-keyed events describing the same charge (e.g. an intent event arriving before the session event) still produce exactly one ledger line.\\n\\n### 2. Reconciliation \u2192 `retrieveCheckoutSession`\\n\\nIf webhooks are misconfigured or an event is lost, `runPaymentReconcile` (started by `startPaymentReconcileTimer` from `scheduler.ts`) asks the provider directly whether checkout sessions are paid and settles them through `settleFromProvider`. This is why a missing `STRIPE_WEBHOOK_SECRET` degrades a deployment instead of killing it \u2014 reconciliation is a legitimate, if slower, confirmation path.\\n\\n## Refunds: operator in the loop\\n\\nMoney going back out is not a sweep's decision. The refund flow is:\\n\\n1. `requestRefund` \u2014 a payer or admin requests a refund for a specific order item, with a reason code and comment.\\n2. `decideRefund` \u2014 an operator approves or rejects it.\\n3. `sendRefundToProvider` \u2014 the approved refund is sent to the provider (`createRefund` in `provider/stripe.ts`).\\n4. `reconcileRefunds` \u2014 polls the provider for the outcome and marks the refund `succeeded` or `failed`.\\n\\nWhen a refund settles, `unfundCase` returns the beneficiary's case to `open` \u2014 same cycle number, because they are still on the same attempt, they just no longer have it paid for \u2014 and re-evaluates their party status, since the money going back takes Verified with it. The ledger records `FT2` for the negative amount; the order becomes `partially_refunded` if other seats remain allocated. A partial refund of a bulk order unfunds exactly the refunded seat and nobody else, and the refunded person's cycle becomes buyable again.\\n\\n## Cleanup: cancels, expiry, and the sweep\\n\\n- `cancelOrder` \u2014 payer-initiated cancel while the order is still `draft` or `awaiting_payment`. Never available once the order is processing or paid; at that point the money question belongs to the refund workflow, not a cancel button. Closes the live attempt, moves the order to `cancelled`, and releases all reservations.\\n- `abandonOpenSponsoredCheckouts` \u2014 called when the Team page loads and before starting a new crew checkout. The browser Back button never hits Stripe's `cancel_url`, so an unpaid sponsored order would otherwise hold its employees' seats indefinitely and hide the Pay button even though no money moved. Paid and processing orders are left alone.\\n- `sweepExpiredCheckouts` \u2014 the periodic expiry. It asks the provider **before** expiring anything, and that order matters: a payer can complete checkout in the last second of the window, or the webhook can be late. If the session reports paid, the order is moved to `processing` and left for the settlement path; only genuinely unpaid sessions are expired. The sweep is written to be safe to run concurrently with itself and with a real payment \u2014 every write is conditional on the state it read. It also mops up orphaned reservations whose order is already dead (`lapsedReservations`), which is the difference between \\\"self-healing\\\" and a support ticket.\\n\\n## Safety invariants\\n\\nThese are the properties every change to this module must preserve:\\n\\n1. **Idempotency keys survive crashes.** They are derived deterministically from `(orderId, attemptN)` and persisted in tx1, before the provider call.\\n2. **The same key means the same session.** `completeCheckout` rebuilds the provider call entirely from persisted rows so a retry replays identical parameters.\\n3. **Advisory locks serialise payers.** `lockCase` is taken before the case is read, so the read and the write that follows are one unit. Sponsored checkout takes locks in sorted party-id order so concurrent employers paying for overlapping crews queue instead of deadlocking.\\n4. **Optimistic concurrency backs up the locks.** Every update asserts `ver_n`, so a lost update is impossible even where a lock was never taken.\\n5. **Every state change is a legal transition.** The state machines in `state.ts` are enforced in code. `recordSession` is scoped to `AT1` so a session reference can never be stapled onto a finished attempt; `reserveCase` only succeeds from an open, unfunded case with no unexpired competing reservation; `releaseCase` is scoped to the order that took the hold so a late release from an abandoned order cannot drop a reservation a second payer has since taken.\\n6. **RLS is bypassed because authorisation is explicit.** Migration 023 gives `t_cycle` a `WITH CHECK (core.fn_bypass())` policy, because a payer legitimately funds a case they have no business editing. The trade is that every write in this module is paired with explicit server-side authorisation and an audit event written by its caller.\\n7. **The module refuses to take money when unconfigured or misconfigured.** `requireSellable()` checks `paymentsStatus()` at the top of every path that could charge. The deployment also refuses to *boot* with a live Stripe key outside prod \u2014 see below.\\n\\n## Provider abstraction and boot safety\\n\\nAll provider interaction goes through the interface in `provider/types.ts`. Two implementations exist:\\n\\n- `provider/stripe.ts` \u2014 production.\\n- `provider/fake.ts` \u2014 `FakePaymentProvider` for tests, injected via `setPaymentProviderForTest`, which refuses to work outside a test process.\\n\\n`paymentsStatus()` returns a machine-readable status: `code`, `state`, `canSell`, `provider`, and the price the customer would be charged (`currency`, `taxMode`, `taxRateBp`, `displayAmountC`, `version`). The status never contains the key, the webhook secret, or any `sk_`/`whsec_` substring.\\n\\n`boot.test.ts` runs in child processes \u2014 because `config.ts` reads the environment once at import and throws there, and a misconfiguration must stop the process at startup, not surface as a 500 on the first sale \u2014 and proves:\\n\\n- No Stripe configuration \u2192 boots, reports `PAYMENTS_NOT_CONFIGURED`, `canSell: false`, and says plainly that nothing is being simulated.\\n- Sandbox key without webhook secret \u2192 `PAYMENTS_WEBHOOK_NOT_CONFIGURED`, degraded but sellable (reconciliation is a legitimate confirmation path).\\n- Sandbox key + webhook secret \u2192 `PAYMENTS_READY`. Restricted keys (`rk_test_\u2026`) are accepted.\\n- A live key (`sk_live_\u2026`) on anything except the `prod` tier \u2192 **refuses to boot**. DEV and TEST run the same code paths as PROD, and a live key there would take real money during a click-through.\\n- A publishable key or a malformed key \u2192 refuses to boot, rather than failing at the first sale.\\n- Every tier sells at the same $55.00.\\n\\n## Module layout\\n\\n| File | Responsibility |\\n|---|---|\\n| `checkout.ts` | Orchestration: `startSelfCheckout`, `startSponsoredCheckout`, `cancelOrder`, `sweepExpiredCheckouts`, `releaseReservations`, `abandonOpenSponsoredCheckouts` |\\n| `attempts.ts` | Payment attempt rows, deterministic idempotency keys, session/reference recording, closing attempts |\\n| `cases.ts` | Verification case lifecycle: `openCase`, `reserveCase`, `releaseCase`, `fundCase`, `unfundCase`, `advanceCase`; advisory locking via `lockCase` |\\n| `eligibility.ts` | `evaluateEligibility` \u2014 whether paid verification may be sold to a person |\\n| `orders.ts` | Order + item queries and writes, allocation changes, status transitions, expiry queries |\\n| `pricing.ts` | Price quote and active price |\\n| `state.ts` | Legal state transitions for attempts, cases, and orders |\\n| `inbox.ts` | Provider event inbox: `insertInboxEvent`, `claimInboxEvent`, drive |\\n| `process-event.ts` | Event processing: `processPayEvent`, `settlePaid`, `settleRefundDelivery`, `resolveOrderAndAttempt` |\\n| `reconcile.ts` | `runPaymentReconcile`, `settleFromProvider` \u2014 the polling fallback |\\n| `refunds.ts` | `requestRefund`, `decideRefund`, `withdrawRefund`, `sendRefundToProvider`, `reconcileRefunds` |\\n| `ledger.ts` | Append-only financial ledger |\\n| `views.ts` | Read models for the billing UI, Team page, and funding views (`fundingViewFor`, `workforceFundingFor`) |\\n| `provider/` | Provider interface, Stripe implementation, fake for tests |\\n| `scheduler.ts` | Starts/stops the reconciliation timer |\\n| `webhook.ts` | `registerPaymentJobs` \u2014 webhook job handlers wired into the shared job system |\\n| `index.ts` | Route registration, payment status, order views, cancel |\\n| `billing/index.ts` | Wiring health check only |\\n\\n## Testing strategy\\n\\nThe module has two layers of database tests, both run with `npm run test:db -w backend`:\\n\\n- **`flow.dbtest.ts`** \u2014 self-payment end to end: eligibility gates the sale and nothing is created on the way to refusing; a double-click returns the same order and the same URL with only one provider session; a timed-out create recovers by adopting the session the provider already made; a redirect proves nothing; a replayed webhook posts once; two differently-keyed events describing one charge settle once; a definite provider refusal frees the person; cancelling releases the reservation.\\n- **`bulk.dbtest.ts`** \u2014 employer payment for a crew: one order, N reservations, one session, one charge; a set containing anyone who is not a live employee creates nothing at all; duplicates collapse; overlapping sets are refused with `ALREADY_FUNDED`; the prepay exception (paying for people who have supplied nothing); settlement names the employer as funder without naming them; the Team page reads \\\"claimable\\\" without writing anything; a partial refund unfunds one seat and nobody else.\\n\\nThe tests run the real orchestration against a real database \u2014 the same `startSelfCheckout`, the same inbox, the same `processPayEvent`. The only substitution is the payment provider. Because `withBypass` commits (crash recovery across transactions is half of what is being tested), fixtures are unique per run and left in place: CI starts a fresh database each time, and a local dev database accumulates rows, which is the price of testing the thing that actually ships.\\n\\n`boot.test.ts` covers the configuration gates in child processes, as described above.\",\"payments-billing-frontend-src\":\"# Payments &amp; Billing \u2014 frontend-src\\n\\n# Payments &amp; Billing \u2014 Frontend\\n\\n## Overview\\n\\nThe payments module covers every place a user encounters money in the Assureworx product: the **Billing** page (order history and current funding position), the **verify paywall** (`/verify/pay`), the provider **return/cancel** routes, and the **FundingCallout** banner embedded in other pages.\\n\\nThe module is built around one invariant: **the client never decides whether anything is paid**. Every screen renders `FundingView.label`, a value derived server-side from the order, the allocation, and the ledger. The client maps that label to copy, colour, artwork, and a single permitted action \u2014 it cannot produce the word \\\"Paid\\\" on its own.\\n\\nA second invariant shapes the pay button: **the client never knows the price, the payer, or the beneficiary**. Starting a payment is a POST with an empty body (or a journey hint) followed by a redirect to a provider-hosted page. Every commercial decision is made server-side, milliseconds before the Checkout session is created, and re-checked when the session is confirmed.\\n\\n## Design rules\\n\\nThese rules recur across every file in the module and are the fastest way to reason about the code:\\n\\n| Rule | Rationale |\\n|---|---|\\n| Render `funding.label`, never infer it | The server derives status from order + allocation + ledger. Client branches cannot drift from the source of truth. |\\n| A redirect is not proof of payment | The success URL is a URL the payer could type. Only a signature-verified provider event or a reconcile marks an order paid. |\\n| Poll while `payment_processing`, but reconcile first | Re-reading your own row cannot settle a payment the provider took but never reported. `/payments/me/reconcile` asks the provider before the page re-reads. |\\n| Card details never touch this application | The app POSTs to create a session, then `window.location.assign(redirectUrl)`. The provider hosts the card form. |\\n| The return route has no session gate | Stripe can bounce the browser back while the API is mid-restart; a failed `/auth/me` would strand a payer on an error page even though the charge succeeded. The destination page owns the session gate. The cancel route *does* gate and actively releases the checkout hold (see below). |\\n| One primary action per state | `payability()` returns `\\\"pay\\\"`, `\\\"retry\\\"`, or `\\\"none\\\"` \u2014 never more than one primary CTA. |\\n\\n## Page map and routing\\n\\n| Route | File | Role |\\n|---|---|---|\\n| `/billing` | `(app)/billing/page.tsx` + `BillingClient.tsx` | Worker's money page: current funding position, order history, cancellations, per-person allocation breakdown |\\n| `/verify/pay` | `verify/pay/page.tsx` + `VerifyPayClient.tsx` | Paywall between completing checks and holding a pass (ADR-0025) |\\n| `/payments/return` | `(app)/payments/return/page.tsx` | Provider success redirect. Does nothing except send the browser to the journey's destination page with `?from=checkout` |\\n| `/payments/cancelled` | `(app)/payments/cancelled/page.tsx` | Provider cancel redirect. Releases the checkout hold immediately, then returns the payer to their journey with `?from=cancelled` |\\n| \u2014 | `components/domain/FundingCallout.tsx` | Embeddable one-line funding banner for non-billing pages |\\n\\nBoth provider routes use a closed-set `DESTINATIONS` map keyed by a `journey` query parameter (`verify`, `team`, or billing as default). The parameter is matched against that closed set rather than followed, because anything arriving on those URLs came through a third party.\\n\\nThe **cancel route differs from the return route in one important way**: it calls `requirePortal(\\\"member\\\")` and, if an `order` query parameter is present, POSTs to `/payments/orders/by-no/{orderNo}/cancel`. This exists because cancelling on Stripe used to leave the checkout reservation live until the expiry sweep \u2014 on Team, that made an employee look like they had vanished from Pay (checkbox gone, \\\"Payment in progress\\\") even though no money moved. Releasing the hold immediately prevents that inconsistency.\\n\\n## Payment lifecycle\\n\\n```mermaid\\nflowchart LR\\n    A[Billing or Verify pay page] --&gt;|POST /payments/me/checkout| B[Server creates Stripe session]\\n    B --&gt;|redirectUrl| C[Stripe Checkout]\\n    C --&gt;|success| D[/payments/return]\\n    C --&gt;|cancel| E[/payments/cancelled]\\n    E --&gt;|cancel order by-no + session gate| F[Destination page]\\n    D --&gt;|302 with from=checkout, no session gate| F[Destination page]\\n    F --&gt;|poll: reconcile + re-read funding| G{Webhook settled?}\\n    G --&gt;|no| F\\n    G --&gt;|yes| H[Paid state rendered from label]\\n```\\n\\nThe lifecycle in detail:\\n\\n1. `BillingClient.startPayment()` or `VerifyPayClient.pay()` POSTs to `/payments/me/checkout` and receives `{ redirectUrl }`. `BillingClient` sends an empty body; `VerifyPayClient` sends `{ journey: \\\"verify\\\" }` so the provider can return the payer to the right place.\\n2. The browser does a full-page navigation to the provider-hosted page. The application never sees card details.\\n3. On success or cancel, the provider bounces the browser to `/payments/return` or `/payments/cancelled`, which immediately 302-redirect to the destination page with a `from` parameter.\\n4. The destination page re-reads `/payments/me/funding`. Because the redirect almost always beats the webhook, it sees `payment_processing` and starts polling.\\n5. While `payment_processing`, the page calls `/payments/me/reconcile` then re-reads funding/orders on a timer. The webhook \u2014 not the browser \u2014 settles the payment. When the label changes to a paid state, polling stops and the UI updates itself.\\n\\n## Components\\n\\n### Billing page \u2014 `BillingClient` + `page.tsx`\\n\\nThe server page (`BillingPage`) calls `requirePortal(\\\"trade\\\")`, then loads three resources in parallel:\\n\\n- `/payments/config` \u2192 `PaymentsConfigView`\\n- `/payments/me/funding` \u2192 `FundingView`, **only if** `me.personPartyId` exists. The funding endpoint answers for a person; asking for an account with no profile would 400 and take the whole page down.\\n- `/payments/orders` \u2192 `{ orders: OrderView[] }`\\n\\nIt passes these as `initialData` to the client's `useApi` calls, which seed the cache so the client renders immediately without a loading flash.\\n\\n`BillingClient` renders up to five regions:\\n\\n**No profile state** \u2014 if `hasProfile` is false, an empty state points the user at their work profile. Verification is bought for a person; payment cannot exist before the profile does.\\n\\n**Status panel** \u2014 the heart of the page. It derives, from `funding.label` alone:\\n\\n- `fundingChip(label, funderName)` \u2192 status chip kind and text\\n- `headline(funding)` \u2192 an exhaustive `switch` over every label producing title/body copy\\n- `payability(funding)` \u2192 the single permitted action: `\\\"pay\\\"` for `payment_required`, `\\\"retry\\\"` for `payment_failed`, `\\\"none\\\"` otherwise\\n- `PAYMENT_STATE_ARTWORK[label]` \u2192 illustration\\n\\nThe panel also shows the price block (only when the user can pay and there are no blocking `funding.reasons`), a reason list rendered as hazards, an error strip, and the action row. The action row can hold the primary pay/retry button, a \\\"Finish my checks\\\" button for `not_ready`, a \\\"Check again\\\" refresh for confirming states, and a ghost \\\"Billing support\\\" link.\\n\\n**Thanks panel** \u2014 shown only when `returnedFrom === \\\"checkout\\\"` **and** the label is one of the three paid states. This is the post-payment confirmation, including the order reference and a link to the work profile.\\n\\n**Orders table** \u2014 every order with its number, creation time, beneficiary (or \\\"N people\\\" for multi-person orders), total with GST breakdown, and status chip. `OrderRow` renders a Cancel button only for `draft` or `awaiting_payment` orders, and renders an exception row if any item has `allocation === \\\"exception\\\"` \u2014 a payment that could not be applied to every person on it.\\n\\n**Allocation panel** \u2014 for orders covering more than one person, a per-beneficiary breakdown with order number and allocation chip.\\n\\nPolling behaviour: `CONFIRMING_POLL_MS = 4000`. While `funding.label === \\\"payment_processing\\\"`, the page calls `refreshBoth()` on a timer: first `POST /payments/me/reconcile` (swallowing errors \u2014 reconcile can fail and the next poll can retry), then `Promise.all([refreshFunding(), refreshOrders()])` in parallel. The `refreshRef` pattern keeps the interval stable across re-renders.\\n\\nOne extra effect: landing with `?from=checkout` triggers a single immediate `refreshBoth()` \u2014 the redirect beat the webhook more often than not, so the page pulls once rather than making the user wait a poll interval.\\n\\nOrder cancellation (`cancel`) POSTs to `/payments/orders/{orderId}/cancel`, toasts the result, and refreshes both funding and orders.\\n\\n### Verify paywall \u2014 `VerifyPayClient`\\n\\nThe paywall between completing checks and holding a pass. It shares the billing page's principles (server-derived label, provider-hosted payment) but has a tighter scope and its own rules:\\n\\n- **Redirects out immediately** if the funding label is already paid (`paid_by_you`, `paid_by_your_organisation`, `paid_by_sponsor`) or employer-funded (`awaiting_employer_payment`) \u2014 there is nothing left to do on the paywall.\\n- **Demo handling**: if a demo session exists and is not in \\\"bound verify\\\" mode (`isBoundVerifyMode`), or the session is employer-sponsored, the user is redirected straight to `/verify/pass`. Demo has nothing to buy.\\n- `PAID_LABELS` is a `Set` checked in an effect, so the redirect fires the moment the label changes while polling.\\n- Polling interval is `CONFIRMING_POLL_MS = 3000`; each tick reconciles, then re-reads funding.\\n- The footer carries the single action: pay/retry, \\\"Check again\\\" while confirming, or a secondary link (work profile for employer-pays, billing otherwise).\\n- The price row and the \\\"buying verification work, not a verification result\\\" disclaimer render only when `canPay` is true.\\n\\nThe page is wrapped in `` because it calls `useSearchParams`, which needs a suspense boundary.\\n\\n### Provider return and cancel routes\\n\\nBoth are intentionally thin:\\n\\n- **`/payments/return`** \u2014 no session gate, no API calls. It matches `journey` against the closed set and redirects. The destination page owns authentication and re-reading the server's position. The deliberate absence of `requirePortal` is documented in the source: a failed auth call during an API restart would strand a payer whose charge already succeeded.\\n- **`/payments/cancelled`** \u2014 gates with `requirePortal(\\\"member\\\")`, and if an `order` query parameter is present, cancels that order by number. The cancel is best-effort: `ServerApiError` is swallowed because the order may already be gone, expired, or not the caller's \u2014 the user must still be returned to their journey. Then it redirects to the journey destination with `from=cancelled`.\\n\\nBoth routes send the `from` parameter because destination pages use it: billing shows the thanks panel on `from=checkout`, and the verify paywall shows \\\"You left the payment page. Nothing was charged.\\\" on `from=cancelled`.\\n\\n### FundingCallout\\n\\nA one-line funding banner for pages that are not billing. It renders **nothing** for most states \u2014 a person who has paid does not need reminding on every screen, and a person whose verification is not yet payable does not need a payment banner following them around.\\n\\nIt appears only for the states that need action or reassurance: `payment_required`, `awaiting_employer_payment`, `payment_processing`, `payment_failed`, and `billing_review_required`. Each has fixed copy; the chip label and tone come from `fundingChip`. The action button links to `/billing` (\\\"Pay now\\\" for `payment_required`, \\\"View billing\\\" otherwise) and is omitted entirely for `awaiting_employer_payment`, since the employer pays on Team.\\n\\nThe component takes `enabled` to opt out of the funding fetch entirely (e.g. on pages where the user may not have a profile yet).\\n\\n## Shared state model \u2014 `FundingView.label`\\n\\nThe entire module is driven by this one discriminated value. The server derives it from the order, the allocation, and the ledger; the client only maps it to presentation.\\n\\n| Label | Meaning | Typical action |\\n|---|---|---|\\n| `not_ready` | Evidence incomplete/unreadable | \\\"Finish my checks\\\" \u2192 profile |\\n| `payment_required` | Checks ready, self-pay | Primary pay button |\\n| `awaiting_employer_payment` | Employer pays on Team | None (info + profile link) |\\n| `payment_processing` | Provider confirming | Poll; \\\"Check again\\\" |\\n| `paid_by_you` | Confirmed, self-paid | None |\\n| `paid_by_your_organisation` | Confirmed, org-paid | None |\\n| `paid_by_sponsor` | Confirmed, sponsored | None |\\n| `payment_failed` | Charge failed, nothing taken | Retry pay button |\\n| `refund_under_review` | Refund requested, open | None |\\n| `partially_refunded` | Part returned | None |\\n| `refunded` | Fully returned | None |\\n| `payment_disputed` | Chargeback raised | None |\\n| `billing_review_required` | Needs human review | None |\\n\\nTwo components (`headline` in BillingClient, `headline` in VerifyPayClient) switch exhaustively over these labels; TypeScript's `satisfies` on `PAYMENT_STATE_ARTWORK` guarantees every label has an illustration.\\n\\n## Server integration points\\n\\n| Endpoint | Used by | Direction |\\n|---|---|---|\\n| `/payments/config` | Billing page, VerifyPayClient | Read; `configured` gates the pay button (`sellable`) |\\n| `/payments/me/funding` | Billing page, VerifyPayClient, FundingCallout | Read; the status source of truth |\\n| `/payments/orders` | Billing page | Read; order history |\\n| `/payments/me/checkout` | BillingClient, VerifyPayClient | POST; returns `{ redirectUrl }`; VerifyPayClient sends `{ journey: \\\"verify\\\" }` |\\n| `/payments/me/reconcile` | BillingClient, VerifyPayClient | POST; asks the provider to settle before re-reading |\\n| `/payments/orders/{orderId}/cancel` | BillingClient | POST; cancels a draft/pending order |\\n| `/payments/orders/by-no/{orderNo}/cancel` | PaymentCancelledPage | POST; releases the checkout hold on provider cancel |\\n\\nServer pages use `loadAppPageOptional` (so a failing optional load degrades gracefully) and `serverApi`; client components use the `api` client and `useApi` hook, both from `@/lib`.\\n\\n## Related systems\\n\\n- **`src/lib/verify-demo.ts`** \u2014 `loadSession()` and `isBoundVerifyMode()` gate the verify paywall: demo accounts and employer-sponsored sessions never see the pay button.\\n- **`src/components/ui/StatusChip.tsx`** \u2014 `fundingChip`, `orderChip`, `allocationChip` map server status values to chip kind/label. No payment logic lives here.\\n- **`src/lib/use-api.ts`** \u2014 the data hook all three client components use; server-passed `initialData` seeds its cache.\\n- **Work profiles** \u2014 `hasProfile` / `me.personPartyId` decides whether the funding endpoint is called at all, because funding answers for a person.\\n- **Team** \u2014 the `team` journey destinations and the cancel-hold fix on `/payments/cancelled` are the module's main cross-product coupling; a cancelled Stripe session must not leave an employee looking vanished from Pay.\",\"payments-billing\":\"# Payments &amp; Billing\\n\\n# Payments &amp; Billing\\n\\nThe Payments &amp; Billing module sells verification cycles. It spans two sub-modules \u2014 a backend domain in `backend/src/modules/payments/` and a frontend surface covering every place users encounter money: the **Billing** page, the `/verify/pay` paywall, provider return/cancel routes, and the **FundingCallout** banner. (A small `billing/` router on the backend exists only as a wiring health check; all real functionality lives in `payments/`.)\\n\\n## One invariant, two halves\\n\\nThe whole module enforces a single rule: **nobody is ever charged twice for the same verification, and nobody is charged at all unless the purchase was explicitly authorised.** The backend makes those decisions; the frontend is structurally unable to override them. Every payment screen renders `FundingView.label`, a value derived server-side from the order, the allocation, and the ledger. The client maps that label to copy, colour, and one permitted action \u2014 it cannot produce the word \\\"Paid\\\" on its own, and it never sees the underlying price.\\n\\n## How the sub-modules fit together\\n\\n- [Payments &amp; Billing \u2014 backend-src](payments-billing-backend-src.md) owns the entire lifecycle: checkout (`startSelfCheckout`, `startSponsoredCheckout`), orders and attempts, eligibility cases, the provider abstraction, webhook event processing, refunds, and the `fundingViewFor` view builder.\\n- [Payments &amp; Billing \u2014 frontend-src](payments-billing-frontend-src.md) is deliberately thin. `BillingClient` drives the Billing page and paywall, initiates `startPayment`, and re-syncs via `refreshBoth`. It consumes the backend's funding view and renders it \u2014 it never re-derives payment state.\\n\\nThe seam between the two is the **funding view**: the backend computes it, the frontend renders it. The only payment action the frontend can initiate hands control straight back to the backend checkout flow.\\n\\n## Key workflows spanning both\\n\\n- **Self-pay checkout** \u2014 the user hits `/verify/pay`; `startSelfCheckout` (checkout.ts) opens a case via `openCase`, creates a payment session through the provider, and the user returns or cancels via the provider routes.\\n- **Sponsored checkout** \u2014 `startSponsoredCheckout` snapshots eligibility requirements (`snapshotRequirements`) before any charge.\\n- **Payment confirmation** \u2014 through two independent paths: synchronous session retrieval (`retrieveCheckoutSession` via `stripeProvider` / `StripePaymentProvider`, with failures mapped through `toProviderError`) and asynchronous `processPayEvent`, which resolves orders and attempts (`resolveOrderAndAttempt`) from inbox events (`inboxById`). The provider interface also has a fake implementation (`failNext`, `completeSession`) used by the test suites.\\n- **Refunds** \u2014 operator-approved: `requestRefund` checks `orderHoldsMoney`, enqueues to `refundQueue`, surfaces in `adminRefundList`, and `settleRefundSucceeded` reconciles the case via `caseById`.\\n- **Funding display** \u2014 `fundingViewFor` (persisting eligibility via `persistEligibility`) feeds the Billing page and `FundingCallout`; a `sweepExpiredCheckouts` job cancels stale orders.\\n\\n```mermaid\\ngraph TB\\n    subgraph frontend[\\\"Frontend (frontend-src)\\\"]\\n        BP[BillingPage / BillingClient] --&gt; SP[startPayment]\\n        PW[/verify/pay paywall/]\\n        FC[FundingCallout]\\n    end\\n\\n    subgraph backend[\\\"Backend (backend-src)\\\"]\\n        C[checkout.tsstartSelfCheckout / startSponsoredCheckout]\\n        V[views.tsfundingViewFor]\\n        P[provider abstractionstripeProvider]\\n        E[process-event.tsprocessPayEvent]\\n        R[refunds.tsrequestRefund / settleRefundSucceeded]\\n        S[state.tsorderHoldsMoney]\\n    end\\n\\n    SP --&gt; C\\n    PW --&gt; C\\n    BP --&gt; V\\n    FC --&gt; V\\n    C --&gt; P\\n    P --&gt; E\\n    E --&gt; C\\n    R --&gt; S\\n```\",\"performance-reliability-testing-e2e\":\"# Performance &amp; Reliability Testing \u2014 e2e\\n\\n# Performance &amp; Reliability Testing \u2014 e2e\\n\\n## Overview\\n\\nThe `e2e` module (npm package: `assureworx-e2e-stability`) is a standalone Playwright test suite dedicated to **stability and rendering verification**. It runs the application under test across three browser engines (Chromium, Firefox, WebKit) and captures rich diagnostic artifacts so that hangs, rendering regressions, and cross-browser inconsistencies can be investigated after the fact.\\n\\nThe suite is deliberately **isolated from the monorepo**: it declares its own `@playwright/test` and `typescript` dev dependencies, imports nothing from other packages, and is not referenced by any other module. The package description states this intent explicitly: *\\\"Playwright stability / rendering suite (isolated from monorepo overrides)\\\"*. This isolation protects Playwright's browser-launching and TypeScript compilation from hoisted dependency overrides elsewhere in the repository.\\n\\nThere are no internal calls, outgoing calls, or incoming calls \u2014 the module is a leaf. Its only coupling to the rest of the system is the HTTP endpoint it targets via `E2E_BASE_URL`.\\n\\n## Module Layout\\n\\n```\\ne2e/\\n\u251c\u2500\u2500 package.json            # Test scripts and isolated dev dependencies\\n\u251c\u2500\u2500 playwright.config.ts    # Runner configuration, projects, reporters, timeouts\\n\u251c\u2500\u2500 tsconfig.json           # Strict TypeScript config for the suite itself\\n\u251c\u2500\u2500 tests/                  # Playwright specs (testDir; not shown here)\\n\u2514\u2500\u2500 artifacts/              # Generated output: reports, traces, videos, screenshots\\n```\\n\\n## Configuration Reference\\n\\n### Test Runner (`playwright.config.ts`)\\n\\n| Setting | Value | Rationale |\\n|---|---|---|\\n| `testDir` | `./tests` | All spec files live in the `tests/` directory. |\\n| `workers` | `1` | Serial execution. Stability tests must not compete for CPU or memory; a hang in one test is never masked by resource contention from parallel workers. Projects also run sequentially. |\\n| `fullyParallel` | `false` | Consistent with single-worker serial execution. |\\n| `retries` | `0` | Failures are treated as real failures \u2014 automated retries would hide exactly the kind of intermittent instability this suite exists to detect. |\\n| `timeout` | `120_000` | Long per-test budget, appropriate for rendering and hang-detection scenarios. |\\n| `expect.timeout` | `15_000` | Assertions wait up to 15s for expected conditions. |\\n| `actionTimeout` | `20_000` | Per-action timeout (clicks, fills, etc.). |\\n| `navigationTimeout` | `60_000` | Page navigations get 60s \u2014 longer than regular actions, since slow renders are a known failure mode. |\\n\\n### Browser Projects\\n\\n| Project | Device profile |\\n|---|---|\\n| `chromium` | Desktop Chrome |\\n| `firefox` | Desktop Firefox |\\n| `webkit` | Desktop Safari |\\n\\nEvery test runs in all three projects. This is what makes the suite a *rendering* verification suite: visual and layout behavior is checked across the three major browser engines.\\n\\n### Diagnostics\\n\\n| Setting | Value | Behavior |\\n|---|---|---|\\n| `trace` | `retain-on-failure` | Playwright zip trace kept for failed tests only. |\\n| `screenshot` | `on` | Screenshot captured after every test step \u2014 produces a visual timeline for both passing and failing runs. |\\n| `video` | `retain-on-failure` | Video recorded for all tests, retained only for failures. |\\n| `outputDir` | `artifacts/test-output` | Raw Playwright output (traces, videos, screenshots) collected here. |\\n\\nThe config comment also notes that *manual CDP traces are captured on hangs* \u2014 this is an operational practice for diagnosing hang scenarios, complementing the automatic Playwright artifacts. If a test hangs without failing, an operator may attach to the browser via the Chrome DevTools Protocol to inspect its state; the long timeouts above exist to give such investigations room to happen before the test is killed.\\n\\n### Reporters\\n\\n```ts\\nreporter: [\\n  [\\\"list\\\"],\\n  [\\\"html\\\", { open: \\\"never\\\", outputFolder: path.join(ARTIFACTS, \\\"html-report\\\") }],\\n  [\\\"json\\\", { outputFile: path.join(ARTIFACTS, \\\"results.json\\\") }],\\n]\\n```\\n\\n- **list** \u2014 human-readable terminal output during the run\\n- **html** \u2014 browsable HTML report, written to `artifacts/html-report/`; never auto-opens (CI-safe)\\n- **json** \u2014 machine-readable results at `artifacts/results.json`, suitable for CI ingestion\\n\\n### TypeScript Configuration (`tsconfig.json`)\\n\\nThe suite's own TypeScript is compiled with `target: ES2022`, `module: commonjs`, `strict: true`, and `esModuleInterop: true`. This ensures the test code and config files are type-checked with the same strictness as the rest of the codebase \u2014 spec files cannot silently drift into untyped territory.\\n\\n## Execution Flow\\n\\n```mermaid\\nflowchart LR\\n    A[npm test] --&gt; B[Playwright runner]\\n    B --&gt; C[chromium project]\\n    B --&gt; D[firefox project]\\n    B --&gt; E[webkit project]\\n    C --&gt; F[tests/ directory]\\n    D --&gt; F\\n    E --&gt; F\\n    F --&gt; G[artifacts/: HTML + JSON reports, traces, videos, screenshots]\\n```\\n\\nThe runner executes all specs in the `tests/` directory once per project, serially. Each project produces its own set of diagnostic artifacts, and all reports are aggregated into the `artifacts/` directory.\\n\\n## Running the Suite\\n\\n```bash\\n# Run all tests in all three browser projects\\nnpm test\\n\\n# Open the last HTML report\\nnpm run test:report\\n```\\n\\nThe equivalent direct commands are `npx playwright test` and `npx playwright show-report`.\\n\\n### Environment Variables\\n\\n| Variable | Default | Purpose |\\n|---|---|---|\\n| `E2E_BASE_URL` | `http://127.0.0.1:3000` | Base URL of the application under test. All relative URLs in specs resolve against this. |\\n\\nThe config sets `baseURL` from this variable, so tests should use relative paths (e.g. `/`, `/some/route`) rather than absolute URLs.\\n\\n**Note:** The config defines no `webServer` option. The suite does **not** start the application \u2014 it expects an instance of the app to already be running and reachable at `E2E_BASE_URL`. This makes the suite portable across local dev servers, CI preview deployments, and staging environments.\\n\\n## Writing Tests\\n\\nSpec files live in `tests/` and are plain Playwright Test specs written in TypeScript. Because the suite runs serially with `retries: 0`, keep the following in mind when adding tests:\\n\\n- **Order is deterministic** within a project, but do not rely on cross-project ordering \u2014 each project is an independent run.\\n- **Use relative URLs** \u2014 the configured `baseURL` handles the target.\\n- **Budget for the long timeouts** \u2014 stability scenarios may legitimately involve slow rendering; the 120s test timeout is the ceiling, not a suggestion to make tests slow.\\n- **Prefer assertions with `expect` polling** \u2014 the 15s `expect.timeout` is generous; `toBeVisible`, `toHaveText`, etc. will poll rather than fail fast.\\n- **Do not depend on external services** beyond the application itself \u2014 the suite is meant to be runnable against any environment where the app is deployed.\\n- **Assume a browser may hang** \u2014 if you write a test that could stall, rely on the reported failure artifacts (traces, videos, screenshots) to communicate what happened. The retained diagnostics are the primary debugging interface for this suite.\\n\\n## Relationship to the Rest of the Codebase\\n\\nThis module sits at the edge of the repository:\\n\\n- **No imports from other modules** \u2014 it is self-contained by design.\\n- **No code imports it** \u2014 nothing depends on the suite at build or runtime.\\n- **The only integration point is the network** \u2014 the application served at `E2E_BASE_URL` is the sole external dependency.\\n\\nThe practical consequence is that this suite can be extracted, moved, or run in a completely separate CI job without affecting \u2014 or being affected by \u2014 the rest of the monorepo. Its isolation is a feature: stability and rendering regressions are measured against a clean, dependency-free Playwright environment, and the results are attributable to the application rather than to test-infrastructure drift.\",\"performance-reliability-testing-helpers\":\"# Performance &amp; Reliability Testing \u2014 helpers\\n\\n# Performance &amp; Reliability Testing Helpers\\n\\nThe `e2e/helpers` module contains the shared instrumentation and flow infrastructure used by the Playwright performance and reliability specs (`stability.spec.ts`, `deep-diagnostics.spec.ts`). It standardizes how the test suite drives the application, captures browser diagnostics, and produces evidence artifacts for any performance or reliability regression it uncovers.\\n\\nThe module is split across two files:\\n\\n| File | Exports | Responsibility |\\n|---|---|---|\\n| `instrument.ts` | `PageInstrument`, metric/issue types, threshold constants | Browser-level instrumentation: console/network/long-task capture, metrics collection, screenshots, CPU tracing |\\n| `flows.ts` | `loginAsDevPersona`, `walkRoutes`, `gotoAndSettle`, `withTracing`, route/viewport/persona constants, `Finding` type | High-level user flows and route-walking logic that builds the `Finding[]` report |\\n\\n```mermaid\\nflowchart LR\\n    Specs[\\\"e2e/tests specs\\\"] --&gt; Flows[\\\"flows.tsgotoAndSettle \u00b7 loginAsDevPersona \u00b7 walkRoutes\\\"]\\n    Specs --&gt; Instrument[\\\"PageInstrumentinstrument.ts\\\"]\\n    Flows --&gt;|\\\"screenshots \u00b7 metrics \u00b7 traces\\\"| Instrument\\n    Instrument --&gt;|\\\"PNG \u00b7 metrics JSON \u00b7 trace JSON\\\"| Artifacts[\\\"runDir artifacts\\\"]\\n    Flows --&gt;|\\\"findings.json\\\"| Artifacts\\n```\\n\\n## Thresholds and Shared Constants\\n\\n`instrument.ts` exports the thresholds that define what counts as a performance or reliability problem:\\n\\n| Constant | Value | Meaning |\\n|---|---|---|\\n| `HANG_MS` | `15_000` | A request still pending after 15s is recorded as a network hang |\\n| `SLOW_PAGE_MS` | `8_000` | Navigation elapsed time above 8s triggers a slow-page finding in `walkRoutes` |\\n| `LONG_TASK_MS` | `100` | Long tasks below 100ms are excluded from captured metrics |\\n\\n`flows.ts` defines the test personas and route inventories:\\n\\n- `PRINCIPAL_EMAIL` \u2014 defaults to `alex@meridian.dev`, overridable via `E2E_EMAIL`.\\n- `PRINCIPAL_ROUTES` \u2014 `/dashboard`, `/trades`, `/projects`, `/requests`, `/support`, `/settings`.\\n- `TRADE_ROUTES` \u2014 `/profile`, `/profile/documents`, `/jobs`, `/requests`, `/billing`, `/support`.\\n- `VIEWPORTS` \u2014 `mobile` (390\u00d7844), `tablet` (768\u00d71024), `desktop` (1440\u00d7900).\\n- `PERSONA_NAMES` \u2014 maps known dev-persona emails to the display names shown on the `/auth/login` persona list.\\n\\n## PageInstrument \u2014 Browser Instrumentation\\n\\n`PageInstrument` is the core data-collection class. Construct it with a Playwright `Page` and an output directory; call `attach()` exactly once per page to start collecting.\\n\\n```ts\\nconst instrument = new PageInstrument(page, runDir);\\nawait instrument.attach();\\n```\\n\\n`attach()` is idempotent \u2014 calling it twice does not double-register listeners.\\n\\n### Captured Signals\\n\\nOnce attached, the instrument records:\\n\\n- **Console errors and warnings** \u2014 messages of type `error` or `warning` are pushed to `consoleIssues` with type, text, source URL, and timestamp.\\n- **Uncaught page errors** \u2014 recorded as `pageerror` entries in `consoleIssues`.\\n- **Failed requests** \u2014 `requestfailed` events become `networkIssues` entries with kind `failed` and the underlying failure text.\\n- **HTTP error responses** \u2014 any response with status \u2265 400 becomes a `networkIssues` entry with kind `http_error`.\\n- **Hung requests** \u2014 every outgoing request gets a 15s timer. If the request has not produced a response or failure when the timer fires, it is recorded as kind `hang`. Completion clears the timer via `clearHang()`.\\n- **Long tasks** \u2014 an init script installs a `PerformanceObserver` for the `longtask` entry type. Recorded tasks are stored on `window.__awxLongTasks` (Chromium/WebKit support varies; the observer is wrapped in try/catch).\\n\\n### Metrics Collection\\n\\n`collectMetrics(label)` evaluates navigation timing, long tasks, and layout overflow in the page, then writes the result to `${runDir}/${label}-metrics.json`. The returned `PageMetrics` object includes:\\n\\n- `loadMs` and `domContentLoadedMs` from the Performance Navigation Timing API\\n- `longTasks` and `maxLongTaskMs` (only tasks \u2265 100ms)\\n- `consoleErrors` and `networkIssues` accumulated so far\\n- `overflowX`, `scrollWidth`, `clientWidth` \u2014 overflow is flagged when `scrollWidth` exceeds `clientWidth + 2`\\n- `heapUsedMB` \u2014 from `performance.memory` when available (Chromium only)\\n- `label` and `url` for correlation\\n\\n### Screenshots and Tracing\\n\\n- `screenshot(label)` \u2014 captures a full-page screenshot to `${runDir}/${label}.png` and returns the file path.\\n- `startTrace(label)` \u2014 starts a Chromium CDP `Tracing` session (categories for timeline, v8 execution, and user timing) and returns a handle, or `null` if CDP is unavailable (e.g., non-Chromium browser).\\n- `stopTrace(handle)` \u2014 stops the trace, collects `Tracing.dataCollected` chunks, writes them to `${runDir}/${label}-trace.json`, and detaches the CDP session. Safe to call with a `null` handle \u2014 it returns `null`.\\n\\n### Reporting\\n\\n- `snapshotIssues()` \u2014 returns a plain object with `consoleErrors`, `consoleWarnings`, `networkIssues`, and `longTasks`.\\n- `writeReport(extra)` \u2014 merges `extra` with the issue snapshot and the threshold constants, writes everything to `${runDir}/issues-snapshot.json`, and returns the merged report.\\n\\n## Flow Helpers\\n\\n### `gotoAndSettle`\\n\\n```ts\\nconst { response, elapsedMs } = await gotoAndSettle(page, \\\"/dashboard\\\");\\n```\\n\\nNavigates with `waitUntil: \\\"domcontentloaded\\\"`, then waits best-effort for `networkidle` (20s timeout, swallowed on failure), followed by a fixed 500ms delay for client-side effects to settle. Returns the response and total elapsed time. Relative URLs resolve against the test's `baseURL`.\\n\\n### `loginAsDevPersona`\\n\\n```ts\\nconst { steps, stillOnLogin, loginPageMs } = await loginAsDevPersona(page, instrument, email);\\n```\\n\\nPerforms a development-persona login with a well-defined fallback chain:\\n\\n1. Navigate to `/auth/login` and capture screenshot/metrics under the `01-login-page` label.\\n2. If the persona list is visible and a matching persona button exists (matched by display name from `PERSONA_NAMES`, or by raw email), click it.\\n3. If the persona is not visible, POST a passwordless login to `/api/v1/auth/login` with `{ email }`. If that fails and an email field is present, fall back to filling the email form and clicking Continue.\\n4. Wait up to 45s for the URL to leave `/auth/*`.\\n5. Wait for the app chrome (`navigation` or `banner` role) to become visible, then wait 1200ms for the LoginReveal curtain to finish.\\n6. Capture screenshot/metrics under `02-after-login`.\\n\\nReturns `{ steps, stillOnLogin, loginPageMs }` \u2014 `steps` is a human-readable trace of which login path was taken, `stillOnLogin` reports whether the flow failed to leave `/auth/*`, and `loginPageMs` is the initial page-load elapsed time.\\n\\n### `walkRoutes`\\n\\n```ts\\nawait walkRoutes(page, instrument, PRINCIPAL_ROUTES, \\\"principal\\\", findings, browserName, viewportName);\\n```\\n\\nThis is the main route-perf scanner. For each route in the list it:\\n\\n1. Computes a stable label: `{prefix}-{NN}-{route}` (e.g., `principal-03-_projects`).\\n2. Snapshot the current console/network issue counts so new issues can be attributed to this route.\\n3. Calls `gotoAndSettle`, records the HTTP status and elapsed time.\\n4. Runs an interaction probe using a double `requestAnimationFrame`; the measured rAF gap reveals main-thread blocking.\\n5. Evaluates the route against every detection rule (below) and pushes findings.\\n\\n#### Finding Rules\\n\\n| Condition | Severity | Evidence |\\n|---|---|---|\\n| Navigation throws or times out | `critical` | screenshot `${label}-crash` |\\n| rAF probe gap &gt; 2000ms (main-thread jank) | `high` | screenshot + CDP trace |\\n| `elapsedMs` &gt; `SLOW_PAGE_MS` (slow load) | `medium` | screenshot + CDP trace |\\n| Response status \u2265 500 | `high` | screenshot `${label}-http-error` |\\n| Response status 400\u2013499 | `medium` | screenshot `${label}-http-error` |\\n| New console `error`/`pageerror` since step start | `high` | screenshot `${label}-console` |\\n| New request hang (&gt; 15s) | `high` | route screenshot |\\n| New failed request / HTTP error (\u2265 500) | `medium` | route screenshot |\\n| `maxLongTaskMs` \u2265 500 | `high` | `${label}-metrics.json` |\\n| `maxLongTaskMs` 200\u2013499 | `medium` | `${label}-metrics.json` |\\n| Horizontal overflow (`scrollWidth` &gt; `clientWidth + 2`) | `medium` | `${label}.png` |\\n\\nOnly the first finding of each type per route is pushed (note the `break` statements) to avoid duplicate screenshot spam. Failed requests for favicons/sourcemaps are filtered out. For jank/slow-load findings, `walkRoutes` starts a CDP trace, waits 800ms, stops the trace, and includes the trace file in the finding's `evidence`.\\n\\n### `withTracing`\\n\\n```ts\\nconst tracing = await withTracing(context, runDir, \\\"stability-run\\\");\\n// ... test body ...\\nawait tracing.stop(success);\\n```\\n\\nWraps the entire test in a Playwright context trace with screenshots, snapshots, and sources enabled. `stop()` writes the trace to `${runDir}/${name}-pw-trace.zip`.\\n\\n## Findings and Output Artifacts\\n\\n`walkRoutes` and `loginAsDevPersona` produce `Finding` objects with a normalized shape:\\n\\n```ts\\ntype Finding = {\\n  id: string;\\n  severity: \\\"critical\\\" | \\\"high\\\" | \\\"medium\\\" | \\\"low\\\";\\n  title: string;\\n  repro: string[];       // steps to reproduce\\n  likelyCause: string;\\n  evidence: string[];    // paths to screenshots/traces\\n  browser?: string;\\n  viewport?: string;\\n  url?: string;\\n};\\n```\\n\\nSpecs collect these into an array and persist them via `findingsPath(runDir)`, which returns the path to `findings.json` in the run directory.\\n\\n### Artifacts written to `runDir`\\n\\n| Artifact | Producer | Content |\\n|---|---|---|\\n| `{label}.png` | `screenshot()` | Full-page screenshot evidence |\\n| `{label}-metrics.json` | `collectMetrics()` | Per-page performance/layout snapshot |\\n| `{label}-trace.json` | `stopTrace()` | CDP main-thread trace chunks |\\n| `{name}-pw-trace.zip` | `withTracing().stop()` | Full Playwright context trace |\\n| `issues-snapshot.json` | `writeReport()` | Aggregated issue counts + thresholds |\\n| `findings.json` | specs via `findingsPath()` | Final structured finding report |\\n\\nFilename labels are sanitized by `sanitize()` \u2014 non-alphanumeric characters become `_` and the label is truncated to 80 characters.\\n\\n## Integration with Test Specs\\n\\nThe two consumers are:\\n\\n- **`stability.spec.ts`** \u2014 creates a `PageInstrument`, calls `attach()`, logs in via `loginAsDevPersona`, runs `walkRoutes` across principal and trade routes at various viewports, then calls `writeReport()` and writes `findings.json` via `findingsPath()`.\\n- **`deep-diagnostics.spec.ts`** \u2014 uses `gotoAndSettle`, `loginAsDevPersona`, `PageInstrument` tracing/start/stop, and `snapshotIssues()` for deeper single-route investigation, including CDP trace capture for suspected hangs.\\n\\nA typical spec setup looks like:\\n\\n```ts\\nconst instrument = new PageInstrument(page, runDir);\\nawait instrument.attach();\\nconst { steps, stillOnLogin } = await loginAsDevPersona(page, instrument);\\n\\nconst findings: Finding[] = [];\\nawait walkRoutes(page, instrument, PRINCIPAL_ROUTES, \\\"principal\\\", findings, browserName, viewportName);\\n// ... persist findings, write report/trace ...\\n```\\n\\nBecause all metrics, screenshots, and traces are keyed by deterministic labels, artifacts from a run can be correlated across the findings report, the issues snapshot, and the raw trace files.\",\"performance-reliability-testing-loadtests\":\"# Performance &amp; Reliability Testing \u2014 loadtests\\n\\n# Performance &amp; Reliability Testing \u2014 `loadtests`\\n\\n## Overview\\n\\nThe `loadtests` module contains the performance, reliability, and security validation tooling for the Assureworx API. Its primary artifact is a k6 script (`user-journey.js`) that drives a realistic end-to-end user session against the API \u2014 login, authenticated reads, logout \u2014 under three escalating load profiles. The module also holds pentest/security scripts and the evidence artifacts (results tables, Azure metric exports, probe logs) produced during test runs.\\n\\nThe module's job is to answer two questions:\\n\\n1. **Is the API healthy under expected load?** \u2014 via the baseline and load stages.\\n2. **Where and how does it break under stress?** \u2014 via the stress stage, which deliberately pushes VU counts until abort thresholds trip or the API fails.\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    VU[k6 VUuser-journey.js] --&gt; L[loginPOST /api/v1/auth/login]\\n    L --&gt; M[meGET /api/v1/auth/me]\\n    M --&gt; D[dashboardGET /api/v1/reporting/dashboard]\\n    D --&gt; P[projectsGET /api/v1/nodes/projects]\\n    P --&gt; R[requestsGET /api/v1/requests]\\n    R --&gt; N[notificationsGET /api/v1/notifications]\\n    N --&gt; O[logoutPOST /api/v1/auth/logoutbaseline only]\\n    API[API server] --&gt; PG[(PostgreSQL)]\\n    API --&gt; RQ[(Redisoptional)]\\n```\\n\\nThe k6 script is the entry point. Each virtual user executes the journey sequentially; the `STAGE` environment variable selects the load profile, and `AUTH_MODE` selects whether logout is part of the journey.\\n\\n## The k6 user journey (`user-journey.js`)\\n\\nThe script models a authenticated user session against these endpoints, in order:\\n\\n| Step | Endpoint | Purpose |\\n| --- | --- | --- |\\n| 1 | `POST /api/v1/auth/login` | Authenticate with `{ email }` (dev auth) |\\n| 2 | `GET /api/v1/auth/me` | Resolve the session principal |\\n| 3 | `GET /api/v1/reporting/dashboard` | Heaviest read \u2014 the first endpoint to degrade under concurrency |\\n| 4 | `GET /api/v1/nodes/projects` | Project list |\\n| 5 | `GET /api/v1/requests` | Request list |\\n| 6 | `GET /api/v1/notifications` | Notification list |\\n| 7 | `POST /api/v1/auth/logout` | End the session \u2014 baseline only |\\n\\n### Key functions\\n\\n- **`ensureSession`** \u2014 establishes a valid session cookie before the journey can proceed; used internally by the login path.\\n- **`login`** \u2014 performs the login POST and extracts the session cookie via **`extractSessionCookie`** for subsequent authenticated requests.\\n- **`hit`** \u2014 the low-level HTTP request wrapper used by `login` and the journey steps; it also feeds k6 check/error metrics.\\n- **`thinkTime`** \u2014 inserts a delay between steps to simulate human pacing. Baseline journey wall time (~66 ms) is reported excluding this delay.\\n\\n### Auth modes\\n\\nThe script honors an `AUTH_MODE` environment variable:\\n\\n- **`journey`** \u2014 full flow including logout. Used by the baseline stage (1 VU).\\n- **`session`** \u2014 log in once per VU, then repeat the authenticated reads without logout. Used by load and stress stages.\\n\\nSession mode exists because logout increments `session_ver`, which revokes every other VU sharing the same actor (all VUs authenticate as the same seeded principal, e.g. `alex@meridian.dev`). Running logout under load would cause mass session revocation and fake the error rate.\\n\\n## Test stages\\n\\n`STAGE` is passed with `-e STAGE=...` on the k6 command line.\\n\\n| Stage | Profile | Purpose |\\n| --- | --- | --- |\\n| `baseline` | 1 VU, 3 iterations, `AUTH_MODE=journey` | Sanity check; per-endpoint latency floor |\\n| `load` | Ramp to 100 VU / 30 s, hold 2 min, `AUTH_MODE=session` | Proves sustained health at expected concurrency |\\n| `stress` | Ramp 100 \u2192 200 \u2192 400 \u2192 800 \u2192 1200 VU | Finds the breaking point; aborts when `http_req_failed` exceeds 25% (`abortOnFail`) |\\n\\nThe stress stage is designed to fail. The abort threshold prevents the run from wasting time once the API is clearly collapsing, and the failure mode itself (connection refused vs. 5xx vs. 429) is the diagnostic signal.\\n\\n## Running the tests\\n\\n### Prerequisites\\n\\n- k6 installed (`winget install GrafanaLabs.k6`)\\n- API running on `:4000` with `ALLOW_DEV_AUTH=1` and a seeded principal (default `alex@meridian.dev`)\\n- For local runs past ~40 concurrent logins, set in `backend/.env`:\\n\\n```env\\nLOAD_TEST_RELAX_LIMITS=1\\nDB_POOL_MAX=80\\n```\\n\\n`LOAD_TEST_RELAX_LIMITS` only applies when `ALLOW_DEV_AUTH=1` \u2014 never on Entra-only Azure tiers.\\n\\n**Important:** prefer a non-watch API process during stress runs:\\n\\n```bash\\nnpx tsx --env-file=.env src/server.ts\\n```\\n\\n`tsx watch` restarts cause connection resets that fake failures.\\n\\n### Commands\\n\\n```bash\\n# Baseline \u2014 1 VU, full login \u2192 shell \u2192 logout\\nk6 run -e STAGE=baseline -e BASE_URL=http://localhost:4000 loadtests/user-journey.js\\n\\n# Load \u2014 ramp to 100 VU / 30s, hold 2m (session mode: login once per VU)\\nk6 run -e STAGE=load -e BASE_URL=http://localhost:4000 loadtests/user-journey.js\\n\\n# Stress \u2014 ramp 100 \u2192 200 \u2192 400 \u2192 800 \u2192 1200 until abort thresholds\\nk6 run -e STAGE=stress -e BASE_URL=http://localhost:4000 loadtests/user-journey.js\\n```\\n\\nPoint at a deployed API when scripted auth is available:\\n\\n```bash\\nk6 run -e STAGE=baseline -e BASE_URL=https://api.beta.assureworx.com.au -e ORIGIN=https://beta.assureworx.com.au loadtests/user-journey.js\\n```\\n\\n`ORIGIN` is passed through for CORS/origin validation on deployed environments.\\n\\n## Pentest scripts\\n\\nThe module also contains a set of standalone Node scripts (`.mjs`) for security probing. They share a small utility surface and are run individually \u2014 they are not part of the k6 flow.\\n\\n| Script | Focus |\\n| --- | --- |\\n| `pentest-jwt-pg-chain.mjs` | JWT forgery \u2192 PostgreSQL chain probing |\\n| `pentest-redis-sqlmap.mjs` | Redis + SQL injection surface probing |\\n| `pentest-route-extract.mjs` | Route enumeration via recursive `walk` |\\n| `pentest-curl-proofs.mjs` | Curl-based proof generation for findings |\\n| `pentest-live-probes.mjs` | Live endpoint, TCP, and Redis probes |\\n| `pentest-deep-probes.mjs` | Deeper probing with `signHs256` JWT signing |\\n\\n### Shared utilities\\n\\n- **`sign`** / **`signHs256`** \u2014 build HS256-signed JWTs for auth-bypass attempts; both delegate to **`b64url`** for base64url encoding.\\n- **`b64url`** \u2014 base64url encode/decode helper.\\n- **`probe`** / **`rawRequest`** \u2014 HTTP request helpers for one-off probes.\\n- **`redisPing`** / **`redisCmd`** / **`redis`** \u2014 Redis connectivity and command execution probes.\\n- **`tcpProbe`** \u2014 raw TCP connectivity check.\\n- **`parseCookies`** / **`cookieHeader`** \u2014 session-cookie extraction and header construction.\\n- **`walk`** \u2014 recursive route-tree traversal used by `pentest-route-extract.mjs`.\\n- **`finding`** / **`note`** / **`log`** \u2014 output helpers for structured findings.\\n\\n## Results &amp; evidence artifacts\\n\\nThe module tracks run artifacts alongside the scripts:\\n\\n- **`RESULTS.md`** \u2014 the canonical results report for the 2026-08-04 local run, including baseline, load, and stress outcomes, per-endpoint latency tables, Azure correlation data, and the list of fixes landed.\\n- **`azure-aca-metrics.json`** \u2014 Azure Container App metric export (Requests, Replicas, ResponseTime, CPU, RestartCount) captured during the Azure probe window.\\n- **`azure-pg-metrics.json`** \u2014 Azure PostgreSQL Flexible Server metrics (CPU percent, memory percent, succeeded/failed/active connections).\\n- **`azure-health-burst.txt`** / **`azure-health-probe.txt`** \u2014 raw cold-start and warm health-probe output against the deployed DEV and TEST APIs.\\n- **`assurance-fix-evidence-20260806-191548.txt`** \u2014 TAP output for 42 unit tests across assurance sweep config, readiness derivation, identity currency/consent, retention config, erasure policy, and SMS reliability guards, plus Defender for Storage evidence and geocode-persistence code references.\\n\\n## Known bottlenecks &amp; landed fixes\\n\\nThe results report identifies two distinct bottleneck classes:\\n\\n### Local (the 2026-08-04 run)\\n\\n- **PostgreSQL connection termination** \u2014 Postgres killed connections under pressure (error `57P01`); the Node process had no `client.on('error')` handler and crashed, producing TCP connection refusals at ~400 VUs. The stress run aborted at 26.2% error rate with 31,826 connection-refused failures and zero 5xx/429 responses.\\n- **Dashboard query latency** \u2014 `GET /api/v1/reporting/dashboard` was the first endpoint to degrade under concurrency (p95 266 ms at 100 VUs, ~577 ms at the stress ceiling), making it the primary query-level bottleneck.\\n\\n**Ceiling established:** ~200\u2013400 concurrent VUs on the local laptop + local Postgres; 100 VUs sustained for 2 minutes is healthy.\\n\\n### Azure (deployed)\\n\\n- **Scale-to-zero cold start** \u2014 DEV `/health` first hit **26.5s**, TEST **23.5s**; warm `/health` is ~20 ms (n=40: min 18 / med 22 / max 26 ms). This dominates first-request latency after idle and is the biggest deployed bottleneck.\\n- **No application telemetry** \u2014 the Node API was not instrumented with App Insights / OTel, so `AppRequests` was empty for 6h; local k6 results could not be correlated with Azure platform telemetry.\\n\\n### Fixes landed (same day)\\n\\n| Issue | Fix |\\n| --- | --- |\\n| pg `57P01` killed Node | `pool.on('error')` + checked-out client listeners; connect/query/statement timeouts in `backend/src/shared/db/pool.ts` |\\n| No app telemetry in Azure | `@azure/monitor-opentelemetry` added in `backend/src/shared/telemetry.ts`; Key Vault + secretref on DEV/TEST APIs; deploy scripts synced |\\n\\nThe telemetry SDK only takes effect in the cloud after the API image that includes it is deployed.\\n\\n## Relationship to the codebase\\n\\nThe `loadtests` module sits adjacent to the backend, not inside it:\\n\\n- **`backend/src/server.ts`** \u2014 the target process under test; run with `npx tsx --env-file=.env src/server.ts` during stress runs.\\n- **`backend/.env`** \u2014 host for `LOAD_TEST_RELAX_LIMITS` and `DB_POOL_MAX`.\\n- **`backend/src/shared/db/pool.ts`** \u2014 the pg connection pool whose error-handling and timeout fixes were driven by the stress findings.\\n- **`backend/src/shared/telemetry.ts`** \u2014 the Azure OTel instrumentation added as a direct result of the missing-telemetry finding.\\n- **`ALLOW_DEV_AUTH`** \u2014 the dev-auth flag that gates whether the k6 login flow works at all; it also gates `LOAD_TEST_RELAX_LIMITS`.\\n\\nThe evidence artifacts pull from Azure platform resources \u2014 `Microsoft.App/containerApps` and `Microsoft.DBforPostgreSQL/flexibleServers` \u2014 as well as `Microsoft.Security/defenderForStorageSettings` from the assurance fix evidence. Re-runs against staging should query `appi-aw-test-aue` / `log-aw-test-aue` for `AppRequests` / `AppDependencies` now that the API emits telemetry.\",\"performance-reliability-testing-src\":\"# Performance &amp; Reliability Testing \u2014 src\\n\\n# Performance &amp; Reliability Testing (frontend/src)\\n\\n## Overview\\n\\nThe `src` performance module is a dev-only instrumentation and analysis harness for the Assureworx frontend. It records React render timings, network requests, section visibility, route changes, clicks, and manual markers into a single in-memory store (`PerfStore`), then aggregates that data into per-path and per-component statistics and a prioritized list of findings such as request storms, duplicate bursts, chatty endpoints, slow renders, remount churn, and inactive-section leaks.\\n\\nTwo surfaces consume the store:\\n\\n- **Interactive UI** \u2014 a floating `PerfRecorder` panel (dev only) for start/stop recording, live metrics, tabular inspection, synthetic stress simulation, and JSON export.\\n- **Headless scripts** \u2014 `selftest.ts`, `localhost-stress.ts`, `prod-scenarios.ts`, and `post-fix-scenario.ts` exercise the analysis engine against simulated or live traffic and exit non-zero on regression.\\n\\nEverything is gated on `NODE_ENV === \\\"development\\\"`. In production builds, all tracking methods no-op and the UI components render `null` or pass children through untouched, so the installed cost is effectively zero.\\n\\n## Architecture\\n\\n```mermaid\\nflowchart LR\\n    subgraph Instrumentation\\n        P[PerfProfiler]\\n        S[PerfSection]\\n        A[api.ts / use-api.ts]\\n    end\\n    P --&gt;|trackRender| Store[(PerfStore)]\\n    S --&gt;|setSection + trackRender| Store\\n    A --&gt;|beginNetwork / trackNetworkInstant / trackMarker| Store\\n    Store --&gt;|snapshot| Findings[findings.ts]\\n    Findings --&gt;|buildPathStats / buildComponentStats / analyseFindings| Store\\n    Store --&gt;|subscribe + snapshot| UI[PerfRecorder panel]\\n    Store --&gt;|runStressSimulation| UI\\n    CLI[selftest / localhost-stress / prod-scenarios] --&gt;|events| Findings\\n```\\n\\nThe data flow is one-directional: instrumentation call sites push events into `PerfStore`; the store derives statistics and findings lazily in `snapshot()`; the UI re-renders via `useSyncExternalStore` whenever the store emits. The analysis functions in `findings.ts` are pure and shared by the store, the selftest, and the CLI scenario scripts.\\n\\n## Instrumentation Components\\n\\n### `PerfProfiler`\\n\\nA thin wrapper around React's `` that forwards commit timings into the store.\\n\\n```tsx\\n\\n  {children}\\n&lt;\\/PerfProfiler&gt;\\n```\\n\\nThe `onRender` callback passes `phase`, `actualDuration`, and `baseDuration` to `perf.trackRender()`. When `active` is `false`, commits are still recorded but flagged as inactive \u2014 this is what powers the \\\"Inactive section still rendering\\\" finding. In non-development builds, `PerfProfiler` returns `{children}` without any Profiler overhead.\\n\\n### `PerfSection`\\n\\nMarks a page region for visibility tracking and wraps it in a `PerfProfiler` automatically.\\n\\n- Registers the section with `perf.setSection(id, { visible, active })` via an `IntersectionObserver` (threshold `0.05`) so off-screen sections are distinguishable from on-screen ones.\\n- Sets `data-perf-section={id}` and `data-perf-active` attributes on the DOM node for click attribution.\\n- Always sets the initial state to `visible: true` before the observer first fires.\\n- The profiler id becomes `Section:${id}` (e.g. `Section:profile-work`), so render findings reference the section.\\n\\n`PerfSection` is used by page-level clients such as `ProfileClient` to wrap tab panels. `PerfProfiler` alone is used for shell and page containers (`AppShell`, `DashboardClient`, `ProfileClient`, `ActivityRail`).\\n\\n### `PerfRecorderHost`\\n\\nThe dev-only mount point, rendered from `RootLayout`. It dynamically imports `PerfRecorder` with `ssr: false` so the recorder never appears in server HTML or production client bundles.\\n\\nThe host currently returns `null` unconditionally: a temporary `PERF_RECORDER_ENABLED = false` flag disables the floating chip. Flip that flag to `true` to restore the recorder in development.\\n\\n### `PerfRecorder`\\n\\nThe floating UI panel. When closed it renders a single FAB button showing recording state and live event counts. When open it shows:\\n\\n- **Metrics row** \u2014 requests/sec, renders/sec, in-flight requests, elapsed time.\\n- **Tabs** \u2014 `findings`, `network`, `renders`, `stress`.\\n- **Actions** \u2014 Record/Stop, Clear, Close, Export JSON.\\n\\nThe panel subscribes to the store with `useSyncExternalStore`, using `perf.subscribe()` for the client snapshot and a static `emptySnapshot` for server rendering. It also installs a capture-phase document click listener that feeds `perf.trackClick()` for clicks outside the panel, using `data-perf-section`, `aria-label`, or the tag name as the target label.\\n\\nThe **stress tab** exposes preset buttons for `perf.runStressSimulation()`: full stress, 100 uploads, 100 clicks, and an idle-section leak pattern.\\n\\n## The `PerfStore` (`lib/perf/store.ts`)\\n\\n`PerfStore` is a singleton class exported as `perf` from `@/lib/perf`. It is a pub/sub event buffer with lazy snapshot derivation.\\n\\n### Lifecycle\\n\\n| Method | Behavior |\\n|---|---|\\n| `perf.start()` | Begins recording: clears the event buffer, resets counters, records a `recording-start` marker. |\\n| `perf.stop()` | Records a `recording-stop` marker, stops accepting events. |\\n| `perf.clear()` | Drops all events and resets `inFlight`/`droppedEvents`; if still recording, restarts the elapsed clock. |\\n\\nRecording state is double-gated: `isRecording()` returns `true` only when `NODE_ENV === \\\"development\\\"` **and** recording is active. The `enabled` getter is evaluated lazily so tests can flip `NODE_ENV` before exercising the store.\\n\\n### Event recording methods\\n\\n| Method | Event kind | Source |\\n|---|---|---|\\n| `trackRender({ idLabel, phase, actualDurationMs, baseDurationMs, active })` | `render` | `PerfProfiler` |\\n| `beginNetwork({ method, path, source, cache })` \u2192 `{ end(result) }` | `network` | `api.ts` request/upload |\\n| `trackNetworkInstant({ method, path, source, cache, durationMs, status })` | `network` | `use-api.ts` (dedupe / seed-skip / TTL hit) |\\n| `trackClick(target, simulated)` | `click` | `PerfRecorder` listener, stress sim |\\n| `setSection(sectionId, { visible, active })` | `section` | `PerfSection` |\\n| `setPathname(pathname)` | `nav` | `PerfRecorder` via `usePathname` |\\n| `trackMarker(label, detail?)` | `marker` | `prefetchApi`, `upload`, stress sim |\\n\\n`beginNetwork` is the async pair: it increments `inFlight`, pushes an `inflight` event, and returns an `end()` callback that patches the event with duration/status/error and balances the in-flight counter. The `end()` implementation deliberately decrements `inFlight` even if recording stopped mid-request \u2014 an earlier version gated it on recording state and stranded the counter above zero.\\n\\n### Buffer and backpressure\\n\\nThe store caps the event buffer at `MAX_EVENTS = 5000`. When the cap is exceeded, the oldest events are spliced out and `droppedEvents` is incremented. Because rates divide by elapsed time, discarding old events would silently understate request/render rates; `droppedEvents` is surfaced in the UI (\\\"buffer full \u2014 rates understated\\\") and in the snapshot so partial windows are identifiable.\\n\\n### Snapshot and subscription\\n\\n`snapshot()` derives everything on demand:\\n\\n- `pathStats` via `buildPathStats(events)`\\n- `componentStats` via `buildComponentStats(events)`\\n- `findings` via `analyseFindings(events, elapsedMs, thresholds)`\\n- Rate metrics: `requestsPerSec` (excluding `deduped`, `seed-skip`, and `hit` events), `rendersPerSec`, `inFlight`\\n\\nThe snapshot is cached (`cachedSnapshot`) and invalidated on every `emit()`. This stability contract matters for `useSyncExternalStore`: `getSnapshot` must return a referentially stable object until the store actually changes. `emit()` schedules listener notification in a microtask, coalescing multiple synchronous event pushes into one re-render.\\n\\n`exportJson()` serializes the thresholds, the current snapshot, and the full raw event list for offline analysis.\\n\\n### `runStressSimulation`\\n\\nSynthetic load that never touches the network. It generates:\\n\\n- `concurrentGets` network events against `/reporting/dashboard` and `/directory`, with every third request marked `deduped`;\\n- `uploadWorkers` POSTs to `/artifacts` plus `progressTicksPerUpload` render events per worker labelled `EvidenceUploadForm#N`;\\n- `clicks` click events with interspersed `ProfileClient` renders;\\n- `inactiveSectionRenders` renders under an explicitly deactivated `profile-work` section;\\n- a remount-churn block: five `AppShell` mount events each paired with duplicate `/reporting/dashboard` and `/notifications` GETs.\\n\\nThe simulation is designed to trip every finding category so the findings tab and the selftest can validate the engine end-to-end.\\n\\n## Findings Engine (`lib/perf/findings.ts`)\\n\\n`findings.ts` contains pure, browser-free functions \u2014 the selftest and CLI scripts depend on this.\\n\\n### Statistics builders\\n\\n- `networkEvents(events)` / `renderEvents(events)` \u2014 type-guarded filters.\\n- `buildPathStats(events)` \u2014 aggregates per-path request counts, errors, deduped/seed-skip counts, avg/max duration.\\n- `buildComponentStats(events)` \u2014 aggregates per-`idLabel` render counts, mount/update split, inactive renders, total/max actual duration.\\n\\n### `analyseFindings(events, elapsedMs, thresholds)`\\n\\nRuns a fixed pipeline of checks over the recording window and returns findings sorted by severity (critical \u2192 warn \u2192 info). The elapsed window is floored at 1 second so a short cold-paint recording with a couple of prefetches doesn't register as a request storm.\\n\\n| Check | Finding ID(s) | Trigger |\\n|---|---|---|\\n| Request rate | `request-rate-critical` / `request-rate-warn` | non-cached requests/sec \u2265 `requestRateCritical` / `requestRateWarn` |\\n| Duplicate bursts | `dup-${path}` | \u2265 2 same method+path trips within `duplicateWindowMs`, excluding uploads and cache-hit/deduped/seed-skip events |\\n| Chatty endpoint | `chatty-${path}` | path request count \u2265 `chattyPathWarn` (skips `/artifacts`) |\\n| Inactive rendering | `inactive-${idLabel}` | `inactiveRenders` \u2265 `inactiveRenderWarn` |\\n| Slow render | `slow-${idLabel}` / `jank-${idLabel}` | peak `actualDurationMs` \u2265 `renderMsCritical` / `renderMsWarn` |\\n| Upload burst | `upload-burst` | \u2265 20 upload/artifact network events (\u2265 50 critical) |\\n| Click storm | `click-storm` | \u2265 40 clicks at \u2265 10 clicks/sec (info-level) |\\n| Remount churn | `remount-${idLabel}` | `mounts` \u2265 4 (\u2265 8 critical) |\\n| Clean | `clean` | no threshold breaches and \u2265 1 event |\\n\\n### Thresholds\\n\\nExposed as `DEFAULT_THRESHOLDS` and overridable at runtime via `perf.setThresholds()` or per-call in `analyseFindings()`.\\n\\n| Threshold | Default | Meaning |\\n|---|---|---|\\n| `duplicateWindowMs` | 1500 | duplicate detection window for identical method+path |\\n| `requestRateWarn` | 8 | warn when real req/s \u2265 8 |\\n| `requestRateCritical` | 20 | critical when real req/s \u2265 20 |\\n| `renderMsWarn` | 16 | render over one frame budget (~60fps) |\\n| `renderMsCritical` | 50 | blocking render |\\n| `inactiveRenderWarn` | 5 | inactive/off-screen commits before flagging |\\n| `chattyPathWarn` | 6 | requests to one path before flagging |\\n\\n## Verification Scripts\\n\\nAll scripts run with `tsx` from the `frontend` directory.\\n\\n### `selftest.ts`\\n\\nHeadless self-test covering the findings engine and the stress simulation:\\n\\n- `testFindingsEngine()` \u2014 feeds a synthetic event batch containing a duplicate dashboard GET pair, five inactive `Section:profile-work` renders, and a 22ms render; asserts the corresponding `dup-`, `inactive-`, and `jank-`/`slow-` findings are produced.\\n- `testStressSimulation()` \u2014 forces `NODE_ENV=development`, runs the full stress sim, and asserts event counts and the presence of critical/warn, inactive-section, and chatty/duplicate/request findings.\\n\\nExit code is non-zero on any failed assertion.\\n\\n### `localhost-stress.ts`\\n\\nA live stress test against the real BFF + API (`npm run perf:localhost -w frontend`). Prerequisites: backend on `:4000`, frontend on `:3000`, `ALLOW_DEV_AUTH=1`, seeded DB.\\n\\nFlow:\\n\\n1. **Health check** \u2014 pings `ORIGIN` and `${API}/auth/entra/status`, aborting if either is unreachable.\\n2. **Login** \u2014 POSTs to `/api/v1/auth/login` with `STRESS_EMAIL` (default `alex@meridian.dev`) and an optional `DEV_AUTH_SECRET` header; extracts the session cookie.\\n3. **Preflight** \u2014 a GET to `/reporting/dashboard` must succeed, proving the session is authorized as a principal.\\n4. **Wave A** \u2014 soft-nav simulation: 5 \\\"page visits\\\" \u00d7 7 shell+page endpoints (auth/me, notifications, entra status, dashboard, projects, directory, requests) at concurrency 8.\\n5. **Wave B** \u2014 directory click storm: 100 GETs to `/directory` at concurrency 20, with intermittent search queries.\\n6. **Wave C** \u2014 dashboard hammer: 50 concurrent GETs to `/reporting/dashboard` at concurrency 25 (dual-subscribe regression check).\\n\\nIt reports request totals, error/429 counts, wall time, p50/p95/p99 latency, per-path averages/maxima, sample failures, and the findings-engine output. The process fails if error rate exceeds 5% or any 429s are observed.\\n\\n### `prod-scenarios.ts`\\n\\nModels four production-realistic sessions (no Strict Mode doubles) and runs each through the analysis pipeline:\\n\\n| Scenario | Audience | Key assertions |\\n|---|---|---|\\n| `cold-dashboard` | First authenticated paint | server seed-skips page data; shell prefetches occur once |\\n| `soft-nav-profile` | Production soft navigation | `AppShell` updates rather than remounts; shell chrome served from TTL cache; inactive sections unmount |\\n| `trades-seeded` | Directory with server seed | default seed-skip; filter changes abort in-flight requests |\\n| `upload-crew-20` | 20 concurrent evidence uploads | one POST per upload; throttled progress keeps render count bounded |\\n\\nThe script exits non-zero if any scenario produces a critical finding.\\n\\n### `post-fix-scenario.ts`\\n\\nA single ~8-second dashboard \u2192 profile \u2192 tab-switch \u2192 upload session modeled *after* the documented hotspot fixes. It prints the same stats as `prod-scenarios` plus targeted expectations: exactly one `/reporting/dashboard` GET, zero inactive section renders, and throttled `EvidenceUploadForm` renders (~13 instead of hundreds). It exits non-zero on critical findings.\\n\\n## Integration with the Rest of the Codebase\\n\\nThe store is consumed from four places outside this module:\\n\\n- **`src/lib/api.ts`** \u2014 `request()` and `upload()` wrap their fetches with `perf.beginNetwork(...)` and call the returned `end({ status, error, cache })` on settlement. `upload()` also emits a start marker via `perf.trackMarker()`.\\n- **`src/lib/use-api.ts`** \u2014 `useApi`/`sharedGet` call `perf.trackNetworkInstant()` for events that never cross the network (client TTL `hit`, concurrent-request `deduped`, server-seeded `seed-skip`). `prefetchApi` emits a marker.\\n- **Components** \u2014 `AppShell`, `DashboardClient`, `ProfileClient`, and `ActivityRail` wrap their trees in `PerfProfiler`. `ProfileClient` uses `PerfSection` for its tab panels (`profile-status`, `profile-work`, `profile-details`).\\n- **`RootLayout`** \u2014 mounts `PerfRecorderHost`, which dynamically loads `PerfRecorder` in development only.\\n\\n`lib/perf/index.ts` is the barrel export: it re-exports the `perf` singleton, all public analysis functions, and the full set of types (`PerfEvent`, `PerfSnapshot`, `PerfFinding`, `PerfThresholds`, etc.).\\n\\n## Production Behavior\\n\\nThe module is designed to be compiled out of production:\\n\\n- `PerfStore.enabled` is `false` unless `NODE_ENV === \\\"development\\\"`, and every public tracking method early-returns when recording is off. `start()` and `clear()` no-op outside dev.\\n- `PerfProfiler` and `PerfSection` render their children directly without `Profiler` or `IntersectionObserver` in production.\\n- `PerfRecorderHost` returns `null` and `PerfRecorder` is loaded via `next/dynamic` with `ssr: false`, so it never ships in server HTML or the initial client bundle.\\n\\nThe only production-visible cost is the `NODE_ENV` comparison in the tracking methods invoked from `api.ts` and `use-api.ts`.\",\"performance-reliability-testing-tests\":\"# Performance &amp; Reliability Testing \u2014 tests\\n\\n# Performance &amp; Reliability Testing \u2014 `e2e/tests`\\n\\nThis module contains the Playwright-based performance and reliability regression suite for the Assureworx frontend. It exercises public and authenticated routes across multiple viewports, instruments the browser for heap usage, long tasks, layout overflow, and network failures, then produces structured `Finding` records that are written to per-run artifact directories and merged into a global rollup file.\\n\\nThe module intentionally lives in two spec files with different mandates:\\n\\n| File | Mandate |\\n|---|---|\\n| `stability.spec.ts` | Broad stability net: public route loads, auth flows, responsive layout matrix, memory-leak probe, interaction freeze detection |\\n| `deep-diagnostics.spec.ts` | Chromium-only deep probes: CDP heap statistics, `PerformanceObserver` long tasks, layout overflow scanning, map tile failures |\\n\\nBoth files share the same helpers, finding model, and artifact layout.\\n\\n---\\n\\n## Module Layout\\n\\n```\\ne2e/\\n\u251c\u2500\u2500 tests/\\n\u2502   \u251c\u2500\u2500 stability.spec.ts          # Cross-browser stability suites\\n\u2502   \u2514\u2500\u2500 deep-diagnostics.spec.ts   # Chromium/CDP-only deep diagnostics\\n\u251c\u2500\u2500 helpers/\\n\u2502   \u251c\u2500\u2500 flows.ts                   # Auth, navigation, route-walking, finding plumbing\\n\u2502   \u2514\u2500\u2500 instrument.ts              # PageInstrument: metrics, console, network, traces, screenshots\\n\u2514\u2500\u2500 artifacts/\\n    \u251c\u2500\u2500 {browser}/\\n    \u2502   \u2514\u2500\u2500 {suite}/\\n    \u2502       \u2514\u2500\u2500 {timestamp}/\\n    \u2502           \u251c\u2500\u2500 findings.json\\n    \u2502           \u251c\u2500\u2500 heap-series.json / deep-series.json\\n    \u2502           \u251c\u2500\u2500 *.png (screenshots)\\n    \u2502           \u251c\u2500\u2500 *.trace (CDP traces)\\n    \u2502           \u2514\u2500\u2500 report.json or similar (from writeReport)\\n    \u2514\u2500\u2500 all-findings.json          # Global rollup, merged by every suite\\n```\\n\\n`findingsPath(runDir)` (from `helpers/flows.ts`) resolves the `findings.json` path inside a run directory.\\n\\n---\\n\\n## Shared Dependencies\\n\\n### `PageInstrument` (`helpers/instrument.ts`)\\n\\nEvery test constructs a `PageInstrument` per run directory and calls `attach()` before any navigation. The instrument provides:\\n\\n- `screenshot(label)` \u2014 captures a PNG into the run directory\\n- `collectMetrics(label)` \u2014 returns metrics including `heapUsedMB` and `overflowX`\\n- `startTrace(name)` / `stopTrace(trace)` \u2014 CDP performance tracing, returns the trace file path\\n- `snapshotIssues()` \u2014 returns `{ networkIssues: Array&lt;{ kind, url, method, status }&gt; }`\\n- `writeReport(meta)` \u2014 writes a suite report with metadata such as `{ suite, heaps, email }`\\n- `consoleIssues` \u2014 accumulated console errors/page errors, filtered during assertions\\n\\nThe module also exports `SLOW_PAGE_MS`, the threshold (in milliseconds) used to flag slow page loads.\\n\\n### Flow helpers (`helpers/flows.ts`)\\n\\n- `loginAsDevPersona(page, instrument, email)` \u2014 performs passwordless dev login; returns `{ stillOnLogin, steps, loginPageMs }`\\n- `gotoAndSettle(page, route)` \u2014 navigates and waits for network idle; returns `{ elapsedMs, response }`\\n- `walkRoutes(page, instrument, routes, label, findings, browserName, viewport)` \u2014 iterates routes with per-route metrics, overflow checks, and screenshot capture; appends findings\\n- `findingsPath(runDir)` \u2014 resolves `findings.json`\\n- Constants: `PRINCIPAL_EMAIL`, `PRINCIPAL_ROUTES`, `VIEWPORTS` (desktop, tablet, mobile \u2014 each with `name`, `width`, `height`)\\n\\n---\\n\\n## Findings Model\\n\\nAll issues are recorded as `Finding` objects with a consistent shape:\\n\\n```ts\\n{\\n  id: string;          // stable, route- or probe-scoped (e.g. \\\"layout-overflow-/dashboard\\\")\\n  severity: \\\"critical\\\" | \\\"high\\\" | \\\"medium\\\" | \\\"low\\\";\\n  title: string;\\n  repro: string[];     // human-readable reproduction steps\\n  likelyCause: string;\\n  evidence: string[];  // paths to screenshots, traces, or JSON snapshots\\n  browser: string;\\n  url?: string;\\n  viewport?: string;\\n}\\n```\\n\\nSeverity conventions across the module:\\n\\n| Severity | Used for |\\n|---|---|\\n| `critical` | Login failures that block the suite |\\n| `high` | Console errors, HTTP 5xx, network hangs &gt;15s, heap growth leaks, long tasks \u2265500ms, interaction freezes |\\n| `medium` | Slow loads, horizontal overflow, layout overflow, missing map tiles, long tasks 200\u2013500ms |\\n| `low` | Mobile placeholder truncation |\\n\\n---\\n\\n## `deep-diagnostics.spec.ts` \u2014 Chromium/CDP Deep Probes\\n\\nThe entire file is a single `test.describe(\\\"Deep diagnostics (chromium)\\\")` block with a `test.skip` guard for non-Chromium browsers. It runs one long test that performs a full instrumented pass over the principal routes.\\n\\n### Execution flow\\n\\n1. **Setup** \u2014 creates a timestamped run directory, attaches `PageInstrument`, logs in as `PRINCIPAL_EMAIL`. If login fails, writes a `deep-login-failed` critical finding and exits early.\\n\\n2. **CDP session** \u2014 opens `newCDPSession(page)`, enables `Performance` and `HeapProfiler` domains.\\n\\n3. **Route sweep** \u2014 for each of `/dashboard`, `/trades`, `/projects`, `/requests`, `/support`, `/settings`:\\n   - Navigate via `gotoAndSettle`\\n   - Inject a `PerformanceObserver` for long task entries into `window.__awxLT` (idempotent)\\n   - Force layout work with mouse wheel scrolling\\n   - Sample `Runtime.getHeapUsage` before and after `HeapProfiler.collectGarbage`\\n   - Run a computed-layout scan via `page.evaluate`: collects up to 8 overflowing nodes, document-level horizontal overflow, long task durations, failed images, and Leaflet map tile counts\\n   - Capture a screenshot\\n\\n### Findings produced\\n\\n- **`layout-overflow-{route}`** (medium) \u2014 any node with `scrollWidth &gt; clientWidth + 4` (excluding elements with explicit `overflow: hidden`), or document-level horizontal overflow\\n- **`longtask-{route}`** (medium/high) \u2014 max long task \u2265200ms; \u2265500ms escalates to high; triggers a CDP trace as evidence\\n- **`dashboard-map-no-tiles`** (medium) \u2014 `.leaflet-container` present but zero `.leaflet-tile`/container images on `/dashboard`\\n\\n4. **Heap growth without GC** \u2014 loops `/dashboard \u2192 /trades \u2192 /projects` six times without forcing garbage collection, sampling heap after each loop. Growth &gt;25MB produces a `cdp-heap-growth` high finding.\\n\\n5. **Mobile placeholder check** \u2014 switches to `VIEWPORTS.mobile` (390\u00d7844), opens `/trades`, and inspects the search input's `scrollWidth` vs `clientWidth`. Produces either `mobile-trades-search-truncate` or `mobile-trades-search-placeholder-long` (both low severity).\\n\\n6. **Network issue triage** \u2014 pulls `instrument.snapshotIssues()` and emits:\\n   - `carto-tile-aborts` (medium) \u2014 \u22653 failed/aborted CartoCDN or basemap requests\\n   - `http5xx-*` (high) \u2014 API/BFF responses with status \u2265500\\n   - `hang-*` (high) \u2014 requests with no response after the instrument's hang threshold (15s)\\n\\n7. **Persistence** \u2014 writes `deep-series.json` (per-route metrics, heap series, truncation result, network issues), calls `instrument.writeReport(...)`, writes `findings.json`, and merges all findings into `artifacts/all-findings.json`.\\n\\n---\\n\\n## `stability.spec.ts` \u2014 Cross-Browser Stability Suites\\n\\nThis file contains five independent tests, each with its own run directory, `PageInstrument`, and findings array. A shared `writeFindings(runDir, findings)` helper writes the per-run `findings.json` and merges into the global rollup.\\n\\n### 1. Public auth + landing loads\\n\\nIterates `/auth/login`, `/auth/register`, `/verify`, and `/`. For each route:\\n\\n- Measures `gotoAndSettle` elapsed time; flags `slow-public-*` (medium) if &gt; `SLOW_PAGE_MS`\\n- Checks `collectMetrics().overflowX`; flags `overflow-public-*` (medium)\\n- Inspects new console issues of type `error`/`pageerror`; flags `console-public-*` (high)\\n- Asserts the root route responds with one of `200, 302, 307, 308`\\n\\n### 2. Principal login + main nav flows\\n\\nThe main authenticated smoke test:\\n\\n- Logs in as `PRINCIPAL_EMAIL`; a failed login produces a `login-failed` critical finding and a hard assertion failure (`expect(login.stillOnLogin).toBe(false)`)\\n- Flags `slow-login-page` (medium) if login page load exceeds `SLOW_PAGE_MS`\\n- Walks all `PRINCIPAL_ROUTES` via `walkRoutes`, capturing metrics, screenshots, console errors, and overflow findings per route\\n- Drills into the first `/projects/{id}` link if one is visible\\n- Asserts **zero** critical findings at the end\\n\\n### 3. Responsive layout matrix\\n\\nLogs in once at the desktop viewport, then iterates every entry in `VIEWPORTS` (desktop, tablet, mobile). For each viewport, `walkRoutes` runs over `/dashboard`, `/projects`, `/trades`, `/requests`, `/settings` with the viewport name embedded in finding labels and metadata.\\n\\n### 4. Memory leak probe\\n\\nSkipped on Firefox (`test.skip(browserName === \\\"firefox\\\", ...)`) because heap metrics are unreliable there.\\n\\n- Logs in, then runs 8 iterations of navigating `/dashboard \u2192 /projects \u2192 /trades \u2192 /requests \u2192 /support`\\n- After each iteration, forces GC via a CDP session and records `heapUsedMB` from `collectMetrics`\\n- Writes `heap-series.json`\\n- Compares the average of the first two heap samples against the last two; if growth exceeds **30MB and 40%**, emits `memory-leak-nav-loop` (high) with the heap series and first/last dashboard screenshots as evidence\\n\\n### 5. Interaction freeze detection\\n\\nSkipped on non-Chromium browsers (`test.skip(browserName !== \\\"chromium\\\", ...)`).\\n\\nFor `/dashboard`, `/projects`, `/trades`:\\n\\n1. Starts a CDP trace via `instrument.startTrace`\\n2. Navigates with `gotoAndSettle`\\n3. Simulates interaction: 5 mouse moves, `Tab` presses, and a click on the first visible `button`/`a`\\n4. Measures the maximum `requestAnimationFrame` gap over 10 frames via `page.evaluate`\\n5. Flags `freeze-{route}` (high) if the max rAF gap exceeds 2000ms **or** the total interaction elapsed time exceeds 10s\\n\\nEvidence includes the screenshot and the CDP trace file.\\n\\n---\\n\\n## Data Flow\\n\\n```mermaid\\nflowchart LR\\n    subgraph Specs\\n        ST[\\\"stability.spec.ts\\\"]\\n        DD[\\\"deep-diagnostics.spec.ts\\\"]\\n    end\\n    subgraph Helpers\\n        FL[\\\"helpers/flows.tslogin \u00b7 gotoAndSettle \u00b7 walkRoutes\\\"]\\n        IN[\\\"helpers/instrument.tsPageInstrument\\\"]\\n    end\\n    subgraph Output\\n        RD[\\\"runDir artifactsfindings.json \u00b7 screenshots \u00b7 traces \u00b7 JSON series\\\"]\\n        RO[\\\"artifacts/all-findings.json\\\"]\\n    end\\n    ST --&gt; FL\\n    ST --&gt; IN\\n    DD --&gt; FL\\n    DD --&gt; IN\\n    FL --&gt; RD\\n    IN --&gt; RD\\n    RD --&gt; RO\\n```\\n\\nEach spec file constructs a `PageInstrument` bound to a timestamped run directory. Navigation and auth go through `flows.ts` helpers; instrumentation (screenshots, traces, metrics, network/console snapshots) goes through `PageInstrument`. Every suite writes its own `findings.json` plus supporting artifacts, then merges its findings into the shared `all-findings.json` rollup so CI or reporting tooling can consume one aggregated file.\\n\\n---\\n\\n## Running the Tests\\n\\nThese are standard Playwright specs. Assuming the usual `e2e` config:\\n\\n```bash\\n# All browsers, all stability suites\\nnpx playwright test e2e/tests/stability.spec.ts\\n\\n# Chromium-only deep diagnostics\\nnpx playwright test e2e/tests/deep-diagnostics.spec.ts --project=chromium\\n\\n# Single stability suite by test title\\nnpx playwright test e2e/tests/stability.spec.ts -g \\\"memory leak\\\"\\n```\\n\\nBrowser restrictions are enforced in-code via `test.skip`, so running the deep diagnostics or freeze probes against Firefox/WebKit will report them as skipped rather than failed.\\n\\nThe tests assume a running dev environment with `ALLOW_DEV_AUTH` / passwordless dev persona auth enabled \u2014 `loginAsDevPersona` will fail (and produce critical findings) if that infrastructure is unavailable.\\n\\n---\\n\\n## Extending the Module\\n\\nTo add a new probe:\\n\\n1. Create a new `test` block in the appropriate spec, or a new spec file under `e2e/tests/`.\\n2. Follow the established pattern: `runDirFor` (or an inline equivalent) \u2192 `PageInstrument` attach \u2192 login \u2192 instrumented navigation \u2192 finding construction \u2192 `writeFindings`.\\n3. Give every finding a stable, readable `id` (route-scoped where applicable) and ensure evidence paths point into the run directory.\\n4. If the probe produces a serializable dataset (heap series, metrics, network issues), write it as a JSON file in the run directory and reference it from `evidence`.\\n5. Keep severity consistent with the table in [Findings Model](#findings-model) so downstream triage tooling can rely on it.\\n\\nThe rollup merge in `writeFindings` already handles deduplication concerns at the consumer level (it appends), so new suites automatically contribute to `all-findings.json` without additional wiring.\",\"performance-reliability-testing-zap\":\"# Performance &amp; Reliability Testing \u2014 zap\\n\\n# Performance &amp; Reliability Testing \u2014 ZAP Baseline Scanner\\n\\n## Overview\\n\\nThe `loadtests/zap` module runs an OWASP ZAP baseline security scan against the application under test (AUT) as part of the load testing pipeline. It serves two gates:\\n\\n1. **Reliability gate** \u2014 Verifies the AUT is reachable and responds correctly. The automation plan sets `failOnError: true`, so any job-level error \u2014 including the spider receiving a non-200 response \u2014 fails the run.\\n2. **Security regression check** \u2014 Executes ZAP's passive scanning rules against discovered endpoints and emits structured pass/warn/fail results. No active exploitation is performed; the scanner observes responses and flags configuration gaps.\\n\\nThe module is configuration-only: it contains no custom code. The ZAP Automation Framework (ZAP 2.17.0) reads `zap.yaml` and runs a fixed pipeline of jobs, producing an HTML report, a JSON report, and a raw console capture.\\n\\n## Module Layout\\n\\n```\\nloadtests/zap/\\n\u251c\u2500\u2500 zap.yaml                     # ZAP Automation Framework driver configuration\\n\u251c\u2500\u2500 zap-baseline.html            # Human-readable scan report (generated)\\n\u251c\u2500\u2500 zap-baseline.json            # Machine-readable scan report (generated)\\n\u2514\u2500\u2500 zap-baseline-stdout.txt      # Raw console output from the automation run (captured)\\n```\\n\\n## Execution Pipeline\\n\\n`zap.yaml` defines six jobs that execute sequentially within a single ZAP automation plan:\\n\\n```mermaid\\nflowchart LR\\n    A[\\\"zap.yaml(automation plan)\\\"] --&gt; B[\\\"passiveScan-config\\\"]\\n    B --&gt; C[\\\"spider(maxDuration: 1 min)\\\"]\\n    C --&gt; D[\\\"passiveScan-wait\\\"]\\n    D --&gt; E[\\\"outputSummary\\\"]\\n    E --&gt; F[\\\"report: traditional-html\\\"]\\n    E --&gt; G[\\\"report: traditional-json\\\"]\\n```\\n\\n| # | Job type | Purpose |\\n|---|----------|---------|\\n| 1 | `passiveScan-config` | Configures the passive scanner. Tags are disabled and output is capped at 10 alerts per rule (`maxAlertsPerRule: 10`). |\\n| 2 | `spider` | Crawls the target URL (`http://host.docker.internal:4000`) for up to 1 minute to discover endpoints. |\\n| 3 | `passiveScan-wait` | Blocks until the passive scanner drains its queue. `maxDuration: 0` means no time limit. |\\n| 4 | `outputSummary` | Writes a machine-readable summary to `/home/zap/zap_out.json` in `Long` format. No rules are filtered (`rules: []`). |\\n| 5 | `report` | Renders the `traditional-html` template to `/zap/wrk/zap-baseline.html`. |\\n| 6 | `report` | Renders the `traditional-json` template to `/zap/wrk/zap-baseline.json`. |\\n\\n### Environment Configuration\\n\\nThe plan's `env` block defines a single ZAP context named `baseline`:\\n\\n```yaml\\nenv:\\n  contexts:\\n  - excludePaths: []\\n    name: baseline\\n    urls:\\n    - http://host.docker.internal:4000\\n  parameters:\\n    failOnError: true\\n    progressToStdout: false\\n```\\n\\n- `urls` \u2014 The AUT origin. `host.docker.internal` resolves to the Docker host from inside a container, which is how the module reaches a locally running dev server.\\n- `failOnError: true` \u2014 Any job-level error (e.g., the spider receiving a non-200 status) fails the automation plan. This is what makes the module a reliability gate.\\n- `progressToStdout: false` \u2014 Suppresses progress messages; only scan results and plan warnings appear on stdout.\\n\\n## Scan Outputs\\n\\n### Console Capture (`zap-baseline-stdout.txt`)\\n\\nThe stdout file contains the automation framework's result lines. Passive scan rules that find no issue report as `PASS`:\\n\\n```\\nPASS: Vulnerable JS Library (Powered by Retire.js) [10003]\\nPASS: In Page Banner Information Leak [10009]\\n...\\nPASS: Stats Passive Scan Rule [50003]\\n```\\n\\nNewly discovered issues report as `WARN-NEW`, with the affected URLs and status codes:\\n\\n```\\nWARN-NEW: Storable and Cacheable Content [10049] x 3\\n\\thttp://host.docker.internal:4000 (404 Not Found)\\n\\t...\\n```\\n\\nThe final tally line is the primary pass/fail signal:\\n\\n```\\nFAIL-NEW: 0  FAIL-INPROG: 0  WARN-NEW: 3  WARN-INPROG: 0  INFO: 0  IGNORE: 0  PASS: 64\\n```\\n\\nThe capture also includes automation plan warnings. In the reference run, the spider reports:\\n\\n```\\nJob spider error accessing URL http://host.docker.internal:4000 status code returned: 404 expected 200\\n```\\n\\nThis indicates the AUT returned 404 for the root path at scan time. The 100% 4xx insight in the reports corroborates this \u2014 a CI pipeline consuming this module should treat this as a signal that the AUT may not have been fully initialized when the scan ran.\\n\\n### HTML Report (`zap-baseline.html`)\\n\\nThe `traditional-html` template produces a standalone report containing:\\n\\n- **Summary of Alerts** \u2014 Counts by risk level. The reference run shows 0 High, 1 Medium, 1 Low, 1 Informational.\\n- **Insights** \u2014 Automated site statistics. The reference run reports 100% of responses with 4xx status, 100% of endpoints with `text/html` content type, 100% GET endpoints, and 2 total endpoints.\\n- **Alert Detail** \u2014 Full descriptions, affected URLs, evidence, remediation guidance, and CWE/WASC references for each finding.\\n\\n### JSON Report (`zap-baseline.json`)\\n\\nMachine-readable equivalent of the HTML report. Top-level structure:\\n\\n- `@programName`, `@version`, `@generated` \u2014 Run metadata\\n- `insights[]` \u2014 Keyed statistics (`insight.code.4xx`, `insight.endpoint.total`, etc.)\\n- `site[]` \u2014 Per-site alert groups, each containing `alerts[]` with plugin IDs, risk codes, per-URL instances, and remediation references\\n- `sequences[]` \u2014 Active-scan sequence results (empty in a baseline scan)\\n\\n## Alerts in the Reference Run\\n\\nThe baseline scan surfaces three passive-scan findings:\\n\\n| Plugin ID | Alert | Risk | Instances |\\n|-----------|-------|------|-----------|\\n| 10055 | CSP: Failure to Define Directive with No Fallback | Medium | 3 |\\n| 10063 | Permissions Policy Header Not Set | Low | 3 |\\n| 10049 | Storable and Cacheable Content | Informational | 3 |\\n\\n- **10055** \u2014 The AUT sends `Content-Security-Policy: default-src 'none'` but omits `frame-ancestors` and `form-action`, which do not fall back to `default-src`.\\n- **10063** \u2014 Responses lack the `Permissions-Policy` header entirely.\\n- **10049** \u2014 Responses carry no explicit cache lifetime directive; ZAP assumes a liberal 1-year heuristic per RFC 7234.\\n\\nAll three findings are consistent with an AUT running with minimal/default HTTP headers \u2014 typical of a bare development server.\\n\\n## Integration Notes\\n\\n- The module runs inside the ZAP Docker image (ZAP 2.17.0), with report output written to `/zap/wrk/` and the summary to `/home/zap/zap_out.json`.\\n- The target `host.docker.internal:4000` implies the AUT is reachable on the Docker host's port 4000 from within the ZAP container.\\n- Because `failOnError: true` and the reference run shows a spider error and 100% 4xx responses, consumers of this module should treat the spider warning and any `WARN-NEW` tallies as actionable signals rather than noise.\\n- The module has no code-level dependencies \u2014 it is a pure configuration artifact consumed by the ZAP Automation Framework.\",\"performance-reliability-testing\":\"# Performance &amp; Reliability Testing\\n\\n# Performance &amp; Reliability Testing\\n\\nThe Performance &amp; Reliability Testing module is the Assureworx verification layer for speed, stability, and resilience. It covers three surfaces: in-app frontend instrumentation, cross-browser automated regression suites, and API load/security validation. Each surface produces structured evidence \u2014 `Finding` records, screenshots, CPU traces, k6 result tables, and ZAP reports \u2014 so regressions can be investigated and proven after the fact.\\n\\n## Sub-modules\\n\\n| Sub-module | Responsibility |\\n|---|---|\\n| [src](src.md) | Dev-only in-browser instrumentation: `PerfStore` records render timings, network requests, route changes, clicks, and markers; `PerfRecorder` provides interactive inspection, live metrics, and synthetic stress; `findings.ts` aggregates raw events into prioritized findings |\\n| [e2e](e2e.md) | Standalone Playwright suite (`assureworx-e2e-stability`) that drives the app across Chromium, Firefox, and WebKit with its own dependencies, isolated from the monorepo |\\n| [helpers](helpers.md) | Shared instrumentation and flow infrastructure for the Playwright specs: `PageInstrument` (console/network/long-task capture, screenshots, CPU tracing) and flows like `loginAsDevPersona` and `walkRoutes` |\\n| [tests](tests.md) | The two Playwright spec files: `stability.spec.ts` (broad stability net, responsive viewport matrix, memory-leak probe, interaction-freeze detection) and `deep-diagnostics.spec.ts` (Chromium-only deep capture) |\\n| [loadtests](loadtests.md) | k6 `user-journey.js` driving realistic API sessions \u2014 login, authenticated reads, logout \u2014 under baseline, load, and stress profiles, plus pentest/security scripts and evidence artifacts |\\n| [zap](zap.md) | OWASP ZAP baseline scanner: a reachability/reliability gate (`failOnError: true`, any non-200 spider response fails the run) and passive security regression checks |\\n\\n## How the sub-modules fit\\n\\nThe module mirrors the full request path of the product. [src](src.md) is embedded in the frontend itself, capturing what the app sees internally \u2014 render durations, network instants, remount churn, duplicate bursts, and inactive-section leaks. The [e2e](e2e.md) suite, built on [helpers](helpers.md) and [tests](tests.md), observes the app from the outside: it drives real user journeys across engines and viewports, instruments the browser to catch hangs, overflow, memory growth, and cross-browser rendering inconsistencies, then writes structured findings to per-run artifact directories and a global rollup. [loadtests](loadtests.md) and [zap](zap.md) validate the API layer \u2014 the former with escalating k6 load profiles and pentest probes, the latter as a passive security scan that doubles as a deployment reliability gate.\\n\\n```mermaid\\nflowchart LR\\n    subgraph E2E[\\\"e2e \u2014 Playwright suite (3 engines)\\\"]\\n        HLP[\\\"helpers \u00b7 PageInstrument, flows\\\"]\\n        TESTS[\\\"tests \u00b7 stability &amp; deep-diagnostics specs\\\"]\\n    end\\n    subgraph Frontend[\\\"app (instrumented)\\\"]\\n        SRC[\\\"src \u00b7 PerfStore, PerfRecorder, findings\\\"]\\n    end\\n    subgraph API[\\\"API validation\\\"]\\n        LOAD[\\\"loadtests \u00b7 k6 + pentest scripts\\\"]\\n        ZAP[\\\"zap \u00b7 ZAP baseline scan\\\"]\\n    end\\n    SRC --&gt;|embedded, tracked during driven sessions| TESTS\\n    HLP --&gt; TESTS\\n    TESTS --&gt; ART[\\\"artifact dirs + global rollup\\\"]\\n    LOAD --&gt; ART\\n    ZAP --&gt; ART\\n```\\n\\n## Key workflows\\n\\n**In-app performance analysis** \u2014 The frontend's `PerfStore` records events; `analyseFindings` (via `buildPathStats`, `findDuplicateBursts`, `topPaths`) converts raw events into prioritized findings; the `PerfRecorder` panel exposes live metrics, tabular inspection, synthetic stress simulation, and JSON export during development.\\n\\n**Cross-browser reliability suite** \u2014 Playwright runs `stability.spec.ts` and `deep-diagnostics.spec.ts` against the app, using `PageInstrument` from [helpers](helpers.md) for console/network/long-task capture and evidence generation. The suite exercises public and authenticated routes, a viewport matrix, interaction-freeze detection, and memory-leak probes; findings and reports are written per run and merged into a global rollup file.\\n\\n**API load &amp; security validation** \u2014 [loadtests](loadtests.md)' k6 script answers whether the API is healthy under expected load (baseline/load stages) and where it breaks under stress (stress stage); pentest scripts and live probes extend this to security. The ZAP baseline scan then serves as both a reachability gate and a passive security checker.\\n\\n**Verification loop** \u2014 Because the same in-app instrumentation is embedded in the frontend and the e2e suite drives that instrumented app, findings from real browser automation (long-task clusters, layout overflow, request storms caught by `PageInstrument`) can be cross-referenced against the in-app `PerfStore` data \u2014 giving developers external symptoms and internal cause in the same artifact directory.\",\"product-architecture-documentation-agents-md\":\"# Product &amp; Architecture Documentation \u2014 AGENTS.md\\n\\n# AGENTS.md \u2014 Assureworx Knowledge Wiki Schema\\n\\n## Module Overview\\n\\n`AGENTS.md` is the root-level operating contract for the Assureworx repository's persistent, LLM-maintained knowledge wiki. It is a declarative schema and workflow specification \u2014 not executable code \u2014 that defines how a code-model agent must structure, maintain, and evolve a markdown knowledge base alongside the application source.\\n\\nThe module implements the \\\"LLM Wiki\\\" pattern: the wiki is a **compounding artifact** that accumulates knowledge over time, rather than being re-derived on every query. Responsibility is split between human and agent:\\n\\n- **Human** \u2014 curates raw sources, directs analysis, asks questions.\\n- **LLM agent** \u2014 owns the wiki layer entirely: summarizing, cross-referencing, filing, bookkeeping, and linting.\\n\\nThe file itself mandates that it be read at the start of any session that touches the wiki. `wiki/overview.md` and `wiki/index.md` are the session entry points.\\n\\n---\\n\\n## Architecture\\n\\nThe module defines four knowledge layers, three of which are wiki layers plus one special case:\\n\\n| Layer | Path | Owner | Role |\\n|---|---|---|---|\\n| Raw sources | `Raw/` | Human (immutable) | Source of truth for *inputs* \u2014 specs, meeting notes, transcripts, data files. Read-only. |\\n| The wiki | `wiki/` | LLM agent | Generated markdown \u2014 summaries, entity/concept pages, architecture pages, decision records. Fully owned by the agent. |\\n| The schema | `AGENTS.md` | Human + agent | Governs wiki structure and workflows. Co-evolved over time. |\\n| Code | application source | \u2014 | Authoritative for *behavioral* facts. When code and wiki disagree, code wins \u2014 the agent must flag the contradiction and update the wiki (see Lint workflow). |\\n\\n```mermaid\\ngraph TD\\n    SCHEMA[AGENTS.md \u2014 schema &amp; workflows] --&gt;|governs| WF[Ingest / Query / Lint workflows]\\n    RAW[Raw/ \u2014 immutable sources] --&gt;|input to| WF\\n    WF --&gt;|distributes knowledge| WIKI[wiki/ \u2014 product \u00b7 architecture \u00b7 decisions \u00b7 sources]\\n    WIKI --&gt; IDX[index.md \u2014 catalog]\\n    WIKI --&gt; LOG[log.md \u2014 append-only log]\\n    WIKI --&gt; OV[overview.md \u2014 current synthesis]\\n    CODE[Application code] -.-&gt;|authoritative for behavior; contradictions flagged| WIKI\\n```\\n\\nA single ingested source may touch 5\u201315 wiki pages; the ingest workflow distributes knowledge to where it belongs rather than dumping a source into one page.\\n\\n---\\n\\n## Directory Layout\\n\\n```\\nAGENTS.md                     # this schema (repo root \u2014 required)\\nPRODUCT.md / DESIGN.md        # Impeccable design-tooling contracts (not wiki pages)\\nRaw/                          # immutable sources (read-only)\\nscripts/                      # launchers (.bat), deploy, migrate, ISMS helpers\\nwiki/\\n  index.md                    # content catalog \u2014 every page, one-line summary\\n  log.md                      # append-only chronological log of ingests/queries/lints\\n  overview.md                 # top-level synthesis: what Assureworx is, current state\\n  glossary.md                 # domain terms and acronyms (ABN, KYC, HRWL, Tier 1, ...)\\n  product/                    # what the product does (domain knowledge)\\n  architecture/               # how it is built (technical knowledge)\\n  decisions/                  # decision records (ADRs) + open questions\\n  sources/                    # one summary page per ingested Raw source\\n  runbooks/                   # operational how-tos (deploy, Stripe, rotate secrets, etc.)\\nisms/                         # ISO 27001 / SOC 2 wiki (separate schema: isms/AGENTS.md)\\n```\\n\\nRoot-level rule: only `AGENTS.md`, `PRODUCT.md`, and `DESIGN.md` live at the repo root. Product, architecture, and runbook markdown must be filed under `wiki/` (or `isms/`).\\n\\n---\\n\\n## Page Conventions\\n\\nEvery wiki page starts with YAML frontmatter, a title, and a one-sentence summary:\\n\\n```markdown\\n---\\ntype: product | architecture | decision | source | concept | overview | index | log\\nstatus: current | draft | superseded\\nupdated: YYYY-MM-DD\\nsources: [assureworx-platform-spec]      # source IDs this page draws from\\ntags: [compliance, onboarding]\\n---\\n\\n# Page Title\\n\\nOne-sentence summary of what this page covers.\\n\\n## ... body ...\\n\\n## Related\\n- [Other Page](../path/to/page.md)\\n```\\n\\n### Frontmatter fields\\n\\n| Field | Purpose |\\n|---|---|\\n| `type` | Page category; one of `product`, `architecture`, `decision`, `source`, `concept`, `overview`, `index`, `log` |\\n| `status` | Lifecycle state: `current`, `draft`, `superseded` |\\n| `updated` | Last-modified date, `YYYY-MM-DD` |\\n| `sources` | Source IDs the page draws from |\\n| `tags` | Free-form categorization |\\n\\n### Content rules\\n\\n- **Relative markdown links only** \u2014 keeps the wiki portable across GitHub, Obsidian, and plain editors.\\n- **No orphan pages** \u2014 every page must have inbound links. New pages are linked from `index.md` and at least one relevant sibling.\\n- **Cross-reference aggressively** \u2014 any concept that has its own page gets linked.\\n- **Cite sources** \u2014 claims from sources reference the source ID and section where useful (e.g., `spec \u00a75.2`); claims from code cite the file path.\\n- **Flag contradictions inline** with a callout: `&gt; **\u26a0 Contradiction:** spec \u00a75.10 says 14-day grace; code enforces 7 (billing/service.ts).`\\n- **Flag unknowns** with `&gt; **\u2753 Open:** ...` \u2014 real decisions get filed in `decisions/open-questions.md`.\\n- **Prefer short, focused pages** that grow over time; split when a page covers two distinct things.\\n- Keep prose tight; use tables for structured facts, code fences for schemas/routes/snippets.\\n\\n---\\n\\n## Core Components\\n\\n### `wiki/index.md` \u2014 content catalog\\n\\nThe catalog lists **every** wiki page grouped by category, each with a relative link and a one-line summary. It is the first thing read when answering a query: read the index, find relevant pages, drill in. It must be updated on **every** ingest or new page. At this scale (hundreds of pages), the index replaces embedding-based RAG.\\n\\n### `wiki/log.md` \u2014 chronological log\\n\\nAppend-only; past entries are never rewritten. Every ingest, notable query filed back to the wiki, and lint pass gets an entry with a greppable prefix:\\n\\n```\\n## [YYYY-MM-DD] ingest | \\n## [YYYY-MM-DD] query  | \\n## [YYYY-MM-DD] lint   | \\n## [YYYY-MM-DD] decision | ADR-000N \\n```\\n\\n`grep \\\"^## \\\\[\\\" wiki/log.md | tail -5` yields the recent timeline. Each entry is 2\u20135 bullets describing what changed and which pages were touched.\\n\\n### Decision records (ADRs)\\n\\nArchitectural and product decisions are first-class artifacts:\\n\\n- Numbered records at `wiki/decisions/NNNN-slug.md`, frontmatter `type: decision`, `status: proposed | accepted | superseded`.\\n- Body structure: **Context / Decision / Consequences**.\\n- All ADRs listed in `wiki/decisions/index.md`.\\n- Unresolved decisions tracked in `wiki/decisions/open-questions.md`; resolving one creates its ADR and removes it from open questions (with a `log.md` entry).\\n\\n**When to create an ADR:** any choice that is expensive to reverse or that future contributors would question \u2014 framework/library choices, data-model shapes, tenancy/security boundaries, third-party providers, deployment topology.\\n\\n### Data classification catalogue\\n\\n`wiki/architecture/data-classification-catalogue.md` is a living, element-level inventory of every stored or exposed data point, classified **GP / CIC / C / PII / SPII** (Australian English definitions). Any change to a migration column, API route, cookie, blob, email, wallet field, or supplier data flow must update the catalogue **in the same change** and append to `wiki/log.md`. The catalogue is enforced via Cursor rule `.cursor/rules/data-classification-catalogue.mdc` and rolled up into `isms/data-inventory.md`.\\n\\n---\\n\\n## Workflows\\n\\n### Ingest\\n\\nWhen a source is dropped into `Raw/`:\\n\\n1. Read the source fully; for HTML/mixed content, extract the substantive text.\\n2. Discuss key takeaways with the human (unless batch mode was requested).\\n3. Create or update `wiki/sources/.md` \u2014 a summary, key facts, and section map with a link back to the raw file.\\n4. Integrate the knowledge across the relevant `product`, `architecture`, `decision`, and `concept` pages. A single source may touch 5\u201315 pages.\\n5. Update `index.md` and append to `log.md`.\\n6. Note any contradictions with existing pages and any new open questions.\\n\\n### Query\\n\\n1. Read `index.md`, then the relevant pages. Search the wiki first; only re-read `Raw/` or code if the wiki is insufficient.\\n2. Answer with citations \u2014 page links, source sections, or code paths.\\n3. **File valuable answers back into the wiki.** Comparisons, analyses, and discovered connections worth keeping become new pages (usually under `product/`, `architecture/`, or a new `concepts/` page), followed by `index.md` and `log.md` updates. Explorations compound; they do not vanish into chat.\\n\\n### Lint\\n\\nPeriodic health check that reports:\\n\\n| Check | Description |\\n|---|---|\\n| Contradictions | Between wiki pages, or between wiki and code |\\n| Stale claims | Superseded by newer sources or shipped code |\\n| Orphan / missing pages | No inbound links; referenced concepts never documented |\\n| Broken links | And missing cross-references |\\n| Answered open questions | Promoted to decision records |\\n| Data gaps | Worth a web search or a question to the human |\\n\\nThe lint produces a prioritized to-do list; safe fixes are made directly, the rest are proposed.\\n\\n---\\n\\n## Integration With the Broader Codebase\\n\\n### Embedded GitNexus code-intelligence contract\\n\\nThe `` / `` block binds `AGENTS.md` to the GitNexus code index for **assureworx_app** (7055 symbols, 17119 relationships, 300 execution flows). It imposes mandatory agent behavior when editing application code:\\n\\n- **MUST run `impact({target: \\\"symbolName\\\", direction: \\\"upstream\\\"})`** before editing any symbol, and report the blast radius (direct callers, affected processes, risk level).\\n- **MUST run `detect_changes()`** before committing to verify the change scope; use `detect_changes({scope: \\\"compare\\\", base_ref: \\\"main\\\"})` for regression review.\\n- **MUST warn the user** on HIGH or CRITICAL impact risk before proceeding.\\n- Use `query()` for concept search, `context()` for symbol context, `explain()` for taint findings.\\n- Use `rename` for symbol renames \u2014 never find-and-replace.\\n\\nIf the index is stale, re-run `node .gitnexus/run.cjs analyze` from the project root (`npx gitnexus analyze` as fallback).\\n\\n### Other integration points\\n\\n- **`isms/`** \u2014 a separate knowledge wiki with its own `isms/AGENTS.md` schema for ISO 27001 / SOC 2 material. Rolled-up data inventory lives at `isms/data-inventory.md`.\\n- **`PRODUCT.md` / `DESIGN.md`** \u2014 Impeccable design-tooling contracts at repo root; they document intent, while the wiki documents synthesized knowledge.\\n- **`Raw/`** \u2014 immutable source of truth for inputs; the wiki's `sources/` pages link back to it.\\n- **`scripts/`** \u2014 launch, deploy, migrate, and ISMS helper scripts referenced by runbooks.\\n- **`.cursor/rules/data-classification-catalogue.mdc`** \u2014 Cursor editor rule enforcing the data-classification catalogue.\\n\\n### Wiki \u2194 code relationship\\n\\nOnce code exists, the wiki must be **implementation-honest**: cite code for behavior, and reserve spec/product pages for intent and domain rules. When code and wiki disagree, code wins for behavioral facts; the agent flags the contradiction inline and updates the wiki.\\n\\n---\\n\\n## Operational Contract\\n\\nThe module ends with hard rules for the maintaining agent:\\n\\n- Prefer **editing existing pages** over creating new ones \u2014 grow the wiki, don't sprawl it.\\n- Make **atomic, well-scoped edits**; keep the wiki internally consistent after every change.\\n- **Never invent facts** \u2014 unknown items are marked `\u2753 Open`, never guessed.\\n- Every ingest/lint ends with `index.md` updated + `log.md` appended. No exceptions.\\n- Commit logically \u2014 one ingest or one lint per commit \u2014 when the human asks to commit.\\n- `wiki/overview.md` always reflects the **current** best synthesis of product and build state; update it whenever a major fact changes.\\n- Start every deep session by reading `wiki/overview.md` and `wiki/index.md`.\",\"product-architecture-documentation-architecture\":\"# Product &amp; Architecture Documentation \u2014 architecture\\n\\n# Architecture Documentation\\n\\nThe `wiki/architecture/` directory is the architecture knowledge base for the Assureworx platform. It records how the system is built today \u2014 repository layout, backend module structure, logical and physical data model, data classification, document extraction, and the live Azure DEV/TEST estates \u2014 plus the target CI/CD operating model. These are living documents: several carry maintenance obligations that require the docs to change in the same change as the code (see [Cross-cutting maintenance rules](#cross-cutting-maintenance-rules)).\\n\\nTwo points to internalise before relying on any page here:\\n\\n- **`cicd-target-operating-model.md` is a draft.** It is a design for approval, not a description of a working pipeline. As of 2026-08-01 there is no evidence of GitHub Actions runs, IaC, or a PROD environment. DEV and TEST were provisioned manually; the manual deploy scripts remain the bootstrap/emergency path until the pipeline lands.\\n- **Physical names are obfuscated** ([ADR-0007](../decisions/0007-obfuscated-physical-schema.md)). Business code never references table names directly \u2014 it uses constants from `backend/src/shared/schema/tables.ts`, and the physical\u2194logical dictionary lives in `schema-mapping.md` (mirrored in-DB by `meta.map_dict` / `meta.code_dict`). `data-model.md` describes the **logical** model only.\\n\\n## Document inventory\\n\\n| Document | Status | What it covers | Primary anchors |\\n| --- | --- | --- | --- |\\n| `code-map.md` | current | Monorepo layout, deploy topology, path index, backend module \u2192 physical table ownership, contribution rules | `frontend/`, `backend/src/modules/*`, `packages/shared-types/`, `db/migrations/` |\\n| `backend.md` | current | Express modular monolith: per-module file pattern, middleware ordering, API envelope, BullMQ background jobs | `backend/src/modules/*`, `backend/src/shared/middleware/*`, `backend/src/shared/services/*` |\\n| `data-model.md` | current | Logical data model, schema conventions, security model overview | `db/migrations/*`, `schema-mapping.md` |\\n| `schema-mapping.md` | current | Physical\u2194logical dictionary, RLS &amp; security model, code meanings | `meta.map_dict`, `meta.code_dict`, `backend/src/shared/schema/tables.ts` |\\n| `data-classification-catalogue.md` | current | Authoritative per-column / per-endpoint / per-store sensitivity classification (GP, CIC, C, PII, SPII), suppliers, non-DB stores | Every DB column, every `/api/v1/*` route, cookies, blobs, Key Vault, Redis |\\n| `document-extraction.md` | current | Document extraction ladder (tier 1 PDF text \u2192 tier 3 Azure Document Intelligence), rule packs, validation findings, cost governor, audit | `backend/src/shared/services/extraction/*`, `core.t_meter`, ADR-0019/0020/0032 |\\n| `azure-dev-inventory.md` | current | Live DEV Azure resources, tenancy, security/cost posture, cutover notes | `rg-assureworx-dev-aue`, `scripts/deploy-dev.ps1`, runbooks |\\n| `azure-test-inventory.md` | current | Live TEST Azure resources \u2014 a fully separate twin of DEV provisioned 2026-08-01 | `rg-assureworx-test-aue` |\\n| `cicd-target-operating-model.md` | **draft** | Target branching/promotion model, environment separation, promotion gates, implementation sequence, open decisions | GitHub, ACR, Azure Container Apps (design only) |\\n\\nSupporting pages referenced across the module: `infrastructure.md`, `security.md`, `integrations.md`, `frontend.md`, `stack.md`, `identity-verification.md`, `production-readiness-report-2026-08-01.md`, `plan-review-2026-07-20.md`.\\n\\n## Core patterns the docs record\\n\\n### Modular monolith backend\\n\\n`backend.md` and `code-map.md` together define the backend as a **modular monolith** \u2014 microservice-ready, not microservices. Each feature is one folder under `backend/src/modules/` with a fixed file pattern:\\n\\n| File | Responsibility |\\n| --- | --- |\\n| `routes.ts` | Express router + middleware attachment \u2014 **zero business logic** |\\n| `controller.ts` | Request/response handling; calls service; returns envelope |\\n| `validation.ts` | Zod schemas for all inputs |\\n| `service.ts` | Business logic, orchestration, audit-log calls |\\n| `repository.ts` | All DB queries \u2014 **tenant isolation enforced here**, not in routes |\\n| `types.ts` | Domain TypeScript interfaces |\\n\\nModules \u2014 `auth`, `memberships`, `parties`, `nodes`, `links`, `requests`, `directory`, `artifacts`, `checks`/`verification`, `training`, `cases`, `notifications`, `billing`, `reporting`, `admin`, plus `wallet` and `identity` \u2014 each own a set of physical tables; the ownership map is in `code-map.md`. The module's rules: no domain table names in business code, frontend never talks to Postgres, migrations apply via ops not app boot, new features register a router in `app.ts`.\\n\\nMiddleware ordering is a documented invariant on business-logic routes: **auth \u2192 tenant resolution \u2192 subscription check \u2192 handler**. `require-auth.ts` validates the Entra External ID JWT against Microsoft's JWKS on every request; `resolve-tenant-context.ts` resolves org memberships from token claims into `req.tenantContext`; `require-subscription.ts` enforces billing status and runs *after* auth + tenant resolution so suspended users can still authenticate. `idempotency.ts` provides retry-safe idempotency keys.\\n\\nThe API contract is the shared envelope in `packages/shared-types/`:\\n\\n```json\\n{ \\\"success\\\": true, \\\"data\\\": {}, \\\"meta\\\": { \\\"page\\\": 1, \\\"total\\\": 100 } }\\n{ \\\"success\\\": false, \\\"error\\\": { \\\"code\\\": \\\"DOCUMENT_EXPIRED\\\", \\\"message\\\": \\\"...\\\", \\\"details\\\": [] } }\\n```\\n\\nBackground work runs on BullMQ (Azure Managed Redis). `backend.md` documents the job table (`document.expiry.check`, `document.verify.api`, `notification.send.email`, `training.overdue.check`, `subscription.expiry.check`, `kyc.result.webhook`, `report.generate`); the classification catalogue's non-DB section records the current queue names (`email.send`, `sms.send`, `artifact.extract`, `geocode.site`, `payment.event`, `payment.reconcile`) plus the `rl:*` rate-limit keys and DLQ.\\n\\n### Obfuscated schema and the schema brain\\n\\n[ADR-0007](../decisions/0007-obfuscated-physical-schema.md) moved the deployed schema to obfuscated, party-model physical names in `core.*` / `meta.*`. The logical model in `data-model.md` is what developers should reason about: Party (`core.t_party`) unifies Tier 1 orgs, trade orgs, and individuals; login user is `core.t_actor`; membership/role is `core.t_grant`; project/site is `core.t_node`; engagement readiness is `core.t_link`; onboarding request chains are `core.t_request`; documents are `core.t_artifact`; verification events are `core.t_check`. The 4-state Verified lifecycle (Pending / Verified / Action required / Suspended) lives on the party, replacing the spec's boolean flag.\\n\\nSchema conventions: UUID PKs with `gen_random_uuid()`, `TIMESTAMPTZ` timestamps, soft delete via `deleted_at` with partial unique indexes (`WHERE deleted_at IS NULL`), coded `*_cd` status/kind columns with CHECK constraints, and `attrs JSONB` extension bags on major entities.\\n\\n`meta.map_dict` and `meta.code_dict` are the in-DB \\\"schema brain\\\": physical table names, logical names, column dictionaries, and code meanings. `schema-mapping.md` is the human-readable mirror, and `backend/src/shared/schema/tables.ts` is the constant file that keeps physical names out of business code.\\n\\n&gt; **Note:** `data-model.md` states the deployed migration as `002_enterprise_core.sql`, while the TEST inventory records migrations 001\u2013025 applied and the classification catalogue references evidence through 035. The migration directory is the authoritative source; treat the \\\"current deployed migration\\\" line in `data-model.md` as stale.\\n\\n### Tenant isolation and security model\\n\\nThe security model is defence-in-depth: the repository layer is the primary enforcement point ([ADR-0003](../decisions/0003-repository-layer-tenant-isolation.md)), with Postgres RLS behind it. The app runtime connects as role `app_rt`; the `meta` schema is revoked from it. RLS uses `SET LOCAL app.scope` (owner org UUID) via `backend/src/shared/db/pool.ts` (`withScope` / `withBypass` transactions), with an explicit audited `app.bypass` for Platform Admin. Audit (`core.t_event`) and training results are immutable via privilege REVOKE + blocking triggers.\\n\\nThe data classification catalogue is the authoritative sensitivity registry: 634 column/element rows and 137 endpoints as of 2026-08-07, classified across five operational classes (GP, CIC, C, PII, SPII) mapped to ISMS POL 002. It also records what is intentionally **not** stored \u2014 identity document images and selfie biometrics (Didit-hosted), KYC document numbers, DoB and residential address from the KYC webhook, payment card data, and passwords \u2014 and the supplier/residency table (ABR, Nominatim, Didit, Entra, ACS Email, Twilio, Apple/Google Wallet).\\n\\n### Document extraction ladder\\n\\n`document-extraction.md` documents the pipeline that reads uploaded compliance documents, pre-fills upload forms, and auto-accepts clean results to `F02` (`decideArtifactStatus`). The tier ladder turns a file into characters; rule packs then interpret the text:\\n\\n- **Tier 1** \u2014 PDF text layer via `unpdf`/PDF.js, in the request, free, bounded by `PDF_TEXT_BUDGET_MS` (2,500 ms) and `MAX_PAGES` (20).\\n- **Tier 2** \u2014 local raster OCR. Not implemented, but deliberately kept in the ladder ahead of tier 3 so building it later displaces cloud spend rather than adding to it.\\n- **Tier 3** \u2014 Azure AI Document Intelligence `prebuilt-read` (`cloud-read.ts`), US$1.50/1,000 pages, managed-identity auth (`disableLocalAuth: true`). The model id is hard-coded because the model id *is* the meter \u2014 a configurable model could multiply the bill 133\u00d7.\\n\\nEscalation: tiers score via `scoreExtraction` (`validate.ts`) with class-weighted field weights (expiry 3, reference 2, cover 2, authority 1, issue date 1). Below `ESCALATE_BELOW` (**0.7**) the next tier runs. `canOcrImprove` (`sources.ts`) stops the ladder when the current tier already produced a sound text layer \u2014 a low score on legible text means the field is genuinely absent, and a more expensive re-read cannot put it back.\\n\\nFive independent cost caps: `ARTIFACT_MAX_PAGES` (10, uploads), `DOC_INTEL_MAX_PAGES` (4, provider-billed pages per document), `DOC_INTEL_MONTHLY_PAGE_CAP` (2,000, reserved in `core.t_meter` before bytes leave the process), `DOC_INTEL_PARTY_DAILY_PAGES` (30, read from `extraction.cloud_read` audit events), `DOC_INTEL_MAX_CONCURRENT` (4, in-process semaphore). Every billable read writes an `extraction.cloud_read` audit event \u2014 party, actor, class, provider, model, region, pages, duration, outcome \u2014 and **no document content**.\\n\\nExtraction is advisory except one hard reject: expired + confidence \u2265 0.8 fails upload with `DOCUMENT_EXPIRED` before the blob is stored. Photo ID (A002) is not uploadable at all \u2014 it is rejected with `IDENTITY_VIA_PROVIDER` because identity is a Didit check, not a document artifact. Probes: `npm run -w backend probe:extraction` (offline ladder + rules) and `npm run -w backend probe:cloud-read` (live tier 3, needs `DOC_INTEL_ENDPOINT`, `DATABASE_URL`, Azure login).\\n\\n### Azure environments\\n\\nTwo environment inventories describe live Azure resources in the Assureworx - Startup Sponsorship subscription (tenant `e0045fa5-8177-4976-a60a-892074203e28`, Australia East):\\n\\n- **DEV** (`azure-dev-inventory.md`) \u2014 `rg-assureworx-dev-aue`. Postgres Flexible Server `psql-aw-dev-aue` (Entra auth enabled, password auth still on), Container Apps `ca-aw-api-dev-aue` + `ca-aw-web-dev-aue`, Container Apps Environment `cae-assureworx-dev-aue`, ACR `crawdevaue`, Storage `stawdevaue`, Key Vault `kv-aw-dev-aue`, Azure Managed Redis `redis-aw-dev-aue` (Balanced_B0), Document Intelligence `di-aw-dev-aue` (S0, `disableLocalAuth: true`), App Insights + Log Analytics, and Entra External ID CIAM `assureworxdev1.onmicrosoft.com`. Posture: scale-to-zero (`minReplicas=0`), `ALLOW_DEV_AUTH` **forbidden** in Azure (API boot throws if set), web site behind a toggleable Chrome Basic Auth gate, blob versioning/change-feed/soft-delete on, Defender on-upload malware scanning, App Insights 1 GB/day cap.\\n- **TEST** (`azure-test-inventory.md`) \u2014 `rg-assureworx-test-aue`, provisioned 2026-08-01 as a cheap, fully separate twin. Same shape of resources with `-test-` names, `ASSUREWORX_ENV=test`, `ALLOW_DEV_AUTH` unset, JIT provisioning off (`AUTH_JIT_PROVISION=0`), platform admin allowlisted to `tech@assureworx.com.au`. Note: the CIAM directory (`assureworxtest1.onmicrosoft.com`) had to be created in the US because the AU GoLocal flag was rejected by the create API; the data plane remains Australia East.\\n\\nPROD is not provisioned anywhere in these docs.\\n\\n## CI/CD target state\\n\\n`cicd-target-operating-model.md` is a **draft design for approval** \u2014 it explicitly states it is not evidence that the pipeline or TEST/PROD environments exist. The core enterprise rule is **build once, promote the same immutable artifact**: GitHub builds versioned API and web images once, records commit SHA + image digests in a release record, and promotes those exact digests DEV \u2192 TEST \u2192 PROD, with environment-specific configuration and secrets held in separate Azure resources and Key Vaults. Nothing is rebuilt on the path to PROD.\\n\\nThe current-state table in that doc is candid: local Git exists with two commits, `.github/workflows/ci.yml` exists in the clean baseline, and `scripts/deploy-dev.ps1` builds and pushes mutable `:dev` images from a developer PC. There is no workflow-run evidence, no IaC (Bicep/Terraform not yet selected), no GitHub environment/reviewer configuration, and no TEST/PROD beyond the manually provisioned TEST estate. The proposed implementation sequence starts with protecting the existing work (safe baseline commit, secret removal, remote backup) before any pipeline work.\\n\\nOpen decisions recorded there: GitHub vs alternative providers, named independent TEST/PROD approvers, separate subscriptions vs resource groups per environment, and Bicep vs Terraform.\\n\\n## Cross-cutting maintenance rules\\n\\nThe module's most important operational rule lives in `data-classification-catalogue.md`:\\n\\n&gt; Any new column, JSONB key, cookie, blob, email/wallet field, or API route **must** update this page in the same change. Agents: `.cursor/rules/data-classification-catalogue.mdc`.\\n\\nIts maintenance protocol (\u00a77) lists the triggers: add/rename/drop a column in `db/migrations/*`; add a JSON key under `attrs` / `result` / `delta` that holds personal or commercial data; add or change an API route in `backend/src/modules/**`; add cookies, tokens, blobs, email templates, wallet fields, or telemetry that may log PII; integrate a new supplier. The checklist is: amend the relevant section \u2192 bump `updated:` \u2192 append `wiki/log.md` \u2192 touch `isms/data-inventory.md` if SPII or a new personal category is involved.\\n\\nOther structural conventions across the module:\\n\\n- Every page has frontmatter: `type: architecture`, `status: current|draft`, `updated`, `sources`, `tags`.\\n- Architecture pages link outward to `../decisions/` (ADRs), `../runbooks/`, `../product/`, and `../../isms/` rather than restating their content.\\n- Current evidence is dated and attributed (e.g., \\\"confirmed via Azure CLI as `tech@assureworx.com.au` on 20 Jul 2026\\\"). The CI/CD draft explicitly separates the target design from current evidence.\\n\\n## How the documents relate\\n\\n```mermaid\\nflowchart LR\\n    MIG[\\\"db/migrations/\\\"] --&gt; SM[\\\"schema-mapping.md\\\"]\\n    SM --&gt; DM[\\\"data-model.md\\\"]\\n    SM --&gt; DC[\\\"data-classification-catalogue.md\\\"]\\n    SRC[\\\"backend/src/modules/\\\"] --&gt; BA[\\\"backend.md\\\"]\\n    BA --&gt; CM[\\\"code-map.md\\\"]\\n    EX[\\\"document-extraction.md\\\"] --&gt; DC\\n    ADR[\\\"decisions/ ADR-*\\\"] --&gt; INV[\\\"azure-dev / test inventory\\\"]\\n```\\n\\n- `schema-mapping.md` is the hub for anything schema-shaped: it mirrors `db/migrations/` and feeds both the logical model (`data-model.md`) and the classification catalogue (`data-classification-catalogue.md`).\\n- `backend.md` and `code-map.md` are the entry points for code navigation; `code-map.md` includes the module \u2192 physical table ownership map and the path index for key scripts (`start-dev.bat`, `deploy-dev.bat`, `run-mobile-demo.bat`).\\n- `document-extraction.md` anchors to `backend/src/shared/services/extraction/*`, records the ADR decisions that shaped it (0019, 0020, 0032), and its audit events are classified in the catalogue.\\n- The environment inventories are operational snapshots, maintained against the runbooks (`azure-dev-deploy.md`, `azure-test-deploy.md`, `dev-custom-domains.md`, `acs-email-dev.md`, `entra-monitoring-and-cost-dev.md`) and the ADRs that changed topology (0001, 0020, 0021).\\n\\n## Related\\n\\n- [ADR-0001 Full Azure stack](../decisions/0001-full-azure-stack.md) \u2014 environment topology decisions\\n- [ADR-0007 Obfuscated physical schema](../decisions/0007-obfuscated-physical-schema.md) \u2014 why physical names are obfuscated\\n- [ADR-0003 Repository-layer tenant isolation](../decisions/0003-repository-layer-tenant-isolation.md)\\n- [ADR-0019 / 0020 / 0032](../decisions/) \u2014 document extraction decisions\\n- [ISMS data inventory](../../isms/data-inventory.md) and [ISMS POL 002](../../isms/policies/data-classification-and-handling-policy.md)\\n- [Ways of Working](../ways-of-working.md) \u2014 tenancy and login rules for Azure ops\",\"product-architecture-documentation-backend\":\"# Product &amp; Architecture Documentation \u2014 backend\\n\\n# Product &amp; Architecture Documentation \u2014 backend\\n\\nModule: `@assureworx/backend`\\n\\n## Overview\\n\\n`@assureworx/backend` is the Phase 1 backend for the AssureWorx platform. It is an Express 5 API built as a **modular monolith**: a single deployable process containing multiple bounded modules. Although all modules run inside one process today, each is structured as an independent unit so it can later be extracted into its own Container App without a rewrite.\\n\\n## Architecture\\n\\n```mermaid\\ngraph TB\\n    Client[\\\"API clients\\\"]\\n    Proc[\\\"Express 5 app(single process)\\\"]\\n    Auth[\\\"auth module\\\"]\\n    DB[(\\\"SQL database\\\")]\\n    Migrations[\\\"db/migrations/(applied outside image)\\\"]\\n\\n    Client --&gt;|\\\"GET /health\\\"| Proc\\n    Client --&gt;|\\\"GET /api/v1/auth/health\\\"| Auth\\n    Proc --&gt; Auth\\n    Auth --&gt; DB\\n    Migrations -.-&gt; DB\\n```\\n\\nPhase 1 runs exactly **one process**. The public HTTP surface is namespaced by module under `/api/v1`. There are currently no internal cross-module calls, no outbound service calls, and no inbound dependencies from other services. The only infrastructure dependency is the shared SQL database, whose schema is managed outside the service image.\\n\\n## HTTP Surface\\n\\nTwo health endpoints are confirmed:\\n\\n| Endpoint | Purpose |\\n|---|---|\\n| `GET /health` | Process-level liveness for the Express app |\\n| `GET /api/v1/auth/health` | Health of the auth module, mounted at `/api/v1/auth` |\\n\\nThe `/api/v1` prefix is the stable API versioning boundary; module routes hang off it.\\n\\n## Module Structure\\n\\nThe codebase is organized as bounded modules rather than a layered monolith. There is no shared controllers/services layer. Each module owns its routes, handlers, and domain logic. The auth module is the first concrete module.\\n\\nThis layout is deliberate: because modules do not depend on a common internal framework layer, any module can be moved into its own container image with minimal changes \u2014 the HTTP routes are already module-scoped.\\n\\n## Database &amp; Migrations\\n\\n- Migrations live in `../../db/migrations/` \u2014 outside this package.\\n- Migrations are **not** embedded in the container image and are **not** run at boot.\\n- Physical-to-logical schema naming conventions are documented in `wiki/architecture/schema-mapping.md`.\\n\\nFor local development, apply migrations from the repository root before starting the backend. For deployed environments, migration must run as part of the release pipeline, not during application startup.\\n\\n## Local Development\\n\\n```bash\\nnpm run dev -w @assureworx/backend\\n```\\n\\nThis starts the dev server on port `4000`:\\n\\n- http://localhost:4000/health\\n- http://localhost:4000/api/v1/auth/health\\n\\n## Microservice Extraction Path\\n\\nWhen a module outgrows the shared process:\\n\\n1. Create a new Container App image from the module's directory.\\n2. Mount the existing `/api/v1/` routes \u2014 already namespaced, so no route changes are required.\\n3. Point the new service at the same shared database, or split out its schema if the module warrants it.\\n4. Route traffic at the ingress/gateway level to the new service.\\n\\nBecause Phase 1 enforces no internal cross-module calls, no service-to-service communication refactor is required at extraction time.\",\"product-architecture-documentation-claude-md\":\"# Product &amp; Architecture Documentation \u2014 CLAUDE.md\\n\\n# CLAUDE.md \u2014 Product &amp; Architecture Documentation Module\\n\\n## Overview\\n\\nThe `CLAUDE.md` module is the GitNexus integration contract for the **assureworx_app** codebase: a self-contained Markdown document, delimited by `` and `` markers, that teaches an AI coding agent how to use GitNexus code-intelligence tooling when working in this repository.\\n\\nThe module has no executable code of its own \u2014 the call graph shows zero internal, outgoing, or incoming calls, and no execution flows are detected. Its function is declarative: it encodes mandatory tool-invocation policies, hard prohibitions, resource URIs, and skill-file pointers that shape how the agent navigates the codebase, assesses blast radius, and commits changes.\\n\\n## Architecture\\n\\n```mermaid\\ngraph LR\\n    Agent[AI Coding Agent] --&gt;|reads| Doc[CLAUDE.md Module]\\n    Doc --&gt;|mandates pre-edit| Impact[impact tool]\\n    Doc --&gt;|mandates pre-commit| Detect[detect_changes tool]\\n    Doc --&gt;|routes exploration| Query[query / context tools]\\n    Doc --&gt;|points to| Skills[.claude/skills/gitnexus/*]\\n    Impact --&gt; MCP[GitNexus MCP Server]\\n    Detect --&gt; MCP\\n    Query --&gt; MCP\\n    MCP --&gt; Index[assureworx_app index7055 symbols]\\n    Doc --&gt;|refresh command| CLI[.gitnexus/run.cjs analyze]\\n```\\n\\nThe module is the policy layer. It does not query the index itself; it instructs the agent which MCP tools to call, which skill files to read, and which CLI command to run when the index is stale.\\n\\n## Key Components\\n\\n### 1. Managed-Region Markers\\n\\nThe document is wrapped in `` and `` HTML comments. This is the regeneration boundary: GitNexus rewrites everything inside the markers on each analysis run without touching surrounding project documentation. Any content placed outside the markers is user-owned and preserved.\\n\\n### 2. Index Metadata Header\\n\\nThe opening block identifies the indexed project and its data profile:\\n\\n- **Project name:** `assureworx_app`\\n- **Symbol count:** 7055\\n- **Relationship count:** 17119\\n- **Execution flow count:** 300\\n\\nIt also documents the index-freshness remedy: run `node .gitnexus/run.cjs analyze` from the project root. The fallback is `npx gitnexus analyze`, with a note that npm 11 crashes \u2014 the workaround is a global install (`npm i -g gitnexus`), referencing issue #1939.\\n\\n### 3. \\\"Always Do\\\" \u2014 Mandatory Pre-Flight Policies\\n\\nThis section defines non-negotiable tool invocations the agent must perform before taking specific actions:\\n\\n| Policy | Tool call | Purpose |\\n|--------|-----------|---------|\\n| Pre-edit impact analysis | `impact({target: \\\"symbolName\\\", direction: \\\"upstream\\\"})` | Compute blast radius \u2014 direct callers, affected processes, risk level \u2014 before modifying any symbol |\\n| Pre-commit change verification | `detect_changes()` | Confirm edits only affect expected symbols and execution flows |\\n| Regression review | `detect_changes({scope: \\\"compare\\\", base_ref: \\\"main\\\"})` | Compare the working tree against the default branch |\\n| Risk escalation | user warning | Alert the user before proceeding when impact analysis returns HIGH or CRITICAL risk |\\n| Concept exploration | `query({search_query: \\\"concept\\\"})` | Find execution flows, process-grouped and ranked by relevance, instead of grepping |\\n| Symbol deep-dive | `context({name: \\\"symbolName\\\"})` | Pull callers, callees, and the execution flows a symbol participates in |\\n| Security review | `explain({target: \\\"fileOrSymbol\\\"})` | List taint findings (source\u2192sink flows); requires the index built with `analyze --pdg` |\\n\\n### 4. \\\"Never Do\\\" \u2014 Hard Prohibitions\\n\\nThis section lists operations the agent is forbidden from performing:\\n\\n- Editing any function, class, or method without first running `impact` on it.\\n- Ignoring HIGH or CRITICAL risk warnings from impact analysis.\\n- Renaming symbols with find-and-replace \u2014 the `rename` tool must be used instead because it is call-graph aware.\\n- Committing without running `detect_changes()` to verify the affected scope.\\n\\nThese prohibitions exist because the underlying operations (blind edits, mechanical renames, unchecked commits) bypass the call-graph safety net that GitNexus provides.\\n\\n### 5. Resources Table\\n\\nMaps GitNexus MCP resource URIs to their use cases:\\n\\n| Resource URI | Use |\\n|--------------|-----|\\n| `gitnexus://repo/assureworx_app/context` | Codebase overview, index-freshness check |\\n| `gitnexus://repo/assureworx_app/clusters` | All functional areas |\\n| `gitnexus://repo/assureworx_app/processes` | All execution flows |\\n| `gitnexus://repo/assureworx_app/process/{name}` | Step-by-step execution trace for a named flow |\\n\\nAll URIs share the `gitnexus://repo/assureworx_app/` prefix. The `{name}` placeholder in the last resource is filled with a process name retrieved from the processes resource.\\n\\n### 6. CLI Task-Mapping Table\\n\\nMaps developer tasks to skill files under `.claude/skills/gitnexus/`:\\n\\n| Task | Skill file |\\n|------|------------|\\n| Architecture questions (\\\"How does X work?\\\") | `gitnexus-exploring/SKILL.md` |\\n| Blast radius (\\\"What breaks if I change X?\\\") | `gitnexus-impact-analysis/SKILL.md` |\\n| Bug tracing (\\\"Why is X failing?\\\") | `gitnexus-debugging/SKILL.md` |\\n| Rename / extract / split / refactor | `gitnexus-refactoring/SKILL.md` |\\n| Tools, resources, schema reference | `gitnexus-guide/SKILL.md` |\\n| Index, status, clean, wiki CLI commands | `gitnexus-cli/SKILL.md` |\\n\\nEach task routes to a specialized skill file containing the detailed procedure for that workflow.\\n\\n## Execution Model\\n\\nThe module's execution is indirect. Because it is a Markdown policy document, it has no runtime of its own \u2014 hence the empty call graph and the absence of execution flows. It exerts its effect through the agent that consumes it:\\n\\n1. On session start, the agent reads `CLAUDE.md` and loads the policy sections into working context.\\n2. Before any symbol edit, the agent invokes `impact` and reports the blast radius; if risk is HIGH or CRITICAL, it warns the user.\\n3. During exploration, the agent prefers `query` and `context` over grep for flow-aware navigation.\\n4. Before commit, the agent runs `detect_changes` and verifies the affected scope matches expectations.\\n\\nThe 300 execution flows reported in the header belong to the indexed codebase (assureworx_app), not to this module.\\n\\n## Integration with the Rest of the Codebase\\n\\nThe module connects to four external surfaces:\\n\\n- **MCP tool surface** \u2014 `impact`, `detect_changes`, `query`, `context`, `explain`, and `rename` are GitNexus MCP server tools. This document is the contract governing when the agent must call each one.\\n- **MCP resource surface** \u2014 the resource URIs point into the same MCP server, exposing repo overview, clusters, processes, and per-process traces.\\n- **Skill-file surface** \u2014 `.claude/skills/gitnexus/*/SKILL.md` files contain the step-by-step procedures that back the policies summarized in the CLI table.\\n- **CLI surface** \u2014 `.gitnexus/run.cjs` is the local runner for GitNexus commands; the module documents `analyze` as the index-refresh entry point.\\n\\nIn the larger system, the module sits between the agent and the GitNexus MCP server. It does not contain analysis logic, but it determines when and how that logic is triggered \u2014 making it the guardrail that keeps agent edits call-graph-safe.\\n\\n## Maintenance\\n\\nGitNexus regenerates this module during analysis. The marked region (`` \u2026 ``) is rewritten on each run, so manual edits inside the markers are overwritten. To update the policy:\\n\\n1. Edit the GitNexus generator or the skill files that produce the content.\\n2. Re-run `node .gitnexus/run.cjs analyze`.\\n3. Verify the regenerated region reflects the change.\",\"product-architecture-documentation-decisions\":\"# Product &amp; Architecture Documentation \u2014 decisions\\n\\n# Decisions (ADR Log)\\n\\n## Purpose\\n\\nThe `wiki/decisions/` module is the **Architecture Decision Record (ADR) log** for the Assureworx platform \u2014 an Australian construction-compliance product running on a full Microsoft Azure stack. It is a static wiki module: it contains no executable code, and every file is a Markdown document that records one architectural or commercial decision, why it was made, and what it costs the project.\\n\\nThe log exists to answer two questions that code alone cannot:\\n\\n- **Why is the system shaped this way?** \u2014 e.g., why the database schema is obfuscated (ADR-0007), why the browser never talks directly to the API (ADR-0008), why licence verification is document-extraction rather than registry lookup (ADR-0019).\\n- **What has been settled, and what is still open?** \u2014 each ADR carries a status (`accepted`, `proposed`, `superseded`) and either resolves or raises numbered items on the [open questions page](open-questions.md).\\n\\nThe module is the contract between product decisions and engineering implementation. ADRs name the actual code artifacts that implement them \u2014 files like `backend/src/shared/services/verified-status.ts`, `backend/src/modules/identity/didit.ts`, and `next.config.ts` \u2014 so a developer tracing a behaviour can move from \\\"why does this exist?\\\" to \\\"where does it live?\\\" without guessing.\\n\\n---\\n\\n## File &amp; frontmatter conventions\\n\\nEach decision is one file named `NNNN-slug.md` (zero-padded, sequential). The filename slug is a short kebab-case summary of the topic, e.g. `0001-full-azure-stack.md`, `0024-payment-provider-boundary.md`.\\n\\nEvery file opens with YAML frontmatter:\\n\\n| Field | Purpose | Example |\\n| --- | --- | --- |\\n| `type` | Record kind \u2014 always `decision` | `type: decision` |\\n| `status` | Lifecycle state \u2014 `proposed`, `accepted`, or `superseded` | `status: accepted` |\\n| `updated` | ISO date of the last change to the record | `updated: 2026-08-04` |\\n| `sources` | Input documents the decision draws on | `sources: [assureworx-platform-spec]` |\\n| `tags` | Topic vocabulary used for discovery | `tags: [azure, hosting, data-residency]` |\\n\\nThe frontmatter `status` field is the authoritative lifecycle state; a superseded record keeps its original body intact and names its successor in the status line (see ADR-0004).\\n\\nThe body follows a consistent shape: **Context** (the problem), **Decision** (what was chosen and why), **Consequences** (positive, negative, and follow-up work), **Related** (links to other ADRs and wiki pages). This makes the log skimmable: a reader can scan the Decision section of any record and get the whole position.\\n\\n---\\n\\n## Status lifecycle\\n\\nThree states, one transition path:\\n\\n```\\nproposed \u2192 accepted \u2192 superseded\\n```\\n\\n- **`proposed`** \u2014 a decision that is on the table but not yet settled. It may block work and explicitly name blockers. Examples: ADR-0009 (real-time verification orchestration), ADR-0012 (Assureworx ID and identity resolution).\\n- **`accepted`** \u2014 the decision is the plan of record. Most of the log is accepted; acceptance often corresponds to \\\"confirmed by founder\\\" or \\\"implemented in migration N\\\".\\n- **`superseded`** \u2014 the decision is no longer the plan of record. The record is retained for history and states exactly what survives. Example: ADR-0004 (trade subscription pricing) superseded by ADR-0023 (pay-per-verification) \u2014 the \\\"Principal Contractors are free\\\" principle survives, the revenue mechanism does not.\\n\\nTwo mechanisms keep the log honest without rewriting history:\\n\\n- **Supersession** \u2014 a whole decision is replaced and the old record points forward: ADR-0004 \u2192 ADR-0023.\\n- **Amendment** \u2014 a specific clause of an accepted decision is changed while the rest stands, and the amended record notes it inline. ADR-0019 \u00a77 (deferring the Azure extraction tier) was amended by ADR-0020; ADR-0021 (SMS deferred) was amended by ADR-0028 (transactional SMS via Twilio); ADR-0019's auto-verify ban was amended by ADR-0032.\\n\\n---\\n\\n## The decision landscape\\n\\nThe 28 records in the log cluster into six themes.\\n\\n### Platform &amp; hosting\\n\\n| ADR | Topic | Status | Notes |\\n| --- | --- | --- | --- |\\n| [0001](0001-full-azure-stack.md) | Full Microsoft Azure stack, Australia East | accepted | Foundation decision. Supersedes the earlier Supabase-based plan; mandates per-service residency verification. |\\n| [0002](0002-postgresql-flexible-server.md) | Azure Database for PostgreSQL Flexible Server | accepted | Single primary database; supersedes Supabase as the DB layer. UUID PKs, soft delete, `TIMESTAMPTZ`, RLS on the immutable `audit_log`. |\\n| [0006](0006-email-azure-communication-services.md) | Transactional email via Azure Communication Services | accepted | Resolves open question Q2; supersedes the \\\"Brevo or SendGrid\\\" option. |\\n| [0021](0021-sms-deferred-entra-acs-email.md) | SMS deferred for Phase 1 DEV | accepted | Email-only OTP for DEV go-live; ACMA / alphanumeric-sender-ID constraints documented; any future SMS requires a new ADR. |\\n| [0028](0028-transactional-sms-twilio.md) | Transactional SMS via Twilio | accepted | Amends ADR-0021; adds non-OTP SMS through an outbox + BullMQ job pipeline. Login OTP remains email-only. |\\n\\n### Data &amp; schema\\n\\n| ADR | Topic | Status | Notes |\\n| --- | --- | --- | --- |\\n| [0007](0007-obfuscated-physical-schema.md) | Obfuscated, party-model physical schema | accepted | Generic `core.*` tables (`t_party`, `t_rel`, `t_node`, `t_artifact`, `t_check`, \u2026) with opaque codes; logical\u2194physical mapping lives in the wiki Schema Mapping page and `meta.*` dictionary tables. |\\n| [0016](0016-derived-job-state.md) | Job state derived from the project, not stored on the engagement | accepted | \\\"Active / completed jobs\\\" computed from project lifecycle + placement liveness; migration `015` adds trade-side visibility helpers. |\\n| [0018](0018-engagement-scoped-requirements.md) | Engagement-scoped requirements and computed readiness | accepted | Implements ADR-0005 layers 4\u20135 via `t_req_set`, `t_req_item`, `t_link_tag`; readiness is computed from a requirement union, never copied. |\\n\\n### Security &amp; tenancy\\n\\n| ADR | Topic | Status | Notes |\\n| --- | --- | --- | --- |\\n| [0003](0003-repository-layer-tenant-isolation.md) | Repository-layer multi-tenant isolation | accepted | Every tenant-scoped query filters on `tier1_org_id` / `trade_org_id` inside `repository.ts`; platform-admin bypass is explicit and audited. |\\n| [0008](0008-bff-proxy-session-auth.md) | BFF proxy + server-issued session cookie | accepted | Resolves pre-build review item B3. Browser talks only to the Next.js origin; Express signs a 12-hour `awx_session` cookie containing identity only; tenant scope resolved from `t_grant` per request. |\\n| [0013](0013-login-email-change.md) | Login email change (dual-verify, stable actor) | accepted | `t_actor` is the durable principal; email change proves control of old and new inboxes; writes `core.t_email_change`. |\\n| [0025](0025-native-auth-bff-proxy.md) | Entra Native Authentication via BFF proxy | accepted | Custom Assureworx screens for sign-up/OTP/reset; Express proxies Native Auth (which does not support CORS); continuation tokens in short-lived httpOnly cookies. |\\n\\n### Identity &amp; verification\\n\\n| ADR | Topic | Status | Notes |\\n| --- | --- | --- | --- |\\n| [0005](0005-verified-scope-hybrid.md) | \\\"Assureworx Verified\\\" is a hybrid | accepted | Portable base profile (layers 1\u20133) + per-engagement readiness (layers 4\u20135); a trade can be Verified yet not ready for a particular site. |\\n| [0009](0009-realtime-verification-orchestration.md) | Real-time verification orchestration | proposed | One verification engine behind `/verify` and `/onboarding`; Verification Run as the unit of work; provider-adapter pattern; async-first with webhooks; explicitly refuses fake pass states. |\\n| [0012](0012-assureworx-id-and-identity-resolution.md) | Assureworx ID and the identity resolution ladder | proposed | Adds public `AWX-XXXX-XXXX` identifier; recognition ladder `D01` ABN \u2192 `D02` ACN \u2192 `D03` email \u2192 `D04` KYC subject reference; splits \\\"onboard\\\" from \\\"connect\\\". |\\n| [0014](0014-didit-identity-verification.md) | Didit for Phase 1 identity verification | accepted | Resolves Q1. ID Document + Passive Liveness + Face Match; outcome-only storage; `IdentityVerifier` boundary; consent as a precondition. |\\n| [0015](0015-identity-reverification-triggers.md) | Trigger-driven identity re-verification | accepted | Re-verify on document expiry or holder notification, never on a timer; consent expires at 24 months; withdrawal destroys the outcome. |\\n| [0019](0019-document-extraction-over-registry-verification.md) | Document extraction over registry verification | accepted | Tiered extraction (PDF text layer \u2192 local OCR \u2192 cloud); 0.7 confidence threshold; extraction proposes, never verifies. |\\n| [0020](0020-azure-document-intelligence-tier-3.md) | Azure AI Document Intelligence as tier 3 | accepted | Amends ADR-0019 \u00a77. Pinned `prebuilt-read` model; managed identity only; layered spend caps; extraction becomes the sole evidence route for licences and insurance. |\\n| [0026](0026-identity-check-diagnostics.md) | Identity check diagnostics: telemetry, not the person | accepted | Extends ADR-0014/0015. Whitelisted non-identifying diagnostics in `t_check.diag`; `session_expires_at` as a first-class column; coaching copy in the UI. |\\n\\n### Commercial &amp; billing\\n\\n| ADR | Topic | Status | Notes |\\n| --- | --- | --- | --- |\\n| [0004](0004-pricing-model.md) | Pricing: free Principal Contractors, $49/yr trade subscription | superseded | Superseded by ADR-0023 on the revenue mechanism. The \\\"Principal Contractors are free\\\" principle survives. |\\n| [0023](0023-pay-per-verification.md) | Pay per verification | accepted | One purchase funds one verification cycle; price pinned in `backend/src/modules/payments/pricing.ts` ($55 AUD incl. GST); three payer shapes including Principal Contractor sponsorship. |\\n| [0024](0024-payment-provider-boundary.md) | Stripe behind a payment provider boundary | accepted | `PaymentProvider` interface with a single `stripe.ts` adapter; official SDK with pinned API version; no mock provider reachable from configuration. |\\n\\n### Operations &amp; lifecycle\\n\\n| ADR | Topic | Status | Notes |\\n| --- | --- | --- | --- |\\n| [0010](0010-pc-cold-start-admin-provisioned.md) | Phase 1 PC cold-start is Platform Admin provisioned | accepted | No public PC self-registration in Phase 1; founder provisions org + first admin via `POST /admin/principal-contractors`. |\\n| [0011](0011-in-app-support-desk.md) | In-app support desk, not ServiceNow | accepted | `core.t_sr` / `core.t_sr_note`; SLA clocks (P1\u2013P4); Platform Admin monitor at `/support/monitor`. |\\n| [0017](0017-employee-deregistration.md) | De-registering an employee decouples, it does not delete | accepted | Retires the `L01` employment edge and live site placements; the person keeps their identity, evidence, and login. |\\n| [0022](0022-invitation-verification-template-snapshots.md) | Invitation verification templates use immutable snapshots | accepted | Each invitation copies the selected check/document keys to `t_request.verification_keys`; later template edits do not alter sent invitations. |\\n| [0027](0027-data-retention-and-public-legal.md) | Data retention schedule and public Privacy / Terms | accepted | Versioned `/privacy` and `/terms` pages; retention schedule v1; retention sweeper; erasure keeps inactive network edges labelled \\\"Withdrew their information\\\". |\\n\\n---\\n\\n## Cross-cutting patterns\\n\\nSeveral decisions are not one-offs \u2014 they establish a pattern that later ADRs deliberately repeat. Recognising these makes the log much faster to read.\\n\\n### 1. Provider boundary (narrow interface, one adapter file)\\n\\nADR-0014 established the shape for identity: a narrow internal interface (`IdentityVerifier`), one adapter file (`backend/src/modules/identity/didit.ts`), and application code that never imports vendor types. ADR-0024 repeats it for payments (`PaymentProvider` with four methods, `stripe.ts` as the only file permitted to import Stripe), and ADR-0020 applies the same discipline to Document Intelligence (model ID pinned in source, not configuration). The consistent intent: **the provider is a commodity, the business rules are not.** A provider swap is a new adapter, not a UI or service rewrite.\\n\\n### 2. BFF same-origin (browser never talks across origins)\\n\\nADR-0008 made the Next.js origin the browser's only server: `next.config.ts` rewrites `/api/v1/*` to the Express API, and the session cookie is therefore always first-party. ADR-0025 extends the same thinking to Entra Native Auth \u2014 which does not support CORS at all \u2014 by proxying it through Express. The pattern recurs whenever a new browser-facing integration appears: if it cannot be same-origin, it does not ship directly to the client.\\n\\n### 3. Derived over stored\\n\\nA recurring philosophy: compute state at read time rather than persisting a value that can go stale.\\n\\n- ADR-0016 \u2014 job state derives from the project lifecycle and placement liveness, not a new engagement state machine.\\n- ADR-0018 \u2014 readiness is computed from a union of requirement sources; only the gap *counts* are stored, and gap *detail* is always recomputed on read.\\n- ADR-0015 \u2014 the immutable check outcome and the displayed \\\"current\\\" identity status are separate; currency is derived from the check, the consent record, and today's date.\\n\\n### 4. Honest verification \u2014 no fake green ticks\\n\\nThe log repeatedly refuses to display a pass state that no real authority produced:\\n\\n- ADR-0009 rejects client-side timers and mock pass/fail in `/verify`, and refuses to mark licence or watchlist as Passed until a real provider says so.\\n- ADR-0019 makes extraction **propose, never verify** \u2014 `F04` (API verified) is unreachable by extraction.\\n- ADR-0020 keeps \\\"read from your document\\\" and \\\"verified by Assureworx\\\" visibly different in the UI, and removed the simulated registry lookup from `verify-demo.ts`.\\n- ADR-0014 rejected keeping the identity mock on the grounds that displaying \\\"verified\\\" off a mock is a misleading-conduct exposure.\\n\\n### 5. Audited by default\\n\\nIsolation bypasses, status transitions, and provider calls are all required to leave an audit trail:\\n\\n- ADR-0003 \u2014 platform-admin bypass is always written to the immutable audit log.\\n- ADR-0002 \u2014 RLS protects the immutable `audit_log`.\\n- ADR-0018 \u2014 `link.readiness_changed` records which requirements were unmet and where each came from.\\n- ADR-0020 \u2014 every billable Document Intelligence read is audited (`extraction.cloud_read`) with no document content.\\n- ADR-0027 \u2014 audit events are retained for 24 months rolling.\\n\\n### 6. No mock fallback for money or identity\\n\\nADR-0024 states this explicitly: no mock provider is reachable from configuration because a mocked payment would render to a worker as \\\"Paid by you\\\" with no money behind it. `FakePaymentProvider` exists only for tests and is injected directly by them. ADR-0014 sets the same precedent for identity \u2014 there is no mock KYC path at all.\\n\\n---\\n\\n## Decision dependencies\\n\\nThe diagram below shows the main dependency chains: which decisions build on which. It is not exhaustive \u2014 it highlights the backbone a new contributor should learn first.\\n\\n```mermaid\\ngraph LR\\n    S1[ADR-0001Azure stack] --&gt; S2[ADR-0002PostgreSQL]\\n    S2 --&gt; D1[ADR-0003Tenant isolation]\\n    D1 --&gt; D2[ADR-0007Obfuscated schema]\\n    V1[ADR-0005Verified hybrid] --&gt; V2[ADR-0018Engagement requirements]\\n    V1 --&gt; V3[ADR-0009Verification orchestration]\\n    V3 --&gt; V4[ADR-0014Didit identity]\\n```\\n\\nTwo chains are worth calling out explicitly because they cross theme boundaries:\\n\\n- **The verification chain.** ADR-0005 defines the hybrid Verified model \u2192 ADR-0009 turns it into an orchestrated real-time flow \u2192 ADR-0014 picks Didit as the KYC provider \u2192 ADR-0015 defines *when* re-verification happens \u2192 ADR-0018 makes the per-engagement half of ADR-0005 executable \u2192 ADR-0019/0020 make document evidence machine-readable \u2192 ADR-0026 adds failure diagnostics without weakening the privacy boundary.\\n- **The commercial chain.** ADR-0004 sets free Principal Contractors + paid trade subscriptions \u2192 ADR-0023 replaces the subscription with pay-per-verification \u2192 ADR-0024 constrains how payments are implemented (provider boundary, pinned price, no mock).\\n\\nThe supersession chain is short: ADR-0004 \u2192 ADR-0023 (revenue mechanism), plus the amendment edges ADR-0019 \u2192 ADR-0020 and ADR-0021 \u2192 ADR-0028.\\n\\n---\\n\\n## Suggested reading order\\n\\nFor a developer new to the codebase, the following order builds context in the fewest reads:\\n\\n| Step | ADRs | Why first |\\n| --- | --- | --- |\\n| 1 | 0001, 0002 | The platform shape \u2014 everything else sits on this stack. |\\n| 2 | 0008 | Auth/session \u2014 affects every API call you will make or debug. |\\n| 3 | 0003, 0007 | Before touching any query or table \u2014 tenant isolation and the obfuscated schema are non-negotiable constraints. |\\n| 4 | 0005, 0018 | The compliance model and how readiness is actually computed. |\\n| 5 | 0009, 0014, 0015, 0019, 0020, 0026 | The verification pipeline in implementation order. |\\n| 6 | 0023, 0024 | Payments \u2014 small, self-contained, and well-patterned. |\\n| 7 | 0027 | Privacy, retention, and legal obligations before go-live work. |\\n\\n---\\n\\n## Working with this module\\n\\n### Adding a new decision\\n\\n1. **Take the next sequential number.** The log is append-only; numbers are never reused.\\n2. **Write the record in the standard shape** \u2014 Context, Decision, Consequences, Related \u2014 with frontmatter (`type: decision`, `status`, `updated`, optional `sources` and `tags`).\\n3. **Status discipline.** Start at `proposed` if the decision is not yet settled; move to `accepted` when it is confirmed. If a decision replaces an earlier one, mark the earlier one `superseded` and link forward from its status line.\\n4. **Resolve open questions by number.** Decisions frequently exist to close items on the [open questions page](open-questions.md) (e.g., ADR-0005 resolves Q9, ADR-0006 resolves Q2, ADR-0014 resolves Q1). State which question you are closing, and file any new ones the decision raises (e.g., ADR-0018 raises Q30/Q31).\\n5. **Cross-link related ADRs.** If your decision implements, amends, or depends on another, say so in the Related section. If it amends a specific clause of an accepted decision, add an amendment note to the amended record as well \u2014 do not silently rewrite it.\\n\\n### Obligations a new decision may trigger\\n\\n- **Schema changes** \u2014 per ADR-0007, any new table, column, or code value must be added to the wiki Schema Mapping page *and* the `meta.*` dictionary tables in the same migration. A schema change without a mapping update fails review.\\n- **New sub-processors or data flows** \u2014 per ADR-0020 and ADR-0027, a provider that receives customer data (as Didit, Stripe, and Azure Document Intelligence do) requires an ISMS supplier-register and data-inventory update before the first live call, plus an APP 8 cross-border position.\\n- **New SMS** \u2014 per ADR-0021, any future SMS (branded or numeric) requires a new ADR covering provider, sender type, ACMA status, DPA, spend/abuse caps, and data-inventory rows.\\n- **Money or identity outcomes** \u2014 per the ADR-0024 / ADR-0014 pattern, no mock implementation may be reachable from configuration for anything that produces a paid, verified, or issued state.\\n\\n---\\n\\n## Connection to the rest of the repository\\n\\nThe decisions module is a hub. Every record links outward to the wiki's other sections and inward to named code artifacts.\\n\\n**Wiki sections referenced throughout:**\\n\\n- `wiki/architecture/` \u2014 infrastructure, stack, data model, backend, security, integrations, schema mapping (authoritative for ADR-0007), and build-brief pages such as `document-extraction.md` and `identity-verification.md`.\\n- `wiki/product/` \u2014 compliance &amp; verification, roles &amp; permissions, billing, account &amp; invite journeys, identity &amp; portability, engagement requirements, entity hierarchy.\\n- `wiki/runbooks/` \u2014 operational procedures such as `acs-email-dev.md`, `entra-external-id-dev.md`, `didit-retention-30-days.md`, and `wallet-passes.md`.\\n- `wiki/isms/` \u2014 data inventory, supplier posture documents (`doc-intel-posture.md`, `didit-posture.md`), and the data-protection plan.\\n- `wiki/sources/` \u2014 the input specifications (`assureworx-platform-spec.md`, `assureworx-product-definition.md`) that decisions cite and sometimes override.\\n\\n**Named code touchpoints** (the implementation surface the ADRs constrain):\\n\\n| Area | Named artifacts in the ADRs |\\n| --- | --- |\\n| Tenant isolation | `repository.ts`, `resolve-tenant-context.ts`, `req.tenantContext` |\\n| Auth / session | `next.config.ts` rewrites, `require-auth.ts`, `backend/src/shared/auth/native-auth.ts`, `scripts/enable-ciam-native-auth.ps1`, `awx_session` cookie |\\n| Verified status | `backend/src/shared/services/verified-status.ts` (`computeStatus`, `recomputeAndPersist`), `backend/src/shared/services/evidence.ts` (`deriveEvidenceStatus`) |\\n| Verification orchestration | Verification Run APIs under `/api/v1/verification/runs`, provider adapters (`IAbnVerifier`, `IKycProvider`, `ILicenceVerifier`), `frontend/src/lib/verify-demo.ts` (to be replaced) |\\n| Identity | `backend/src/modules/identity/didit.ts`, `IdentityVerifier`, `frontend/src/lib/identity.ts` (`identityCoaching`), `didit-boundary.test.ts` |\\n| Document extraction | `rules.ts`, `sources.ts` (`canOcrImprove`), `cloud-read.ts`, `preprocess.ts`, `backend/scripts/probe-cloud-read.ts` |\\n| Payments | `backend/src/modules/payments/pricing.ts`, `modules/payments/provider/types.ts`, `stripe.ts`, `FakePaymentProvider` (tests only) |\\n| Retention / legal | `backend/src/modules/retention/` sweeper, `frontend/src/app/(legal)/privacy/page.tsx`, `frontend/src/app/(legal)/terms/page.tsx` |\\n| Support desk | `core.t_sr`, `core.t_sr_note` |\\n| Migrations | `001`\u2013`031` and later; the ADRs cite the specific migration that implements each schema decision (e.g., `007_email_change.sql`, `015_trade_engagement_visibility.sql`, `022_invitation_verification_templates.sql`, `029_check_diagnostics.sql`, `030_retention_and_account_closure.sql`) |\\n\\nThe pragmatic rule for contributors: **before you change behaviour in any of these files, find the ADR that governs it.** If no ADR covers the change, the log is the place to record one.\",\"product-architecture-documentation-design-md\":\"# Product &amp; Architecture Documentation \u2014 DESIGN.md\\n\\n# DESIGN.md \u2014 Assureworx Design System Specification\\n\\n## Purpose\\n\\n`DESIGN.md` is the product and architecture specification for the Assureworx design system. It defines the visual language, component behavior, and interaction rules for the operational CRM shell in `frontend/`. The document serves two audiences:\\n\\n- **Designers and product managers** who need a canonical statement of the \\\"Evidence Desk\\\" north star and its constraints.\\n- **Frontend developers** who implement components and need to know which tokens to consume, which patterns are sanctioned, and which are explicitly prohibited.\\n\\nThis is a static specification document \u2014 it contains no executable code, no imports, and no runtime behavior. Its influence on the codebase is indirect but authoritative: it is the source of truth that `frontend/src/styles/tokens.css` materializes as CSS custom properties, and it constrains the component implementations under `frontend/src/components/`.\\n\\n## Document Anatomy\\n\\nThe file is a Markdown document with YAML frontmatter. The two parts serve different purposes:\\n\\n| Section | Role |\\n|---|---|\\n| Frontmatter (YAML) | Machine-readable token definitions: colors, typography scales, radii, spacing, and component presets. Values use a template reference syntax (`{colors.electric-blue}`) to compose presets from base tokens. |\\n| Body (Markdown) | Human-readable rules and rationale: the creative north star, design rules, component specs, and the do/don't list. |\\n\\nWhen the two disagree, the frontmatter tokens are the source of truth for concrete values; the body rules govern *how* those tokens may be used.\\n\\n## Token Model\\n\\nThe frontmatter defines a layered token architecture that maps onto CSS custom properties in `tokens.css`.\\n\\n### Color Tokens\\n\\nColors are grouped into four semantic layers:\\n\\n| Layer | Tokens | Usage |\\n|---|---|---|\\n| Primary | `electric-blue`, `electric-dark` | Actions, active nav, links; hover/pressed states |\\n| Secondary | `cyan`, `violet`, `deep-violet` | Network accents only; `deep-violet` is the portable status foreground |\\n| Neutral | `carbon` \u2192 `canvas` | Text ramp (`carbon`, `graphite`, `pewter`), chrome-only (`silver`), surfaces (`cloud`, `ash`, `canvas`) |\\n| Status | `status-verified-*`, `status-warning-*`, `status-error-*`, `status-info-*`, `status-portable-*` | Paired foreground/background for semantic states |\\n\\nThe body imposes three binding usage rules:\\n\\n- **Restrained Accent Rule:** Electric Blue may not exceed ~10% of any screen.\\n- **Status Trinity Rule:** Status is never communicated by color alone \u2014 always color + icon + label.\\n- **Pewter Secondary Rule:** Secondary copy and placeholders use `pewter` (\u22654.5:1 on Canvas/Ash). `silver` is reserved for borders and non-text chrome; it must never be used for readable text.\\n\\n### Typography Tokens\\n\\nA single UI family (Segoe UI Variable / Segoe UI) with a fixed rem scale. Hierarchy is communicated through size and weight, not color:\\n\\n| Style | Size / Weight | Purpose |\\n|---|---|---|\\n| `page` | 18px / 600 | Page `h1` |\\n| `display` | 20px / 600 | Numeric emphasis only (`--text-display`) |\\n| `title` | 13px / 600 | Panel headers, navigation |\\n| `body` | 13.5px / 400\u2013500 | Row primary text |\\n| `label` | 13px / 500 | Form labels |\\n| `meta` | 12px / 400 | IDs, dates, secondary information |\\n| `mono` | 12px floor | ABNs, IDs \u2014 Cascadia Code / Fira Code / Consolas |\\n\\nThe **One Family Rule** prohibits a second UI typeface. The only sanctioned exception is the public-flow brand pane at `/auth/login`, which may use a fluid clamp up to ~2.75rem for the promise line; the app shell remains on the fixed rem scale.\\n\\n### Shape, Spacing, and Elevation\\n\\n- Radii: `sm` 5px, `md` 6px, `lg` 8px, `pill` 999px.\\n- Spacing scale: `xs` 4px through `2xl` 32px.\\n- Elevation is flat by default: `--shadow-rest`, `--shadow-card`, and `--shadow-card-hover` are all `none`. Depth is conveyed with Cloud borders, not shadows. Focus is marked with a 2px Electric Blue ring plus canvas offset (`--focus-ring`).\\n\\n### Component Presets\\n\\nThe frontmatter `components` block defines base recipes for recurring UI primitives \u2014 `button-primary`, `button-secondary`, `card`, `chip-status`, `nav-item-active`, `shell-sidebar`, `shell-workspace`. These presets compose lower-level tokens via reference syntax, e.g. `button-primary` resolves to Electric Blue background, Canvas text, `md` radius, and 10px \u00d7 18px padding. This is the layer that `tokens.css` exposes as custom properties for the CSS module files.\\n\\n## Component Specifications\\n\\nThe body specifies the behaviors developers must implement. All components live under `frontend/src/components/`.\\n\\n| Component | Spec |\\n|---|---|\\n| **Buttons** | Primary: Electric Blue fill, white text. Secondary: Canvas fill, Cloud border. Primary chrome targets \u226544px; `size=\\\"sm\\\"` may drop to 36px for dense desktop tools. |\\n| **StatusChip** | Semantic background/foreground pair + icon + label. This is the enforcement mechanism for the Status Trinity Rule. |\\n| **Cards / Containers** | Canvas on Ash, 6\u20138px radius, Cloud border, no shadow. |\\n| **InputField / SelectField** | Canvas fill, Graphite placeholder text, Electric Blue focus ring. Both share the same invalid-state and `aria-*` wiring. |\\n| **Navigation** | Left sidebar; converts to a drawer at \u2264960px with `inert` when closed, Escape-to-close, focus trap, and `aria-expanded`. Top bar search is inline at \u22651100px and collapses to an icon-expand below that breakpoint. |\\n| **Dashboard status** | A dense status table (`Key indicators` panel) \u2014 indicator \u00b7 current \u00b7 context \u00b7 action \u2014 **not** a grid of hero metric cards. |\\n\\n## Integration with the Codebase\\n\\n```mermaid\\nflowchart LR\\n    A[DESIGN.mddesign specification] --&gt; B[tokens.cssCSS custom properties]\\n    B --&gt; C[CSS Modulesper-component styles]\\n    C --&gt; D[UI ComponentsStatusChip, InputField, ...]\\n    D --&gt; E[Assureworx Shellsidebar + top bar + workspace]\\n```\\n\\nThe dependency chain flows one way: `DESIGN.md` constrains `frontend/src/styles/tokens.css`, which drives the CSS modules, which style the components, which compose the shell. Changes should enter at the top of this chain \u2014 a component should never introduce an ad-hoc color or spacing value that is not backed by a token.\\n\\nKey integration points:\\n\\n- **Tokens:** all color and type values in component CSS modules must reference custom properties from `tokens.css`. No hardcoded hex values or font stacks.\\n- **Shell chrome:** the sidebar and sticky top bar are shared by Principal Contractor and Trade roles; role and membership determine navigation contents, not the chrome layout.\\n- **Accessibility:** status pairings must satisfy contrast on Canvas/Ash, touch targets on primary chrome are \u226544px, and all motion is gated behind `prefers-reduced-motion`. Motion, when present, is state-only at 120\u2013160ms ease-out.\\n- **Dashboard:** the status table replaces the conventional hero-metric card grid; new dashboard work should extend the table pattern rather than introduce card grids.\\n\\n## Maintenance Guidelines\\n\\nWhen editing `DESIGN.md`:\\n\\n- **Keep the frontmatter as the numeric source of truth.** Update values there first, then propagate to `tokens.css`.\\n- **Respect the reference syntax.** Component presets should compose existing tokens (`{colors.electric-blue}`), not introduce one-off values.\\n- **Preserve the rules.** The named rules (Restrained Accent, Status Trinity, Pewter Secondary, One Family, Flat-By-Default) are product decisions, not suggestions. Any change to them is a product-level change and should be reflected here before code.\\n- **Do not add new UI families, shadow chrome, or glassmorphism** \u2014 all three are explicitly rejected by the design direction.\\n- **Keep the do/don't list current.** It is the fastest onboarding reference for contributors and the enforcement checklist for code review.\",\"product-architecture-documentation-frontend\":\"# Product &amp; Architecture Documentation \u2014 frontend\\n\\n# @assureworx/frontend\\n\\n## Overview\\n\\n`@assureworx/frontend` is the Next.js (App Router) client for the AssureWorx CRM. It serves two distinct user personas \u2014 **Principal Contractor** and **Trade** \u2014 inside a single application shell. The two portals share the same host, layout, and visual system, but diverge in navigation and portal-specific surface area.\\n\\nThe frontend is currently a standalone shell: it has no wired internal module calls, no outgoing API requests, and no incoming webhook or server-triggered flows. It is designed to communicate exclusively with `@assureworx/backend` through the `/api/v1` route prefix once integration is connected.\\n\\n## Role in the Codebase\\n\\n```\\nrepo root\\n\u251c\u2500\u2500 DESIGN.md          # Visual system / design tokens contract\\n\u251c\u2500\u2500 PRODUCT.md         # Product-level design contracts\\n\u251c\u2500\u2500 wiki/brand.md      # Brand narrative\\n\u251c\u2500\u2500 backend/           # @assureworx/backend (API provider)\\n\u2514\u2500\u2500 frontend/          # This module (@assureworx/frontend)\\n```\\n\\nThe frontend is the presentation layer for a CRM workflow shared by principals and trades. It consumes design contracts from the root-level `DESIGN.md` and `PRODUCT.md` rather than defining its own visual language. Brand narrative lives in `wiki/brand.md`.\\n\\n## Architecture\\n\\n### Next.js App Router\\n\\nThe application is built on the Next.js App Router model, with the dashboard mounted at `/dashboard` in development. The module uses the standard App Router directory conventions for routes, layouts, and client/server component boundaries.\\n\\n### Dual-Portal Shell\\n\\nA single shell hosts both portals. The top bar contains a **Principal / Trade** toggle that switches between the two demo portals. Switching portals does not reload the application \u2014 it swaps the active navigation and portal context within the same shell instance.\\n\\n```\\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\\n\u2502  Top bar: [Principal | Trade] toggle    \u2502\\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\\n\u2502  Portal nav  \u2502  CRM content area        \u2502\\n\u2502  (varies by  \u2502  (shared shell)          \u2502\\n\u2502   selection) \u2502                          \u2502\\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n```\\n\\nThis design means the shell, layout, authentication boundary, and visual system are implemented once and shared. Portal-specific behavior is isolated to navigation and the feature surfaces rendered beneath it.\\n\\n## Design System &amp; Visual Contracts\\n\\nThe frontend does **not** own its design system. It consumes contracts from two root-level documents:\\n\\n| Document | Responsibility |\\n|---|---|\\n| `DESIGN.md` | Tokens, visual system, spacing, color, typography contracts |\\n| `PRODUCT.md` | Product-level design contracts and interaction patterns |\\n| `wiki/brand.md` | Brand narrative and voice |\\n\\nBrand assets live in `public/`:\\n\\n- `logo.png` \u2014 primary logo\\n- `logo-dark.png` \u2014 dark-mode variant\\n- `fav.png` \u2014 favicon\\n\\n## Backend Integration\\n\\nThe frontend is scoped to talk only to `@assureworx/backend`, and only via the `/api/v1` prefix. As of this writing, that integration is not yet wired \u2014 the module makes no outgoing calls. The routing contract is established, but no API client, fetch layer, or data-fetching patterns are active in the codebase yet.\\n\\nWhen integration lands, all network traffic should flow through `/api/v1`; the frontend should not call backend services through any other path.\\n\\n## Development\\n\\n### Prerequisites\\n\\n- Node.js and npm, installed at the repo root\\n- Backend not required for shell development (the frontend runs standalone)\\n\\n### Running Locally\\n\\n```bash\\n# from repo root\\nnpm install\\nnpm run dev:frontend\\n```\\n\\nThe dev server serves the dashboard at:\\n\\n```\\nhttp://localhost:3000/dashboard\\n```\\n\\nUse the **Principal / Trade** toggle in the top bar to switch between the two demo portals. Both portals render in the same shell; only the navigation and portal context change.\\n\\n## Repository Layout\\n\\n```\\nfrontend/\\n\u251c\u2500\u2500 README.md          # This documentation\\n\u251c\u2500\u2500 public/\\n\u2502   \u251c\u2500\u2500 logo.png       # Primary logo\\n\u2502   \u251c\u2500\u2500 logo-dark.png  # Dark-mode logo variant\\n\u2502   \u2514\u2500\u2500 fav.png        # Favicon\\n\u2514\u2500\u2500 (App Router source \u2014 routes, layouts, components)\\n```\\n\\nDesign tokens and brand contracts are intentionally kept outside this module at the repo root (`DESIGN.md`, `PRODUCT.md`, `wiki/brand.md`) so they remain the single source of truth for all consumers.\",\"product-architecture-documentation-product-md\":\"# Product &amp; Architecture Documentation \u2014 PRODUCT.md\\n\\n# PRODUCT.md \u2014 Product &amp; Architecture Reference\\n\\n## Overview\\n\\n`PRODUCT.md` is the source-of-truth product definition for **Assureworx**, the operational compliance CRM for Australian trade compliance. It is a documentation-only module: it contains no executable code, exports no functions or classes, and has no runtime API surface. Instead, it defines the product's identity, target users, positioning, brand constraints, design principles, and accessibility requirements that downstream implementation work must conform to.\\n\\nThe file is declarative and reference-oriented. Its value to a developer is as a decision record: when building or reviewing UI, navigation, role handling, or compliance-status components, this document tells you *what* the product is and *why* it is shaped that way.\\n\\n## Module Classification\\n\\nThe call graph for this module is empty:\\n\\n- **Internal calls:** none\\n- **Outgoing calls:** none\\n- **Incoming calls:** none\\n- **Execution flows:** none\\n\\nTreat `PRODUCT.md` as an architectural input, not a runtime dependency. It influences code indirectly \u2014 via design tokens, route definitions, role-based access logic, and component patterns \u2014 but nothing imports or executes it.\\n\\n## Product Identity &amp; Platform\\n\\nThe document opens with two registration fields:\\n\\n| Field | Value | Meaning |\\n|---|---|---|\\n| `Register` | `product` | Identifies this module as the product-level definition |\\n| `Platform` | `web` | The product is delivered as a web application |\\n\\nThe product name, **Assureworx**, is stated in the Product Purpose section. It is positioned explicitly as an operational CRM, not a marketing site:\\n\\n&gt; Assureworx is the operational CRM for Australian trade compliance: live status, network navigation, and evidence-led workflows \u2014 not a marketing site.\\n\\n## User Model\\n\\nTwo primary user types share a single Next.js application shell. Role and membership determine which routes and data each user sees.\\n\\n| User Type | Description | Primary Surface |\\n|---|---|---|\\n| **Principal Contractor** | Admins running a desktop-first compliance CRM | Desktop-first |\\n| **Trade** | Companies and individuals with a portable compliance profile | Mobile-capable within the same app |\\n\\nBoth user types share common navigation chrome:\\n\\n- Sidebar navigation\\n- Account menu in the top right\\n\\nThe v0 scope is deliberately limited: prove the shared shell works for **status visibility** and **navigation**. Deeper flows \u2014 invite/onboarding and project\u2013site readiness \u2014 are explicitly deferred to follow-up work.\\n\\n## Product Purpose &amp; v0 Success Criteria\\n\\nThe product's core job is to give users:\\n\\n- **Live status** \u2014 current compliance state at a glance\\n- **Network navigation** \u2014 move across the trade network, projects, and sites\\n- **Evidence-led workflows** \u2014 platform-confirmed status rather than self-declared claims\\n\\nThe four canonical compliance statuses are:\\n\\n```\\nVerified / Pending / Expiring / Suspended\\n```\\n\\nThe v0 success condition is concrete: a user can see compliance status at a glance and navigate the network inside a consistent app shell. The post-v0 roadmap is: invite \u2192 onboard \u2192 verified profile, followed by projects/sites readiness.\\n\\n## Positioning &amp; Brand\\n\\nThe market positioning statement is a single line:\\n\\n&gt; Complete trade compliance visibility.\\n\\n### Brand Personality\\n\\n- **Clear \u00b7 Assured \u00b7 Evidence-led**\\n- Calm, operational product guidance\\n- Direct and specific risk language \u2014 no fearmongering\\n- Australian English\\n- Trust, evidence, and monitoring lead the narrative \u2014 explicitly *not* \\\"AI platform\\\" framing\\n\\n### Anti-References\\n\\nThe document names three visual/tonal directions to avoid:\\n\\n1. **Neon / candy SaaS** \u2014 harsh pink\u2013purple palettes, playful Brutalist novelty\\n2. **Glassmorphism and \\\"AI sparkle\\\" chrome** in operational UI\\n3. **Dense enterprise grey** \u2014 cluttered tables, tiny type, no breathing room\\n\\nLayout may borrow familiar CRM patterns (sidebar + header + workspace), but colour, type, and density must follow the Assureworx brand palette:\\n\\n```\\nElectric Blue / Carbon / Ash\\n```\\n\\nnot candy colours or warehouse grey.\\n\\n## Design Principles\\n\\nFive principles govern product decisions. These are the closest thing this module has to a \\\"spec\\\" for implementation behaviour:\\n\\n1. **Trust must be visible** \u2014 status, source, and currency are immediately understandable.\\n2. **Complexity stays behind the interface** \u2014 the compliance chain can be deep; the screen stays simple.\\n3. **Evidence beats assertion** \u2014 show platform-confirmed status, not self-declared vibes.\\n4. **One shell, role-aware** \u2014 shared chrome; Principal Contractor and Trade each see the right hierarchy for their job.\\n5. **Status first for v0** \u2014 navigation and compliance overview come before deeper workflows.\\n\\nWhen implementing a screen, ask: does it make trust visible? Does it hide complexity? Is status evidence-backed? Does it respect the shared shell? Does it prioritise status?\\n\\n## Accessibility &amp; Inclusion\\n\\nThe accessibility requirement is:\\n\\n- **WCAG 2.2 AA** compliance\\n- Contrast requirements met\\n- Full keyboard use supported\\n- `prefers-reduced-motion` alternatives provided\\n\\nThe most important implementation rule is stated explicitly:\\n\\n&gt; Never communicate status by colour alone \u2014 always colour + icon + explicit label.\\n\\nThis applies directly to the four compliance statuses (`Verified`, `Pending`, `Expiring`, `Suspended`) \u2014 any UI rendering these states must pair colour with an icon and a text label.\\n\\n## Relationship to the Codebase\\n\\nAlthough `PRODUCT.md` has no runtime connections, it governs several implementation areas. The diagram below shows the mapping from the document to concrete code concerns:\\n\\n```mermaid\\ngraph LR\\n    P[PRODUCT.md] --&gt; U[User modelPrincipal Contractor / Trade]\\n    P --&gt; D[Brand &amp; design principlesElectric Blue / Carbon / Ash]\\n    P --&gt; A[AccessibilityWCAG 2.2 AA, colour + icon + label]\\n    P --&gt; S[Status modelVerified / Pending / Expiring / Suspended]\\n    U --&gt; N[Role-aware navigationroutes, membership, data visibility]\\n    D --&gt; T[Design tokenspalette, type, density]\\n    S --&gt; C[Compliance status UIstatus badges, evidence display]\\n    A --&gt; C\\n```\\n\\nConcretely, this means:\\n\\n- **Design tokens** \u2014 the `Electric Blue / Carbon / Ash` palette and the density/type guidance should be reflected in the theme and token definitions used by UI components.\\n- **Routing &amp; access control** \u2014 the Principal Contractor / Trade role split, combined with \\\"membership decides routes and data,\\\" maps to route definitions, navigation menus, and any data-scoping logic in the app shell.\\n- **Compliance status components** \u2014 the four statuses (`Verified`, `Pending`, `Expiring`, `Suspended`) and the colour + icon + label rule drive the implementation of compliance badges and status indicators.\\n- **App shell layout** \u2014 the shared sidebar + header + workspace pattern is the reference for the application shell component structure.\\n- **Copy &amp; tone** \u2014 Australian English, direct risk language, and the evidence-led framing should guide UI copy and error messaging.\\n\\n## Contribution Guidelines\\n\\nWhen updating `PRODUCT.md`, keep the following in mind:\\n\\n- This file is a **decision record**, not a changelog \u2014 changes here should reflect actual product decisions, not implementation details.\\n- Keep the v0 scope explicit. If scope changes, update the v0 success criteria and the \\\"what comes next\\\" statements together.\\n- Brand and anti-reference guidance are constraints. If a new visual direction is introduced, the anti-reference list and design principles must be updated in the same change.\\n- Any new compliance status or user type added to the product must be reflected here first, since this document is the source of truth for downstream UI and access-control work.\",\"product-architecture-documentation-product\":\"# Product &amp; Architecture Documentation \u2014 product\\n\\n# Product &amp; Architecture Documentation \u2014 `product` module\\n\\nThe `product` module is the product-level documentation for Assureworx. It describes what the platform does \u2014 the entity model, roles, journeys, verification rules, identity model, and billing \u2014 and records the decisions and open questions that shape them. It sits between the source specifications (`assureworx-platform-spec`, `assureworx-product-definition`, cited per-file in `sources` front-matter) and the architecture documentation (`../architecture/*`), with ADRs (`../decisions/*-*.md`) recording *why* the current design was chosen.\\n\\n## Overview\\n\\nAssureworx is a compliance and verification platform for the construction industry. Principal Contractors (Tier 1 organisations) verify the trades working on their projects \u2014 identity, licences, insurance, qualifications \u2014 and the resulting profile is *portable*: it belongs to the trade and travels with them across principals. This module documents:\\n\\n- **Structure** \u2014 how organisations, people, and work are arranged (`entity-hierarchy.md`, `roles-and-permissions.md`).\\n- **Verification** \u2014 what \\\"Assureworx Verified\\\" means, how status is reached and lost, and how per-placement readiness differs from portable status (`compliance-and-verification.md`, `engagement-requirements.md`, `trade-classifications.md`).\\n- **Journeys** \u2014 how parties enter the platform, from Platform Admin cold-start to salaried employees and PC project creation (`account-and-invite-journeys.md`, `onboarding-and-request-chain.md`, `employee-verification-journey.md`, `pc-create-project-journey.md`, `pc-project-request-journey.md`).\\n- **Identity** \u2014 how the platform recognises the same person or business across principals (`identity-and-portability.md`).\\n- **Commercial** \u2014 the pay-per-verification billing model (`billing.md`, `employer-pays-for-crew.md`).\\n- **Module catalog** \u2014 mapping of the platform spec \u00a75 modules to these pages and to backend/frontend modules (`modules.md`).\\n\\nA freshness caveat applies across the module: several files carry `\u26a0 Demo today` or `\u2753 Open` callouts, and some statements are superseded by later entries elsewhere. The docs distinguish **product truth** from **implementation state**, so a \\\"not implemented\\\" flag may be stale. For example, `entity-hierarchy.md` still marks profile portability as unimplemented because it predates the 2026-07-26 closure recorded in `identity-and-portability.md`. When files disagree, check `updated` dates and the linked ADRs.\\n\\n## Module layout\\n\\n| File | Covers |\\n| --- | --- |\\n| `entity-hierarchy.md` | The org tree \u2014 Tier 1 \u2192 Projects \u2192 Sites, Trade Network, Training, Incidents \u2014 and tenancy rules. |\\n| `roles-and-permissions.md` | The five roles, tenant boundaries, and notable access restrictions. |\\n| `account-and-invite-journeys.md` | End-to-end account/email entry paths (A\u2013E): PC cold-start, PC seats, trade invites, trade workforce, day-2 account admin. |\\n| `onboarding-and-request-chain.md` | Request types (`direct_company`, `direct_individual`, `sub_request`, `connection`, `top_up`), the invite wizard, and profile portability. |\\n| `identity-and-portability.md` | The Assureworx ID, the identity resolution ladder, and what recognition can and cannot do. |\\n| `compliance-and-verification.md` | The Verified lifecycle, five core checks, requirement layers, blocks, and expiry monitoring. |\\n| `engagement-requirements.md` | Per-placement readiness (layers 4\u20135), requirement sources, blocks, and top-up requests. |\\n| `trade-classifications.md` | Seed trade codes (`EL`, `PL`, `CB`, \u2026) and their mandatory documents. |\\n| `pc-create-project-journey.md` | The four-step New project wizard. |\\n| `pc-project-request-journey.md` | The project-bound request composer and contractor workforce branching. |\\n| `employee-verification-journey.md` | How a trade company onboards salaried tradies. |\\n| `employer-pays-for-crew.md` | Employer-sponsored bulk payment and its guardrails. |\\n| `billing.md` | Stripe billing, the verification-case unit of sale, data model, refunds and disputes. |\\n| `modules.md` | Spec \u00a75 module catalog (5.1\u20135.10) summarising what has no dedicated page yet. |\\n\\n### Document map\\n\\n```mermaid\\nflowchart LR\\n  AI[Account &amp; invite journeys] --&gt; OC[Onboarding &amp; request chain]\\n  IP[Identity &amp; portability] --&gt; OC\\n  OC --&gt; CV[Compliance &amp; verification]\\n  ER[Engagement requirements] --&gt; CV\\n  TC[Trade classifications] --&gt; CV\\n  PC[PC project journeys] --&gt; OC\\n  BI[Billing &amp; payments] --&gt; EP[Employer pays for a crew]\\n  EP --&gt; EV[Employee verification journey]\\n```\\n\\nArrows read as \\\"feeds into\\\": entry journeys create requests, identity makes existing parties recognisable, the request chain produces compliance obligations, and engagement requirements layer per-placement rules on top of the portable profile.\\n\\n## Domain model\\n\\n### Entity hierarchy\\n\\nThe platform is a strict tenancy tree (`entity-hierarchy.md`):\\n\\n- **Platform Admin** sits above all organisations; every other role is scoped to one node.\\n- **Tier 1 Organisation** (Principal Contractor) is the tenant boundary \u2014 all project/site, network, training, and incident data is private to it.\\n- **Projects \u2192 Sites** are the Tier 1's work units; trades are assigned at **site** level.\\n- **Trade Network** under a Tier 1 contains **Trade Companies** (with salaried employees and sub-contractors) and **Sole Traders** engaged directly.\\n- **Training modules** and the **Incident Log** belong to the Tier 1; incident data is never shared with trades (a trade receives a bare notification only).\\n\\nKey rules: a Tier 1 can see every individual in its network (direct plus via companies); sub-requests chain back to the root Tier 1 request via `chain_root_id`; salaried employees' documents are managed by their company admin; sole-trader sub-contractors manage their own documents.\\n\\n### Roles and tenancy\\n\\n| Role | Scope | Notes |\\n| --- | --- | --- |\\n| Platform Admin | Global | Manual document review, support, billing, feature flags, complimentary subscriptions. Bypass is explicit and audit-logged. |\\n| Tier 1 Admin | Own Tier 1 org | Projects/sites, requests, training, network, incidents, billing. |\\n| Tier 1 User | Own Tier 1 org (read) | Dashboards, compliance status, training reports. |\\n| Trade Company Admin | Own company | Company profile, employees, employee docs, sub-requests. |\\n| Trade Individual | Own profile | Own compliance profile, documents, training, consent over Tier 1 access. |\\n\\nTenant isolation is enforced at the **repository layer** \u2014 every tenant-scoped query carries a `tier1_org_id` or `trade_org_id` filter ([ADR-0003](../decisions/0003-repository-layer-tenant-isolation.md)) \u2014 not at the route level. Notable boundaries: trades cannot see incident records, even their own; a Tier 1 sees document **status** only, never the files; billing status is checked *after* auth and tenant resolution so a suspended user can still log in and see why.\\n\\n&gt; **Terminology trap:** in the data model, **Tier 1 = Principal Contractor** (`tier1_admin`). Some product pages loosely call the *trade company admin doing the hiring* \\\"Tier 1\\\". Employee seats are created by `trade_company_admin` (`R04`), never by `tier1_admin` (`employee-verification-journey.md`).\\n\\n### Core statuses and codes\\n\\n| Concept | Codes | Meaning |\\n| --- | --- | --- |\\n| Party verification | `t_party.status_cd` S01\u2013S04 | Pending \u2192 Verified \u21c4 Action required \u2192 Suspended (portable profile state). |\\n| Placement readiness | `t_link.state_cd` J01\u2013J04 | Ready / pending / action required / blocked **for one placement** \u2014 deliberately separate from party status. |\\n| Request kinds | `direct_company`, `direct_individual`, `sub_request`, `connection` (Q04), `top_up` (Q05) | How a trade enters or connects. |\\n| Seat kinds | I02 (PC colleague), I03 (employee) | Pending invitations, accepted via `/auth/invite/{token}`. |\\n| Identity kinds | `t_party_ident`: D01 ABN, D02 ACN, D03 login email, D04 KYC subject, D05 trade licence | Platform-wide `UNIQUE (kind_cd, value_txt)` among live rows. |\\n| Evidence statuses | Missing, Submitted, `F01` hold, `F02`/`F04` verified, Expiring soon, Expired, Rejected | F02 = Document Intelligence auto-accept or admin approve; F04 = API verified. |\\n\\nThe Verified lifecycle is the product's centre of gravity:\\n\\n```\\nPENDING \u2500\u2500(all mandatory checks pass)\u2500\u2500\u25b6 VERIFIED \u21c4 ACTION REQUIRED \u2500\u2500(hard req expires)\u2500\u2500\u25b6 SUSPENDED\\n                                            \u25b2                                                    \u2502\\n                                            \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500(replacement verified)\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n```\\n\\n## Compliance &amp; verification\\n\\n**Assureworx Verified** is defined carefully: the required checks for the relevant profile and context **passed at the recorded time** \u2014 not a government endorsement, not a quality guarantee, and not a permanent status (`compliance-and-verification.md`). To reach Verified, all of the following must hold: ABN verified via the ABR; KYC identity passed (government ID + selfie liveness, handled by Didit); all mandatory documents uploaded for the selected trade classifications; all documents verified \u2014 via API where available, otherwise by Platform Admin manual review (founder review in Phase 1).\\n\\n### The five core checks\\n\\nThe public verification surface is: **1. ABN validated \u00b7 2. Identity confirmed \u00b7 3. Licence current \u00b7 4. Insurance current \u00b7 5. Qualifications verified or on record.** Site induction and engagement-specific requirements sit *alongside* these portable checks.\\n\\n### Requirement layers\\n\\nRequirements evaluate in five layers ([ADR-0005](../decisions/0005-verified-scope-hybrid.md)):\\n\\n1. **Universal profile** \u2014 identity and foundational evidence (portable).\\n2. **Business** \u2014 ABN, entity status, company insurance (portable).\\n3. **Trade-class** \u2014 licences, registrations, qualifications for selected work (portable).\\n4. **Principal Contractor** \u2014 org-specific policy and onboarding evidence (per-engagement).\\n5. **Project &amp; site** \u2014 site induction, training, location rules (per-engagement).\\n\\nLayers 1\u20133 drive the portable `t_party.status_cd`; layers 4\u20135 drive per-placement `t_link.state_cd`, computed as the **union** of portable requirements and the applicable org/project/site/placement requirement sets ([ADR-0018](../decisions/0018-engagement-scoped-requirements.md)). Applicability is **rules-driven**: a requirement applies by what the principal *engaged the trade for* (`t_link_tag`), not what the trade self-selected (`t_party_tag`) \u2014 the \\\"Tom Delaney\\\" scenario in `engagement-requirements.md` is a carpenter engaged for electrical works who needs the electrical licence that his neighbour, also a carpenter, does not.\\n\\n### Blocks\\n\\n| Block | Codes | Effect |\\n| --- | --- | --- |\\n| Warning (soft) | B02 | Alert + visible risk state; status may remain current. |\\n| Engagement block | \u2014 | Blocked for *that placement* only; the portable profile can stay Verified. |\\n| Profile suspension (hard) | B01 | Verified removed for affected scope; all active Tier 1s notified. |\\n\\n(B03 advisory is recorded and shown but never affects readiness.) Readiness resolves in strict order: suspended profile \u2192 blocked; any unmet hard requirement \u2192 blocked; pending profile \u2192 pending; action required or any unmet soft requirement \u2192 action required; otherwise ready.\\n\\nAn engagement gap produces a **top-up request** (`Q05`) carrying only the delta \u2014 designed but not yet built as of the file's last update. Site assignment still refuses a **suspended** party outright; an engagement gap is a reason to ask, not a reason to refuse.\\n\\n### Expiry monitoring\\n\\nA daily CRON (`document.expiry.check`, midnight AEST) drives notifications and status changes: **30 days out** \u2192 in-app + email to the trade; **14 days** \u2192 also all Tier 1 admins in network; **expired** \u2192 Verified revoked immediately (\u2192 Suspended) with all active Tier 1s notified; **renewed and re-verified** \u2192 auto-reinstated. Per-document, `t_artifact_ack` lets each Principal Contractor independently accept or decline a document for their own organisation without overwriting portable verification. Document extraction assists review but never sets a status \u2014 \\\"Read from your document\\\" and \\\"Verified by Assureworx\\\" are deliberately different claims.\\n\\n## Journeys\\n\\n### Account and invite journeys (A\u2013E)\\n\\n`account-and-invite-journeys.md` defines five entry paths:\\n\\n- **A \u2014 First Principal Contractor (cold start).** Platform Admin provisions the org + first admin via `POST /api/v1/admin/principal-contractors` or `scripts/provision-pc.mjs`. Creates org party `K01`, admin person, actor, grant `R02`, complimentary ledger, and set-up seat token. Public self-serve PC registration is explicitly out of Phase 1 ([ADR-0010](../decisions/0010-pc-cold-start-admin-provisioned.md)). Local/DEV still bootstraps Campbell via `backend/src/seed.ts`.\\n- **B \u2014 PC colleague seat.** Tier 1 Admin invites by email + role (`POST /api/v1/memberships/seats`, seat kind I02); acceptance grants R02/R03 on the same org, no wizard.\\n- **C \u2014 Trade invite.** Tier 1 Admin sends a request (`direct_company` / `direct_individual`); the ACS email carries `/verify/i/{token}` (primary) with `/onboarding/invite/{token}` still present. API honesty: when ACS is unset, APIs return `emailSent: false` and the UI shows \\\"Invite ready \u2014 copy link\\\", not \\\"Email sent\\\".\\n- **D \u2014 Trade workforce.** Employees (`L01` employment, seat I03) and subcontractors (`L04` sub-request) are kept separate in UI copy and APIs \u2014 Team lists employees only.\\n- **E \u2014 Day-2 account admin.** Credentials live in Entra External ID, not Postgres; Assureworx owns display identity (`t_party` / `t_grant`); sessions come from the Express BFF ([ADR-0025](../decisions/0025-native-auth-bff-proxy.md)). Password setup flows through `/auth/credentials` \u2192 `/auth/verify-code` \u2192 `/auth/created`. Login email changes are dual-verify ([ADR-0013](../decisions/0013-login-email-change.md)); admin \\\"force reset\\\" is a Microsoft Graph operation, never a password-column write.\\n\\n### Onboarding and request chain\\n\\nEvery trade enters via a **request**; the chain is the audit trail linking every individual back to the Tier 1 that required them (`onboarding-and-request-chain.md`). `onboarding_requests` links each row via `parent_request_id` and `chain_root_id`, with **no enforced subcontracting depth**. The invitation verification template is snapshotted onto the request at send time (`t_request.verification_keys`, [ADR-0022](../decisions/0022-invitation-verification-template-snapshots.md)) \u2014 later edits to the organisation default never change an invitation already in flight. The receiving wizard assembles from that snapshot: only selected steps render, existing profile results are reused, and an empty selection is valid (connects without verification questions).\\n\\nPortability is implemented: an existing party accepts a second PC's invitation via `POST /onboarding/invite/:token/connect` (kind `Q04`) \u2014 network edge, shared portable status, no wizard ([ADR-0012](../decisions/0012-assureworx-id-and-identity-resolution.md)). Sharing the profile does **not** make the trade ready for new sites; engagement requirements resolve separately as a delta.\\n\\n### Employee verification\\n\\nA trade company admin (R04) sets **Employee checks** (`GET` / `PUT /memberships/employee-checks`, [ADR-0030](../decisions/0030-employer-employee-check-selection.md)), registers staff from Team (seat I03), and the selection is snapshotted on each seat. Invitees verify themselves \u2014 `employerSponsored` stepper, **no ABN**, identity locked on \u2014 and the employer pays afterwards on Team. Edits are **forward-only**: existing invitations and in-flight staff keep their original selection. Salaried employees cannot self-checkout (`EMPLOYER_PAYS`); their funding label is `awaiting_employer_payment`. Their nav is `employeeNav` in `frontend/src/lib/nav.ts` \u2014 Work profile, Documents, Support only.\\n\\n### Principal Contractor project journeys\\n\\nTwo complementary flows, both built:\\n\\n- **Create project** (`frontend/src/app/(app)/projects/CreateProjectWizard.tsx`): four steps \u2014 Project details + locations \u2192 Site Requirements \u2192 Add contractors (staged to an invitation list) \u2192 Review. Confirm persists and sends in one action; drafts live in `localStorage` under `awx.drafts.projects.` (max 8).\\n- **Request composer** (`frontend/src/app/(app)/projects/[id]/ProjectInviteComposer.tsx`): binds a request to a project node or site (`t_request.node_id`), selects network companies and/or email recipients, captures company-level and trade-level requirements (`t_req_item.applies_tag_cd`), and sends in bulk via `POST /requests/project-invite`.\\n\\nAfter a **company** invite accept + ABN, `/verify/journey` branches: **Myself only**, **Myself + employees** (stages staff at `/verify/staff`, then `POST /memberships/employees` on submit), or **Company admin bulk** (same staging with a higher cap of 50). Individual invites skip the chooser.\\n\\n## Billing &amp; payments\\n\\n### Unit of sale\\n\\nVerification is sold **per case**: one purchase funds one verification cycle for one person ([ADR-0023](../decisions/0023-pay-per-verification.md), superseding the subscription model of ADR-0004). Price is **$55.00 AUD GST-inclusive** ($50 net + $5 GST, `tax_mode_cd = TX1`), pinned in `backend/src/modules/payments/pricing.ts`; every order item snapshots the price version it was sold at so a historical receipt stays reproducible. The buyer may be the individual (`SELF`), their trade organisation (`SPONSORED`), or a Principal Contractor (`SPONSORED`); the beneficiary is always the individual being verified. Principal Contractors remain free to use the platform \u2014 sponsoring is a purchase, not a subscription.\\n\\nThe purchase funds the *work*, never the outcome: a funded cycle may still verify, reject, or need manual review. **No Verified / ready-to-work without payment** \u2014 the party stays pending/action-required until a funded cycle exists (or `verified_grandfathered` is set on the party).\\n\\n### Data model\\n\\n| Table | Holds |\\n| --- | --- |\\n| `t_cycle` | The chargeable verification case and its funding state. |\\n| `t_order` / `t_order_item` | The purchase and one line per beneficiary, with a price snapshot. |\\n| `t_pay_try` | Payment attempts, idempotency keys, provider references. |\\n| `t_pay_evt` | Durable webhook inbox \u2014 unique per provider event id, replayable. |\\n| `t_fin_txn` | Immutable, append-only financial ledger; signed amounts in AUD cents. |\\n| `t_refund` / `t_dispute` | Refund workflow and ingested chargebacks. |\\n\\nTwo guarantees are enforced by the database rather than application code: `ux_item_funded` makes double-funding the same cycle impossible, and the runtime role cannot update or delete `t_fin_txn`. `t_ledger` (the legacy PL1/PL2 subscription standing) remains for existing commercial standing but no longer drives revenue; `require-subscription.ts` and the 14-day grace period are flagged in `billing.md` as a documented contradiction pending the migration decision for existing subscribers.\\n\\n### Stripe integration\\n\\nCheckout sessions are created only from the backend. The authoritative transition is a verified webhook \u2014 eleven events: `checkout.session.*`, `payment_intent.*`, `charge.refunded`, `charge.refund.updated`, `charge.dispute.*` \u2014 or a five-minute reconciliation job, never a browser redirect. Refunds are backend-initiated with idempotency keys and recorded only on provider confirmation; a partial refund can release one seat of a bulk order without touching the others. Unconfirmed refunds back off on their own schedule rather than stalling settlement ([ADR-0031](../decisions/0031-refund-reconciliation-schedule.md)). Disputes are ingested and raised for operator review, never auto-resolved. When Stripe is unconfigured the app reports `PAYMENTS_NOT_CONFIGURED` and never pretends a payment succeeded.\\n\\nBeneficiary-facing labels (\\\"Payment required\\\", \\\"Paid by your organisation\\\", \\\"Refund under review\\\", \u2026) are derived server-side in `backend/src/modules/payments/state.ts`; the browser maps them onto state images (`paid.png`, `not_paid.png`, `payment_pending.png`) in `frontend/src/app/(app)/billing/BillingClient.tsx` but never derives payment state itself.\\n\\n### Employer pays for a crew\\n\\nBulk checkout (`POST /payments/workforce/checkout`, `backend/src/modules/payments/checkout.ts` \u2192 `startSponsoredCheckout`) lets a trade company select staff on Team and pay for the crew in one order \u2014 up to 50 people, one charge, one provider session ([ADR-0029](../decisions/0029-employer-sponsored-bulk-verification.md)). Authorisation is the live `L01` employment edge, never a party id in the request; any ineligible person in the set refuses the **whole** order; double-clicks hand back the original order. An unused paid seat has no automatic expiry-and-refund \u2014 the employer requests, a Platform Admin reviews, and the money goes back through the provider.\\n\\n## Identity &amp; portability\\n\\nThe platform's differentiator is a **portable profile**: the trade owns one compliance profile and carries it across principals. That requires a first-class Assureworx ID. The four defects that originally blocked this \u2014 no `/auth/register` front door; an `ACCOUNT_EXISTS` hard-fail that turned the portability promise into a 409; email-only identity anchoring; no pre-invite recognition \u2014 were **closed 2026-07-26** by migration `006_identity_portability.sql` and [ADR-0012](../decisions/0012-assureworx-id-and-identity-resolution.md):\\n\\n- **Public Assureworx ID** \u2014 `t_party.public_ref`, generated by `core.fn_public_ref()`.\\n- **Trade self-registration** \u2014 `POST /auth/register` creates an orphan trade with no PC edge.\\n- **Connect instead of onboard** \u2014 `POST /onboarding/invite/:token/connect` (kind Q04).\\n- **Pre-invite recognition** \u2014 `GET /directory/lookup`: existence + Verified status only, no profile leak.\\n\\nIdentity resolution (`resolveIdentity`, `backend/src/shared/services/identity.ts`) walks a ladder: `public_ref` \u2192 D04 (KYC subject) \u2192 D01 (ABN) \u2192 D02 (ACN) \u2192 D03 (login email). The strongest anchor wins; the rest become aliases on the same party. Open edges remain: only D03 is reliably written on the common path \u2014 D02 and D04 have no writers, D05 (trade licence) exists in the schema but has no writer either \u2014 so recognition is still effectively email-plus-ABN in practice. There is also no audited merge operation for duplicate parties. KYC-subject anchoring (D04) is an open privacy decision (Q24), and a consent gate on every new connection is Q8.\\n\\n## Relationship to the rest of the codebase\\n\\nThe wiki files live under `wiki/product/`. Each file cites its sources in front-matter, links sideways to sibling product pages, down to `../architecture/*` for implementation detail, `../decisions/*-*.md` for ADRs, and `../runbooks/*` for operations. The key ADRs referenced across this module:\\n\\nADR-0003 repository-layer tenant isolation \u00b7 ADR-0005 verified-scope hybrid \u00b7 ADR-0006 ACS email \u00b7 ADR-0008 BFF session auth \u00b7 ADR-0010 PC cold-start \u00b7 ADR-0012 Assureworx ID &amp; identity resolution \u00b7 ADR-0013 login email change \u00b7 ADR-0017 employee deregistration \u00b7 ADR-0018 engagement-scoped requirements \u00b7 ADR-0022 invitation template snapshots \u00b7 ADR-0023 pay-per-verification \u00b7 ADR-0025 native-auth BFF proxy \u00b7 ADR-0029 employer-sponsored bulk verification \u00b7 ADR-0030 employee-check selection \u00b7 ADR-0031 refund reconciliation \u00b7 ADR-0032 DI auto-accept and PC document visibility.\\n\\nWhere the concepts land in code:\\n\\n| Concept | Code |\\n| --- | --- |\\n| Price constant | `backend/src/modules/payments/pricing.ts` |\\n| Sponsored checkout | `backend/src/modules/payments/checkout.ts` \u2192 `startSponsoredCheckout`; route `POST /payments/workforce/checkout` |\\n| Funding labels | `backend/src/modules/payments/state.ts`, `views.ts` \u2192 `workforceFundingFor` |\\n| Request creation / existing-party resolution | `backend/src/modules/requests/index.ts` |\\n| Connect / self-register | `backend/src/modules/onboarding/index.ts` (`/connect`), `backend/src/modules/auth/index.ts` (`POST /auth/register`) |\\n| Identity resolution ladder | `backend/src/shared/services/identity.ts` \u2192 `resolveIdentity` |\\n| Employee-check catalogue | `backend/src/shared/services/verification-template.ts`, `backend/src/modules/memberships/index.ts` |\\n| Project wizard / request composer | `frontend/src/app/(app)/projects/CreateProjectWizard.tsx`, `frontend/src/app/(app)/projects/[id]/ProjectInviteComposer.tsx` |\\n| Team + employee checks UI | `frontend/src/app/(app)/team/TeamClient.tsx`, `EmployeeChecksPanel.tsx` |\\n| Billing status UI | `frontend/src/app/(app)/billing/BillingClient.tsx` |\\n| Trade nav (employee) | `frontend/src/lib/nav.ts` \u2192 `employeeNav` |\\n| Expiry CRON job | `document.expiry.check` |\\n\\n## Conventions\\n\\nFiles in this module share a shape that contributors should preserve:\\n\\n- **Front-matter** with `type: product`, `status: current`, `updated` date, `sources`, and `tags`.\\n- **Status callouts**: `&gt; **\u26a0 Demo today:**` marks places where implementation lags the documented product truth; `&gt; **\u2753 Open:**` marks undecided questions, cross-referenced to `../decisions/open-questions.md`; `&gt; **Resolved:**` notes closures on older text that is kept for rationale.\\n- **Product truth vs implementation state** is kept explicit. The strongest example is `identity-and-portability.md`, which retains its original defect analysis after the defects were closed because the analysis explains the design's shape.\\n- **Reference accuracy is part of the contract.** Backend module paths, route verbs, table names, migration numbers, and ADR references appear throughout; update code pointers when the implementation moves.\\n- **UI honesty is a product rule**, not a style preference: the API reports `emailSent: false` when email is unset, billing labels are server-derived, and extraction findings are never presented as verification. Documentation should preserve those distinctions wherever it describes UI behaviour.\",\"product-architecture-documentation-raw\":\"# Product &amp; Architecture Documentation \u2014 Raw\\n\\n# Product &amp; Architecture Documentation \u2014 Raw\\n\\n## Module Overview\\n\\nThe **Raw** module is a documentation-only collection that acts as the working source of truth for the Assureworx platform. It contains no executable code \u2014 no functions, services, or API endpoints. Instead, it defines *what Assureworx is*, *why it exists*, and *how it must be built*, and it is the reference that product, engineering, and marketing teams start from.\\n\\nThe module holds three documents:\\n\\n| File | Type | Version / Date | Status |\\n| --- | --- | --- | --- |\\n| `ASSUREWORX_BRAND.md` | Brand guidelines | 1.0, 20 July 2026 | Working source of truth for brand work |\\n| `ASSUREWORX_PRODUCT.md` | Product definition | 1.0, 20 July 2026 | Working source of truth for product behaviour and commercial policy |\\n| `assureworx-platform-spec.html` | Platform technical specification | 1.1, June 2026 | Draft \u2014 awaiting developer review |\\n\\nEach document serves a distinct audience and purpose:\\n\\n- **Brand guidelines** tell designers, marketers, and UI developers how the brand is presented \u2014 colour tokens, typography, voice, logo rules, and interface conventions.\\n- **Product definition** tells product managers, engineers, and marketers what the product does \u2014 users, entities, verification rules, expiry policy, privacy boundaries, phase scope, and known open questions.\\n- **Technical specification** tells engineers how to build it \u2014 data model DDL, module structure, routes, middleware, integrations, background jobs, and Azure infrastructure.\\n\\n## Governance and Intended Use\\n\\nThe documents form an explicit hierarchy. The product definition states:\\n\\n&gt; Use this file as the starting context for product design, engineering, marketing and AI-assisted development. Record confirmed decisions here before changing public claims, pricing logic, verification rules or access behaviour.\\n\\nThe governance rules across the module are:\\n\\n- `ASSUREWORX_BRAND.md` is the source of truth for new brand work.\\n- `ASSUREWORX_PRODUCT.md` owns product behaviour and commercial policy.\\n- Where documents conflict, the product definition is expected to identify the conflict explicitly rather than silently choose one position (in practice, several conflicts are recorded as open questions \u2014 see [Known Conflicts](#known-conflicts-and-open-questions)).\\n- The technical specification is the developer-ready translation of the product definition; it is marked \\\"Draft \u2014 Awaiting Dev Review\\\", so it is the least authoritative of the three.\\n\\n```mermaid\\nflowchart TD\\n    BRAND[ASSUREWORX_BRAND.mdBrand source of truth] --&gt; UI[Product UI, marketing, decks]\\n    PROD[ASSUREWORX_PRODUCT.mdProduct source of truth] --&gt; UI\\n    PROD --&gt; SPEC[assureworx-platform-spec.htmlTechnical specification]\\n    SPEC --&gt; ENG[Engineering implementation]\\n    ENG --&gt; CODE[Assureworx platform codebase]\\n```\\n\\n## ASSUREWORX_BRAND.md \u2014 Brand Guidelines\\n\\n### Purpose\\n\\nDefines the public identity of Assureworx: the brand promise, positioning, visual system, and language rules. It is the reference for any customer-facing output.\\n\\n### Key content\\n\\n- **Brand foundation.** The promise is *\\\"Verify once. Trust everywhere.\\\"* and the primary proposition is *\\\"Complete trade compliance visibility.\\\"* Six brand principles govern product behaviour \u2014 trust must be visible, compliance must stay current, complexity stays behind the interface, evidence beats assertion, one profile travels, and every action is defensible.\\n- **Audiences.** Principal Contractors, trade companies, individual trades, and auditors/risk stakeholders, each with a desired feeling (in control, organised and ready, verified and able to work, confident in the record).\\n- **Logo system.** Fixed spelling (\\\"Assureworx\\\", never \\\"AssureWorx\\\" or \\\"Assureworx AI\\\"), minimum sizes (120 px full logo, 20 px brand mark), clear-space rules, and an explicit do-not list. The document notes that `logo_assure_darkmode.png` is referenced by the HTML spec but was not supplied in the source files.\\n- **Colour system.** Defines the tokens that the technical spec reuses as CSS variables:\\n\\n| Token | Hex | Role |\\n| --- | --- | --- |\\n| Electric Blue | `#3E6AE1` | Primary brand colour, key actions, links, active states |\\n| Electric Dark | `#2D4FA3` | Pressed states, dark blue emphasis |\\n| Cyan | `#23C9EE` | Network, live data, monitoring accent |\\n| Violet | `#7B5CE0` | Portability accent |\\n| Carbon | `#171A20` | Primary dark background, strongest text |\\n| Canvas White | `#FFFFFF` | Primary canvas |\\n\\n  Semantic colours are reserved for product state (verified, warning, suspended, information, portable profile) and must never be used decoratively.\\n- **Typography.** Segoe UI for product/document work; Cascadia Code for technical identifiers and code. Includes a suggested web scale from hero display (56\u201372 px) down to metadata (11\u201313 px).\\n- **Voice and tone.** Direct, evidence-led, Australian English. Includes a preferred-language table (e.g. \\\"Verified by Assureworx\\\" not \\\"Approved by the government\\\") and tone by context.\\n- **Interface conventions.** Status labels must be explicit text (never a green tick alone), buttons are action-led, cards use Cloud borders with 6\u201312 px radius, and evidence always shows status, source, and last-checked date.\\n- **Claims guardrails.** \\\"Real-time\\\" must be scoped to actual update frequency; \\\"immutable\\\" applies only to protected audit records; \\\"verified\\\" means defined checks passed at the recorded time, not a guarantee of future conduct.\\n\\n## ASSUREWORX_PRODUCT.md \u2014 Product Definition\\n\\n### Purpose\\n\\nThe product-level source of truth. It consolidates the public website copy, the platform technical specification v1.1, and the trade onboarding flow v1.0 into a single document that resolves (or explicitly flags) conflicts between them.\\n\\n### Key content\\n\\n**Problem and thesis.** Compliance evidence is fragmented across emails, folders, and spreadsheets; documents expire without warning; and gaps surface after incidents. The product thesis is that compliance becomes more valuable when portable, continuously monitored, and connected to the full contracting chain.\\n\\n**Users and roles.** Six roles are defined with scope and core permissions:\\n\\n- Platform Admin \u2014 platform-wide operations, manual review, billing controls, feature flags\\n- Principal Contractor Admin \u2014 own organisation, projects/sites, requests, network\\n- Principal Contractor User \u2014 read-oriented monitoring\\n- Trade Company Admin \u2014 company evidence, salaried employees, downstream requests\\n- Individual Trade \u2014 own portable profile\\n- Salaried Employee \u2014 identity managed by company admin, initially no independent login\\n\\n**Core entities.** The domain model includes Principal Contractor organisation, trade organisation, individual, project, site, engagement/participation, onboarding request, evidence item, requirement, verification result, training assignment, incident, and audit event. The hierarchy is a platform tree: Principal Contractors own projects/sites and a trade network; trade companies contain employees and downstream subcontractors; there is no enforced limit on subcontracting depth, and each request retains both its immediate parent and the root request from the originating Principal Contractor.\\n\\n**Assureworx Verified.** A platform-confirmed status showing that required checks passed at the recorded time. The five core checks are: ABN validated, identity confirmed, licence current, insurance current, and qualifications verified or on record. The profile lifecycle is `Pending \u2192 Verified \u2192 Action required \u2192 Suspended`, with renewal paths back to Verified. Verification methods are ranked: authoritative API, controlled manual review, provider verification (KYC), and self-declaration (never presented as verified).\\n\\n**Requirements and blocking.** Requirements evaluate in five layers \u2014 universal profile, business, trade-class, Principal Contractor, and project/site \u2014 so a profile can stay portable while one engagement is blocked for a site-specific gap. Block types are Warning, Engagement block, and Profile suspension. The initial universal evidence set includes photo ID, ABN registration, White Card, Public Liability Insurance, and Workers Compensation, with applicability rules-driven rather than universally enforced.\\n\\n**Expiry policy.** 30 days before expiry the evidence owner is notified; at 14 days the affected Principal Contractor admins are added; at expiry the status is suspended immediately if the evidence is a hard requirement; verified renewal reinstates status and notifies admins. Notifications must state exactly which profile, requirement, engagement, or site is affected.\\n\\n**Capabilities.** Phase 1 capabilities cover authentication and identity, onboarding with parent/root request traceability, compliance management, projects and sites, trade directory, PDF/video training and induction, six reports (compliance summary, site compliance, expiry forecast, training completion, incident summary, audit history), incident management, and Stripe billing with entitlement controls.\\n\\n**Privacy model.** Portable/shared information is limited to verification status and verified facts \u2014 never raw identity imagery or underlying document files. Incident records, annotations, severity ratings, and billing arrangements are tenant-private. Access comes from participation and explicit role, not from knowing an email or ABN.\\n\\n**Audit.** Mandatory audit events cover authentication, evidence lifecycle, verification, status changes, onboarding, access grants, training, incidents, site assignments, role/subscription changes, and platform-admin access. Audit events are append-only with actor, action, resource, tenant context, timestamp, and material change data.\\n\\n**Phase scope.** Phase 1 MVP (September 2026) includes onboarding with unlimited-depth request chains, ABN verification, KYC, evidence upload and manual review, NSW licence verification where reliable, the Principal Contractor portal, mobile-first trade onboarding, reporting, incidents, billing, and Australian data residency. Phase 2 adds more licence integrations, quiz builder, MFA, scheduled reports, and a public verified badge. Phase 3+ includes trade lead generation and AI-assisted document extraction.\\n\\n**Confirmed technical decisions.** Azure (Australia East), Azure Database for PostgreSQL Flexible Server, Microsoft Entra External ID, private Azure Blob Storage, Stripe, Next.js with TypeScript, GitHub with controlled CI/CD.\\n\\n## assureworx-platform-spec.html \u2014 Platform Technical Specification\\n\\n### Purpose\\n\\nThe developer-ready engineering specification. It is a single self-contained HTML document with embedded CSS (which itself mirrors the brand colour tokens via CSS custom properties). It translates the product definition into implementable detail: schema DDL, route maps, module structure, middleware contracts, job queues, and infrastructure topology.\\n\\n### Key content\\n\\n**System overview and entity hierarchy.** Restates the two-sided model (Tier 1 organisations and trades) and defines hierarchy rules: Tier 1 sees all individuals in their network; sub-requests chain back via `chain_root_id`; salaried employees' documents are managed by the company admin; sole trader profiles are portable across Tier 1s; incident data is never shared with trades.\\n\\n**User roles.** The spec defines five roles (the product doc splits \\\"Trade Individual\\\" into Individual Trade and Salaried Employee). Multi-tenant isolation is enforced at the repository layer via a `tier1_org_id` filter on every tenant-scoped query, never at route level.\\n\\n**Data model.** The spec provides DDL for the core PostgreSQL tables:\\n\\n- `tier1_organisations`\\n- `trade_organisations`\\n- `individuals`\\n- `onboarding_requests`\\n- `documents`\\n- `audit_log`\\n\\nAll tables use UUID primary keys and soft delete via `deleted_at TIMESTAMPTZ NULL`. The `documents` table stores `file_hash` (SHA-256) for integrity and the `file_url` is an Azure Blob signed URL with a 15-minute expiry. The `audit_log` table is explicitly immutable, enforced via PostgreSQL RLS policy. `onboarding_requests` carries `parent_request_id` and `chain_root_id` to preserve the full referral chain.\\n\\n**Module behaviour.** Sections 5.1\u20135.10 specify each module:\\n\\n- **Authentication** \u2014 Entra External ID with JWT in an httpOnly cookie, never localStorage. Token validation hits Microsoft's JWKS endpoint; `resolve-tenant-context.ts` middleware injects tenant context into `req.tenantContext` before business logic runs. Rate limits: 10 attempts per IP per 15 minutes, lockout after 5 failures.\\n- **Onboarding** \u2014 three request types (`direct_company`, `direct_individual`, `sub_request`), the Verified journey (ABN API check \u2192 KYC \u2192 mandatory documents uploaded \u2192 verified), and the expiry monitoring sequence (30/14/0 days).\\n- **Compliance** \u2014 re-evaluated on every upload, verification, or expiry event; hard blocks suspend `assureworx_verified`; soft blocks alert at 30 days.\\n- **Projects/sites, directory, training, reporting, incidents, notifications, billing** \u2014 each with concrete behaviour. The training module supports PDF, video, quiz, external link, and mixed content; completion records are immutable. Reports are Phase 1 CSV/PDF exports generated asynchronously. Billing distinguishes trade individuals ($49 AUD/year) from Tier 1 Standard ($5,000), Enterprise ($10,000), and complimentary subscriptions; `require-subscription.ts` middleware runs after auth and tenant resolution.\\n\\n**Integrations.** ABR Web Services for ABN lookup (GUID auth from ATO, 5\u201310 business day lead time), state licensing authorities (NSW Fair Trading and SafeWork NSW in Phase 1; VBA, QBCC, CBS SA, and WA Building &amp; Energy in Phase 2), and a KYC provider that is still TBC (Stripe Identity, Onfido, or Veriff under consideration). KYC stores verification status and provider session reference only \u2014 not raw identity images.\\n\\n**Frontend.** Next.js App Router on Azure Static Web Apps with two portals:\\n\\n- **Tier 1 Portal** \u2014 desktop-first at 1280px+ with routes such as `/projects/[id]/sites/[sid]`, `/trades/individuals/[id]`, `/training/[id]/results`, `/incidents`, `/reports`.\\n- **Trade/Company Portal** \u2014 mobile-first PWA at 375px+ with an onboarding wizard (`/onboarding/abn`, `/onboarding/identity`, `/onboarding/trade-type`, `/onboarding/documents`, `/onboarding/review`) plus `/profile`, `/profile/network`, `/training`, `/team`, `/requests`.\\n\\n**Backend.** Express.js 5 (TypeScript) on Azure App Service or Container Apps, all routes prefixed `/api/v1/`. Each feature module follows a fixed file pattern: `routes.ts` (no business logic), `controller.ts`, `validation.ts` (Zod schemas), `service.ts`, `repository.ts` (tenant filtering enforced here), `types.ts`. Responses use a uniform envelope: `{ success, data, meta }` or `{ success, error: { code, message, details } }`.\\n\\n**Background jobs.** BullMQ on Azure Cache for Redis handles: `document.expiry.check` (daily CRON at midnight AEST), `document.verify.api`, `notification.send.email`, `training.overdue.check`, `subscription.expiry.check`, `kyc.result.webhook`, and `report.generate`.\\n\\n**Infrastructure.** Three environments \u2014 DEV (auto-deploy on feature branches, PR previews), UAT (merge to `develop`), PROD (`main` with PR approval gate). Branch protection requires a PR with one approver and passing CI.\\n\\n## Known Conflicts and Open Questions\\n\\nThe product definition explicitly catalogs conflicts between the public website and the older technical spec. Anyone building from this module must be aware of them:\\n\\n| Conflict / question | Status |\\n| --- | --- |\\n| **Principal Contractor pricing** \u2014 website says `$0 cost for Principal Contractors`; the technical spec lists `$5,000 Standard`, `$10,000 Enterprise`, and a complimentary tier. | Critical open question. Product doc treats the website promise as current until commercial policy changes. |\\n| **\\\"Network-wide trust\\\" disclosure** \u2014 what exactly a new Principal Contractor sees of an existing profile. | Critical open question; recommends consent, data minimisation, and status-without-documents. |\\n| **Scope of Verified status** \u2014 global vs classification-specific vs engagement-specific. | Critical open question; recommends portable base-profile status plus engagement readiness. |\\n| **KYC provider** \u2014 Stripe Identity vs Entrust/Onfido vs Veriff. | High priority, TBC. |\\n| **Email provider** \u2014 Brevo vs SendGrid. | High priority, TBC. |\\n| **NSW licence API access** \u2014 assumed, not confirmed. | High priority \u2014 do not commit Phase 1 to an assumed public API. |\\n| **ABR Web Services credentials** \u2014 application required. | High priority \u2014 can affect MVP path. |\\n| **Salaried employee billing** \u2014 old draft charges every individual; conflicts with company-managed onboarding. | Medium priority. |\\n| **Second-company registration fee (50%)** \u2014 present in onboarding flow but not tied to a pricing model. | Medium priority. |\\n| **Missing logo asset** \u2014 `logo_assure_darkmode.png` referenced but not supplied. | Asset gap, flagged in the brand guide. |\\n\\n## Connection to the Codebase\\n\\nAlthough this module contains no executable code, it anchors the implementation in several concrete ways:\\n\\n- **Schema migrations.** The DDL in the technical spec (Section 4) is directly translatable to numbered migration scripts. The expected migration location is `src/db/migrations/` as specified in the backend module structure.\\n- **Frontend route maps.** The two route listings define the Next.js App Router structure \u2014 pages under the Tier 1 and Trade portal trees must match these paths.\\n- **Backend scaffolding.** The `src/modules/*` structure with the six-file pattern (`routes.ts`, `controller.ts`, `validation.ts`, `service.ts`, `repository.ts`, `types.ts`) is the prescribed layout for every feature module.\\n- **Middleware contracts.** `requireAuth`, `resolve-tenant-context.ts`, `require-subscription.ts`, and `idempotency.ts` are named middleware that must exist in `src/shared/middleware/`, with a defined execution order: auth \u2192 tenant resolution \u2192 subscription check \u2192 business logic.\\n- **Design tokens.** The CSS custom properties in the technical spec (`--electric`, `--carbon`, `--canvas`, `--success`, `--danger`, etc.) match the brand colour tokens in `ASSUREWORX_BRAND.md` and define the palette for the frontend component library.\\n- **Background job names.** The BullMQ job names (`document.expiry.check`, `kyc.result.webhook`, `report.generate`, etc.) are the contracts for the job-queue service.\\n- **Asset reference.** The spec's logo path (`../../FRONTEND/assureworks-crm/public/logo_assure_darkmode.png`) points at a sibling frontend project, revealing the intended repository layout: this Raw documentation module sits alongside a frontend application directory.\\n\\n## Working With This Module\\n\\nWhen contributing to Assureworx, consult the documents in this order:\\n\\n1. **`ASSUREWORX_PRODUCT.md`** first \u2014 it is the consolidated source of truth and the only document that records conflicts. If a product behaviour question has no answer here, treat the answer as unknown rather than guessing.\\n2. **`assureworx-platform-spec.html`** for implementation detail \u2014 schema, routes, module structure, job definitions, and infrastructure.\\n3. **`ASSUREWORX_BRAND.md`** for anything customer-facing \u2014 copy, colour, typography, logo, status labels.\\n\\nThe product definition's governance rule should guide all updates: record confirmed decisions in that file *before* changing public claims, pricing logic, verification rules, or access behaviour. A developer resolving one of the open questions (e.g. confirming the KYC provider or ABR access) should update the product definition and the relevant section of the technical spec together, so the two documents stay aligned.\",\"product-architecture-documentation-readme-md\":\"# Product &amp; Architecture Documentation \u2014 README.md\\n\\n# Product &amp; Architecture Documentation \u2014 README.md\\n\\n## Overview\\n\\nThe repository root `README.md` is the entry point and navigation hub for the Assureworx monorepo. Assureworx is a multi-tenant B2B SaaS compliance network for the Australian construction and trades industry: it helps Principal Contractors verify that companies and workers across their project and site network are ready to work, while giving trades a single portable compliance profile.\\n\\nThis module is static documentation. It contains no executable code, declares no dependencies, and has an empty call graph \u2014 no internal calls, outgoing calls, incoming calls, or execution flows. Its job is to orient anyone who clones the repository and to encode the team's delivery agreements: how to set up the project, how to branch and review, what quality gates to pass, how releases and rollbacks work, and what behaviour is non-negotiable.\\n\\n## How it works\\n\\nThe README is a structured, human-readable contract. It performs three functions:\\n\\n1. **Orientation** \u2014 states what the product is, maps the repository layout, and points to the recommended starting documents: `wiki/overview.md`, `wiki/architecture/code-map.md`, and `wiki/architecture/cicd-target-operating-model.md`.\\n2. **Instruction** \u2014 gives the exact commands and workflow steps for local setup, quality checks, branch management, and pull requests.\\n3. **Policy** \u2014 records the release path, rollback discipline, and working agreements that all contributors must follow.\\n\\nBecause it is Markdown, the module has no runtime behaviour. Its \\\"execution\\\" is a developer reading and following it. The document is maintained manually; the `Working agreements` section creates the obligation to keep it accurate by requiring that the wiki \u2014 and by extension the linked documentation \u2014 be updated whenever architecture, product behaviour, or an important decision changes.\\n\\n## Key components\\n\\n### Repository map\\n\\nThe top-level table is the primary navigation aid. It defines eight root paths:\\n\\n| Path | Purpose |\\n| --- | --- |\\n| `frontend/` | Next.js and TypeScript web application |\\n| `backend/` | Express and TypeScript API and workers |\\n| `packages/` | Shared workspace packages |\\n| `db/migrations/` | Ordered PostgreSQL schema migrations |\\n| `scripts/` | Development, operations and deployment helpers |\\n| `wiki/` | Product and engineering knowledge base |\\n| `isms/` | ISO 27001 and SOC 2 control/evidence workspace |\\n| `Raw/` | Immutable source material used by the wiki |\\n\\nThe opening section also carries a confidentiality notice: the repository contains proprietary Assureworx source code, and access does not grant permission to copy, redistribute, or reuse it outside authorised work.\\n\\n### Local setup\\n\\nDefines the runtime prerequisites:\\n\\n- Node.js 20 or later (Node.js 22 is used by CI)\\n- npm\\n- PostgreSQL 16 for database work and database tests\\n- Docker Desktop for building deployment images locally\\n\\nThe canonical setup sequence from the repository root is:\\n\\n```powershell\\nnpm ci\\nnpm run typecheck\\nnpm test\\nnpm run dev\\n```\\n\\nThe frontend runs at `http://localhost:3000`; the API at `http://localhost:4000`. Environment files are excluded from Git: contributors copy the relevant `.env.example` file and obtain authorised development values. Committing secrets is explicitly forbidden.\\n\\n### Git workflow\\n\\n`main` is protected and always releasable; nobody pushes application changes directly to it. The flow is:\\n\\n1. Update local `main`.\\n2. Create a short-lived branch (e.g. `feature/project-search`, `fix/invite-expiry`).\\n3. Make small commits with clear messages.\\n4. Push and open a pull request into `main`.\\n5. Resolve review comments and conflicts on the working branch.\\n6. Merge only after checks and approval pass; delete the branch afterward.\\n\\nBranch prefixes are codified:\\n\\n| Prefix | Use |\\n| --- | --- |\\n| `feature/` | New product capability |\\n| `fix/` | Defect correction |\\n| `hotfix/` | Urgent production correction using the same review controls |\\n| `chore/` | Maintenance that does not change product behaviour |\\n| `docs/` | Documentation-only change |\\n\\n### Quality checks\\n\\nThe pre-PR gate, run from the repository root:\\n\\n```powershell\\nnpm run lint\\nnpm run typecheck\\nnpm test\\nnpm run build\\n```\\n\\nDatabase changes have an additional rule: migration and RLS/constraint tests must pass against a disposable PostgreSQL database. Migrations must never be tested against production from a workstation.\\n\\n### Delivery environments\\n\\nThe intended release path is:\\n\\n```text\\nPull request \u2192 protected main \u2192 DEV \u2192 approval \u2192 TEST \u2192 approval \u2192 PROD\\n```\\n\\nThe core invariant is that the same immutable container image digest is promoted through every environment. The README is explicit about maturity: DEV exists today; TEST and PROD plus the automated promotion workflow are still being established. Until that work is done, the manual DEV runbook (`wiki/runbooks/azure-dev-deploy.md`) is an explicitly temporary process and must not become a laptop-to-production shortcut.\\n\\n### Safe rollback\\n\\nEvery release records its commit SHA and API/web image digests. Rollback means selecting a previous successful release and redeploying those exact images. The README calls out an important asymmetry: reverting application code does not automatically undo a schema change, so database migrations require a separate recovery plan.\\n\\n### Working agreements\\n\\nFive policy rules:\\n\\n- Never commit `.env` files, credentials, tokens, certificates, or customer data.\\n- Never commit local tool state (`.cursor`, `.agents`, `agent`, `.serena`, `.codex`).\\n- Do not rewrite shared branch history without an agreed recovery point.\\n- Keep the data-classification catalogue (`wiki/architecture/data-classification-catalogue.md`) current when stored or exposed data changes.\\n- Update the wiki when architecture, product behaviour, or an important decision changes.\\n\\n### Help\\n\\nPoints readers to `wiki/runbooks/` for operational instructions and `wiki/decisions/` for architectural decisions. It also establishes a review norm: raise uncertain or risky changes in a pull request *before* implementation rather than bypassing the workflow.\\n\\n## Connections to the rest of the codebase\\n\\nThis module has no code-level dependencies, but it is the documentation hub for the entire monorepo. Every path it references is a real top-level directory or wiki file. The relationship is hub-and-spoke: the README is the single entry point, and `wiki/` is the deep-dive layer:\\n\\n```mermaid\\ngraph LR\\n    README[README.md] --&gt; FRONT[frontend/]\\n    README --&gt; BACK[backend/]\\n    README --&gt; PKG[packages/]\\n    README --&gt; DB[db/migrations/]\\n    README --&gt; WIKI[wiki/]\\n    README --&gt; ISMS[isms/]\\n    WIKI --&gt; OVERVIEW[wiki/overview.md]\\n    WIKI --&gt; CODEMAP[architecture/code-map.md]\\n    WIKI --&gt; CICD[architecture/cicd-target-operating-model.md]\\n```\\n\\nThe most heavily used edge is `README \u2192 wiki/`. The README links to the product overview, the architecture code map, the CI/CD target operating model, the data-classification catalogue, the DEV runbook, the runbooks index, and the decisions index. A contributor who needs to understand the system beyond the README is directed into `wiki/` almost immediately.\\n\\nThe remaining top-level paths (`scripts/`, `Raw/`) are referenced in the repository map but are not expanded in the README itself; they are explored when a task requires them.\\n\\nBecause the call graph is empty, this module does not participate in any runtime execution. It is consumed by humans through GitHub's rendered Markdown view, and it is the canonical statement of the repository's engineering rules.\\n\\n## Maintenance\\n\\nThis README changes when the repository's structure, toolchain, or engineering agreements change. The `Working agreements` section is the enforcement mechanism: contributors are already required to keep the wiki current, and the same standard applies to the README. In particular, any change to the following should be reflected in the README in the same pull request:\\n\\n- the branch prefix table or the pull-request flow,\\n- the quality-check commands or prerequisites,\\n- the delivery environment topology or image-promotion policy,\\n- the rollback procedure,\\n- the repository map (new top-level directories, renamed paths),\\n- the data-classification catalogue, or\\n- the confidentiality notice or working agreements (these should only change after an explicit decision).\",\"product-architecture-documentation-runbooks\":\"# Product &amp; Architecture Documentation \u2014 runbooks\\n\\n# Runbooks \u2014 Product &amp; Architecture Operations Documentation\\n\\n## Overview\\n\\nThe `wiki/runbooks/` module is the operational layer of the Assureworx engineering wiki. It is a set of Markdown documents that capture repeatable, script-backed procedures for the Assureworx platform \u2014 deploying to Azure Container Apps, wiring customer identity through Entra External ID (CIAM), enabling Stripe payments, issuing Apple/Google Wallet passes, configuring transactional email, and satisfying compliance evidence obligations.\\n\\nThe module has no runtime surface: no code is exported, no call graph exists. Its value is the coupling between each document and the automation it invokes (`scripts/*.ps1`, `scripts/*.bat`) and the infrastructure it mutates (Container Apps, Key Vault, Postgres, DNS, Entra, Stripe, ACS, Didit, wallet issuers). The target reader is a developer who needs to perform an operation \u2014 a first deploy, a test-identity reset, a Stripe key rotation \u2014 without prior tribal knowledge. Every command is copy-pasteable, and every environment variable or secret is traced to its source.\\n\\n## Document anatomy\\n\\nEvery runbook is Markdown with YAML frontmatter:\\n\\n| Field | Values | Meaning |\\n| --- | --- | --- |\\n| `type` | `concept` \u00b7 `runbook` \u00b7 `architecture` | Procedural (`runbook`), reference (`concept`), or operator-reference/design hybrid (`architecture`). Most deploy and ops docs use `concept`; `stripe-setup.md` and `wallet-passes.md` use `architecture` because they double as full operator guides |\\n| `status` | `current` \u00b7 `draft` | `current` means the procedure was verified on the `updated` date. `draft` (only `privacy-counsel-brief.md`) means it is not yet actionable |\\n| `updated` | ISO date | Tracks the last *verified* state, not the last edit |\\n| `sources` | list | Intentionally empty in most runbooks \u2014 the source of truth is the live Azure estate, not an external citation |\\n| `tags` | list | `azure`, `entra`, `payments`, `dns`, `runbook`, etc. \u2014 used for wiki search and grouping |\\n| `owner` | string | Optional; e.g. `redis-and-payment-reliability.md` names Assureworx engineering |\\n\\nBody conventions:\\n\\n- **Tables** are the primary vehicle for resource inventories, env-var mappings, DNS record sets, and runbook inventories. This keeps dense operational facts scannable.\\n- **Reference-style links** point at the exact script or backend module being operated on \u2014 never at a directory in the abstract.\\n- **Warning blocks** (`&gt; **\u26a0**`, `&gt; **\u2753 Open:**`) flag consent pitfalls, boot guards, and open design questions.\\n- **Related sections** close every document with links to the relevant ADRs, architecture docs, ISMS plans, and sibling runbooks.\\n\\n## Runbook inventory\\n\\nThe module clusters into seven functional domains.\\n\\n### Platform deployment\\n\\n| Runbook | Type | What it covers |\\n| --- | --- | --- |\\n| `azure-dev-deploy.md` | concept | The central deploy runbook. Local-Docker vs Azure cloud build modes, `scripts/deploy-dev.bat` / `deploy-dev.ps1` entry points, the full DEV resource inventory, required API env vars, the Chrome Basic Auth site gate, migration/seed procedure, smoke checks, and a \\\"gotchas fixed during first deploy\\\" list (probes on port 80, wrong `DATABASE-URL`, digest-based revisions) |\\n| `azure-test-deploy.md` | concept | TEST tier deploy via `scripts/deploy-test.ps1 -CloudBuild`, first-time CIAM app registration, the no-seed data posture (`wipe-test-app-data.mjs` + `bootstrap-platform-admin.mjs`), `beta.assureworx.com.au` DNS binding, and smoke checks |\\n\\n### Identity &amp; directory\\n\\n| Runbook | Type | What it covers |\\n| --- | --- | --- |\\n| `entra-external-id-dev.md` | concept | The largest and most active runbook. Wiring customer login into Entra External ID (CIAM tenant **Assureworx DEV Customers**), the five `ENTRA_*` env vars and their Key Vault secrets, Native Auth + branded OTP email + MFA via `enable-ciam-native-auth.ps1` / `enable-ciam-otp-email.ps1` / `enable-ciam-mfa-dev.ps1`, app registration (portal or `entra-app-reg.ps1`), the `AllPrincipals` consent requirement, and the branded auth route map (`/api/v1/auth/native/*`, `/api/v1/auth/entra/*`) |\\n| `ciam-company-branding.md` | concept | `scripts/apply-ciam-branding.ps1` \u2014 carbon background, brand copy, and logos on hosted CIAM pages, with Graph size limits for banner/header/favicon |\\n| `entra-monitoring-and-cost-dev.md` | concept | What monitoring is live (App Insights 401/403 alert `alert-aw-api-auth-failures-dev-aue`, replica alerts), what is blocked (cross-tenant Log Analytics streaming fails with `LinkedAuthorizationFailed`), the compensating Graph sign-in/audit export, and the monthly MAU/cost review cadence |\\n| `reset-test-identity.md` | concept | Wiping a test email from local Postgres, Azure DEV Postgres, and CIAM via `scripts/reset-test-identity.ps1` / `reset-test-identity.bat` / `wipe-tradie.bat`, with `-LocalOnly` / `-AzureOnly` / `-SkipEntra` / `-All` flags |\\n\\n### Payments &amp; reliability\\n\\n| Runbook | Type | What it covers |\\n| --- | --- | --- |\\n| `stripe-setup.md` | architecture | The complete Stripe operator guide: sandbox setup, restricted keys with exactly six permissions, the deliberate absence of publishable keys and Stripe Price objects (price pinned in `backend/src/modules/payments/pricing.ts`), the 11 webhook events handled by `process-event.ts`, Key Vault storage, boot-time guards (live key outside `prod` refuses to start), Stripe CLI local dev, the 12-scenario test-card run-through, tier separation, key rotation, and the production go-live checklist |\\n| `redis-and-payment-reliability.md` | runbook | The \\\"Postgres is truth; Redis/BullMQ is a pump\\\" model. The money-path inbox (`core.t_pay_evt`), idempotency keys on `t_pay_try`, the `t_fin_txn` ledger uniqueness constraint, reconciliation via `POST /api/v1/payments/admin/reconcile`, PROD Redis requirements (HA enabled, private endpoint), alert suggestions, and operator playbooks for replaying `EV5` dead-letter events, the BullMQ DLQ, and Redis outage |\\n\\n### Email &amp; DNS\\n\\n| Runbook | Type | What it covers |\\n| --- | --- | --- |\\n| `acs-email-dev.md` | concept | Azure Communication Services transactional email in DEV. Provisioned resources (`email-aw-dev-aue`, `acs-aw-dev-aue`), interim vs target sender addresses, the Crazy Domains DNS table for domain verification (SPF merge, DKIM CNAMEs, DMARC, `ms-domain-verification` TXT), delivery-status tracking via `core.t_email_delivery` + migration `036`, branded templates (`email-brand.ts`), and the CLI smoke send |\\n| `dev-custom-domains.md` | concept | Binding `dev.assureworx.com.au` / `api.dev.assureworx.com.au` via `scripts/bind-dev-domains.ps1`, the `asuid.dev` / `asuid.api.dev` TXT verification records, the shared CAE verification ID, env cutover (`PUBLIC_APP_URL`, `CORS_ORIGIN`, `ENTRA_REDIRECT_URI`), and the CIAM redirect-URI update |\\n\\n### Wallet\\n\\n| Runbook | Type | What it covers |\\n| --- | --- | --- |\\n| `wallet-passes.md` | architecture | Apple Wallet + Google Wallet pass issuance from `/verify`. The `backend/src/modules/wallet/` API surface (`/api/v1/wallet/status`, `/pass`, `/gate/resolve`, `/passes`, `/passes/:token/pkpass`, the Apple PassKit web service), the QR/gate realtime model, Apple Pass Type ID certificate lifecycle (PEMs in Key Vault as `WALLET-APPLE-*-PEM`), Google Wallet issuer/service-account setup, and security notes (download-token TTL, `core.t_pass_dl.hits_left`, gitignored `backend/certs/`) |\\n\\n### Data &amp; tooling\\n\\n| Runbook | Type | What it covers |\\n| --- | --- | --- |\\n| `vscode-postgres-setup.md` | architecture | Connecting VS Code / Cursor to Azure Postgres with Entra (passwordless) \u2014 connection settings, expected schema layout (`core`, `meta`, `public`), verification queries for migration `002`, and firewall-rule handling |\\n\\n### Compliance &amp; legal\\n\\n| Runbook | Type | What it covers |\\n| --- | --- | --- |\\n| `didit-retention-30-days.md` | runbook | Per-application data-retention configuration in the Didit Business Console (portal-only; the API does not expose it; minimum 30 days), with evidence capture into the restricted repository and tick-offs across `isms/evidence-register.md`, `isms/didit-posture.md`, and plan 06 |\\n| `privacy-counsel-brief.md` | runbook \u00b7 draft | The briefing pack for Australian privacy counsel \u2014 APP entity status, NDB applicability, controller/processor split, Didit biometric expectations. The only `draft`-status runbook; \\\"Done when\\\" is defined as written advice filed and plan 06 ticked |\\n\\n## Shared operating model\\n\\nEvery runbook in the module conforms to the same infrastructure model. New runbooks should preserve these patterns.\\n\\n### Environment tiers\\n\\n| Tier | `ASSUREWORX_ENV` | Resource group | Characteristics |\\n| --- | --- | --- | --- |\\n| Local | `local` | \u2014 | `scripts/start-dev.bat` \u2192 `http://localhost:3000`, Docker Redis on `:6379`, local Postgres on `:5433`, secrets in local `.env` |\\n| DEV | `dev` | `rg-assureworx-dev-aue` | Shared cloud environment, Entra/CIAM auth, optional Chrome Basic Auth site gate |\\n| TEST | `test` | `rg-assureworx-test-aue` | Pre-production; **no** `backend/src/seed.ts`; `AUTH_JIT_PROVISION=0`; `AUTH_EMAIL_ALLOWLIST=tech@assureworx.com.au` |\\n| PROD | `prod` | not provisioned | Placeholder only \u2014 runbooks explicitly warn \\\"do not bind until the RG exists\\\" |\\n\\n### Resource naming\\n\\nAll Azure resources follow `{kind}-{coarse}-{env}-{region}`: `ca-aw-api-dev-aue`, `ca-aw-web-dev-aue`, `kv-aw-dev-aue`, `psql-aw-dev-aue`, `crawdevaue` (ACR), `appi-aw-dev-aue`, `redis-aw-dev-aue`. Runbooks reference these names literally, so an operator can grep the module for any resource name and see every procedure that touches it.\\n\\n### Tenancy split\\n\\nA deliberate and recurring constraint:\\n\\n- **Directory A** (`e0045fa5-8177-4976-a60a-892074203e28`, Startup Sponsorship subscription `17065bb7-1e80-4ef0-a043-4302a7f37af8`) holds infrastructure: Container Apps, Key Vault, Postgres, ACR, Log Analytics, App Insights.\\n- **Directory B** \u2014 DEV CIAM tenant `assureworxdev1.onmicrosoft.com` (`0286fa4b-f51a-4a2c-9dfc-f7f6ca4c4513`) \u2014 holds customer identities and the `Assureworx Web DEV` app registration.\\n- **TEST CIAM tenant** (`37fe079d-2892-4140-9de6-a744aba5beab`, `assureworxtest1`) exists for TEST-tier registrations via `entra-app-reg-test.ps1`.\\n\\n`tech@` on Directory A cannot administer CIAM; `ishfern` signs into the CIAM tenant separately. This split is why several runbooks require two or three separate `az login` sessions, and why cross-tenant Log Analytics streaming fails with `LinkedAuthorizationFailed` (see `entra-monitoring-and-cost-dev.md`).\\n\\n### Secret flow\\n\\nEvery runbook that needs a secret follows the same chain:\\n\\n1. Secret lives in Key Vault in `SCREAMING-KEBAB` form (`ENTRA-CLIENT-SECRET`, `STRIPE-SECRET-KEY`, `DATABASE-URL`, `REDIS-URL`, `DEV-SITE-BASIC-PASSWORD`, `WALLET-APPLE-*-PEM`).\\n2. `az containerapp secret set` creates a Container App secret as a `keyvaultref:` resolved by the app's managed identity.\\n3. `az containerapp update --set-env-vars` maps `ENV_VAR=secretref:`.\\n4. The application reads the env var; the value never appears in a container definition, pipeline log, or commit.\\n\\nDeploy scripts re-apply these secretrefs on every deploy (`deploy-dev.ps1`, `bind-dev-domains.ps1`, `bind-test-domains.ps1`, `deploy-test.ps1`), so each runbook's single-command path keeps secrets in sync. Docs use placeholder values (`rk_test_\u2026`, `whsec_\u2026`) and `Read-Host` / `-AsSecureString` patterns to keep real keys out of shell history.\\n\\n### Script-backed procedures\\n\\nThe module is executable documentation. Nearly every runbook names the script that performs the procedure, so an operator can choose \\\"run the script\\\" vs \\\"follow the manual steps\\\":\\n\\n| Area | Scripts |\\n| --- | --- |\\n| Deploy | `deploy-dev.bat`, `deploy-dev.ps1`, `deploy-test.ps1` |\\n| DNS / domains | `bind-dev-domains.ps1`, `bind-test-domains.ps1` |\\n| CIAM | `entra-app-reg.ps1`, `entra-app-reg-test.ps1`, `enable-ciam-native-auth.ps1`, `enable-ciam-otp-email.ps1`, `enable-ciam-mfa-dev.ps1`, `apply-ciam-branding.ps1` |\\n| ACS email | `switch-acs-sender-info.ps1`, `wire-acs-managed-identity.ps1` |\\n| Data | `migrate.mjs`, `wipe-test-app-data.mjs`, `bootstrap-platform-admin.mjs`, `purge-party.mjs`, `provision-redis-dev.ps1` |\\n| Identity reset | `reset-test-identity.ps1`, `reset-test-identity.bat`, `wipe-tradie.bat` |\\n| Mobile demo | `run-mobile-demo.bat` (HTTPS-on-LAN for wallet/phone testing) |\\n\\n### Boot guards\\n\\nRunbooks document invariants that the application enforces rather than misbehaving later:\\n\\n- `ALLOW_DEV_AUTH` must be **unset** on Azure DEV \u2014 the boot throws if it is set while `ASSUREWORX_ENV=dev`.\\n- `ASSUREWORX_ENV` must equal `dev` on DEV so `NODE_ENV=production` does not imply the prod tier.\\n- A live Stripe key (`sk_live_` / `rk_live_`) on any tier other than `ASSUREWORX_ENV=prod` fails at boot; a `pk_\u2026` publishable key is refused at boot.\\n- TEST refuses `ALLOW_DEV_AUTH` and rejects unknown Entra users via `AUTH_JIT_PROVISION=0` + `AUTH_EMAIL_ALLOWLIST`.\\n- Wallet endpoints return `503 WALLET_NOT_CONFIGURED` when credentials are absent \u2014 the UI never fakes a saved pass.\\n- Migration scripts fail closed on drift: re-editing an applied migration is reported as `DRIFT` and never silently re-run.\\n\\n## How the module connects to the codebase\\n\\n```mermaid\\ngraph LR\\n    RB[\\\"Runbooks(wiki/runbooks/*)\\\"] --&gt; SCR[\\\"Operator scripts(scripts/*.ps1 \u00b7 *.bat)\\\"]\\n    RB --&gt; WIKI[\\\"Wiki ecosystem(decisions \u00b7 architecture \u00b7 isms)\\\"]\\n    SCR --&gt; AZ[\\\"Azure estates(DEV \u00b7 TEST Container Apps,Key Vault, Postgres, ACR)\\\"]\\n    WIKI --&gt; AZ\\n    AZ --&gt; EXT[\\\"External providers(Entra CIAM \u00b7 Stripe \u00b7 ACS \u00b7Didit \u00b7 Apple/Google Wallet)\\\"]\\n```\\n\\n- **To scripts (`scripts/`):** each runbook's primary command is a repo-root script. The scripts are the executable form of the procedure; runbooks explain when to run them, what they do, and what to verify afterwards.\\n- **To ADRs (`wiki/decisions/`):** decisions are cited as rationale at the points where a reader needs \\\"why\\\". Notable edges: `0006` (ACS email), `0008` (BFF session auth), `0023` (pay-per-verification), `0024` (payment provider boundary), `0027` (data retention), `0005`/`0018` (wallet gate / portable credential).\\n- **To architecture docs (`wiki/architecture/`):** runbooks reference the live inventories (`azure-dev-inventory.md`, `azure-test-inventory.md`), `infrastructure.md`, `payments-design-2026-08-01.md`, `data-model.md`, and `frontend.md`.\\n- **To ISMS (`isms/`):** compliance runbooks write evidence into `isms/evidence/`, update `isms/evidence-register.md` and `isms/didit-posture.md`, reference `isms/environments.md`, and tick tasks in plans `04`, `05`, `06`, `07`.\\n- **To backend modules:** deploy, payment, email, and wallet runbooks name the exact source paths they operate on: `backend/src/modules/payments/inbox.ts`, `backend/src/modules/payments/process-event.ts`, `backend/src/modules/payments/pricing.ts`, `backend/src/shared/services/email-brand.ts`, `backend/src/modules/auth/otp-email.ts`, `backend/src/modules/wallet/`, and migrations (`036_email_delivery_status.sql`, migration `027` for pass-download burn tracking).\\n\\n## Working with the module\\n\\n- **Status is a promise.** `status: current` means the procedure was verified on the date in `updated`. If you run a runbook and the result differs, fix the document or flip it to `draft`. Stale runbooks are how operator time is burned.\\n- **Evidence pattern.** Compliance runbooks (`didit-retention-30-days.md`, `entra-monitoring-and-cost-dev.md`) require screenshots or redacted exports to be stored in the **restricted evidence repository**, never committed to public git. Each has a \\\"Done when\\\" list that is the definition of done.\\n- **Secrets.** The module treats Key Vault as the only acceptable home for real keys. A real key pasted into a runbook is a security incident \u2014 `stripe-setup.md` \u00a712 gives the response procedure.\\n- **Cross-linking conventions.** New runbooks should link: ADRs for \\\"why\\\", the architecture inventory for \\\"what exists\\\", scripts for \\\"how to run it\\\", and ISMS plans for \\\"what evidence is owed\\\".\\n\\n### Suggested reading order for a new operator\\n\\n1. `azure-dev-deploy.md` \u2014 how the app ships and what exists in DEV\\n2. `entra-external-id-dev.md` \u2014 how identity works and why two tenants exist\\n3. `dev-custom-domains.md` \u2014 how the public hostnames bind\\n4. `stripe-setup.md` \u2014 how money works when it is switched on\\n5. `redis-and-payment-reliability.md` \u2014 what happens when infrastructure degrades\\n6. On demand: `wallet-passes.md`, `acs-email-dev.md`, `vscode-postgres-setup.md`, `azure-test-deploy.md`, and the compliance pair (`didit-retention-30-days.md`, `privacy-counsel-brief.md`)\",\"product-architecture-documentation-sources\":\"# Product &amp; Architecture Documentation \u2014 sources\\n\\n# Sources Module\\n\\n## Purpose\\n\\nThe `wiki/sources/` directory ingests and normalises the raw upstream documents that define the Assureworx product and platform. Each file in this module is a **source record**: a curated wrapper around a raw file in `Raw/` that extracts the operating rules, flags conflicts between documents, and routes content into the rest of the wiki.\\n\\nThe module exists to solve three problems:\\n\\n1. **Version anchoring** \u2014 raw files change; wiki pages need a stable reference to a specific version (`v1.0`, `v1.1`, dated).\\n2. **Precedence resolution** \u2014 the raw documents disagree with each other (e.g., \\\"Tier 1 organisation\\\" vs \\\"Principal Contractor\\\"). The source records encode *which document wins* and where the conflict is recorded.\\n3. **Traceability** \u2014 every wiki claim can be traced back to a source file, section, and version.\\n\\n## Module layout\\n\\n```\\nwiki/sources/\\n\u251c\u2500\u2500 assureworx-brand-guidelines.md      # Brand, voice, spelling, visual identity\\n\u251c\u2500\u2500 assureworx-platform-spec.md         # Technical spec: data model, architecture, infra\\n\u2514\u2500\u2500 assureworx-product-definition.md    # Product behaviour, scope, commercial policy\\n```\\n\\nAll three reference raw files under `../../Raw/`:\\n\\n| Raw file | Source record |\\n| --- | --- |\\n| `Raw/ASSUREWORX_BRAND.md` | `assureworx-brand-guidelines.md` |\\n| `Raw/ASSUREWORX_PRODUCT.md` | `assureworx-product-definition.md` |\\n| `Raw/assureworx-platform-spec.html` | `assureworx-platform-spec.md` |\\n\\n## Source precedence\\n\\nThe three sources are not peers. When they conflict, the following rules apply:\\n\\n1. **Product Definition v1.0** governs product behaviour, scope, and commercial policy.\\n2. **Brand Guidelines v1.0** governs spelling, terminology, voice, and visual identity \u2014 including any terminology used in product prose.\\n3. **Platform Spec v1.1** remains authoritative for technical concerns (data model, backend/frontend architecture, infrastructure) *until code exists*.\\n\\nThe Product Definition is dated the same day as the Brand Guidelines (20 July 2026) and explicitly supersedes the June Platform Spec on product, commercial, and terminology matters. The Platform Spec itself acknowledges this supersession.\\n\\n```mermaid\\nflowchart TD\\n    RAW_B[Raw/ASSUREWORX_BRAND.md] --&gt; BRAND[Brand Guidelines v1.0]\\n    RAW_P[Raw/ASSUREWORX_PRODUCT.md] --&gt; PROD[Product Definition v1.0]\\n    RAW_S[Raw/assureworx-platform-spec.html] --&gt; SPEC[Platform Spec v1.1]\\n\\n    BRAND --&gt; WIKI[Wiki product &amp; architecture pages]\\n    PROD --&gt; WIKI\\n    SPEC --&gt; WIKI\\n\\n    PROD -- \\\"supersedes (product/commercial/terminology)\\\" --&gt; SPEC\\n    BRAND -- \\\"governs spelling &amp; voice\\\" --&gt; PROD\\n    SPEC -- \\\"authoritative (technical) until code exists\\\" --&gt; WIKI\\n```\\n\\n## The source documents\\n\\n### `assureworx-brand-guidelines.md`\\n\\n**Source:** `Raw/ASSUREWORX_BRAND.md` \u00b7 v1.0 \u00b7 20 July 2026 \u00b7 status \\\"Working source of truth\\\"\\n\\nAuthoritative for brand, visual identity, voice, and messaging. Legal entity: Assureworx Labs Pty Ltd (ACN 699 745 876, Australia).\\n\\nThis source is the reference for the design system the Next.js frontend must implement and the language all copy must use. Its critical rules are normative across the entire wiki:\\n\\n- **Spelling:** `Assureworx` is the only correct form. `AssureWorx`, `Assure Works`, `Assurework`, and `Assureworx AI` are explicitly incorrect. The wiki notes that earlier drafts used `AssureWorx` and were corrected.\\n- **Terminology:** public-facing term is **Principal Contractor**; \\\"Tier 1 organisation\\\" is reserved for technical hierarchy contexts. \\\"Trade\\\" is the broad audience term.\\n- **Branding constraint:** never brand as an \\\"AI platform\\\"; lead with trust, evidence, monitoring.\\n- **Locale:** Australian English (organisation, licence, authorised, enrolment, fulfil).\\n- **Accessibility:** never communicate status by colour alone \u2014 always pair colour with icon and explicit label.\\n\\nIt also anchors the extracted essentials: the palette (Electric Blue `#3E6AE1` primary, Cyan `#23C9EE`, Violet `#7B5CE0`, Carbon `#171A20`, Canvas White, plus the semantic set), type (Segoe UI product / Cascadia Code or Fira Code mono), voice principles, the messaging promise (\\\"Verify once. Trust everywhere.\\\"), and the canonical UI status labels (Verified, Current, Pending verification, Expiring soon, Expired, Suspended, Rejected, Manual review required).\\n\\nThe distilled working reference lives on the [Brand page](../brand.md); this source record is the point of verification for it.\\n\\n### `assureworx-product-definition.md`\\n\\n**Source:** `Raw/ASSUREWORX_PRODUCT.md` \u00b7 v1.0 \u00b7 20 July 2026 \u00b7 status \\\"Working product source of truth\\\"\\n\\nThe authoritative product source. It consolidates three upstream inputs: the website copy (20 July 2026), the Platform Spec v1.1 (June 2026), and the Trade Onboarding Flow v1.0 (June 2026 \u2014 *not present in `Raw/`*, see [Open items](#open-items)).\\n\\nThis record is the most important one to read before changing product-facing wiki pages, because it contains an explicit **conflict table** documenting every known disagreement with the Platform Spec and the chosen resolution:\\n\\n| Area | Platform Spec v1.1 | Product Definition v1.0 | Resolution |\\n| --- | --- | --- | --- |\\n| Public term for buyer | \\\"Tier 1 organisation\\\" | \\\"Principal Contractor\\\" | Use Principal Contractor in prose; keep `tier1_org_id` in the data model |\\n| Tier 1 pricing | $5,000 / $10,000 / complimentary | $0 for Principal Contractors | Conflict recorded; billing page marked conflicted; tracked as open question |\\n| Verified statuses | 3 states (Pending \u2192 Verified \u2192 Suspended) | 4 states (adds Action required) | Compliance page updated |\\n| Public core checks | Implicit | 5 named checks (ABN, identity, licence, insurance, qualifications) | Compliance page updated |\\n| Requirements model | Universal doc list | 5 layers (profile \u2192 business \u2192 trade-class \u2192 PC \u2192 site) | Compliance page updated |\\n| Subcontracting depth | Chain implied | No enforced depth limit | Onboarding page updated |\\n| MVP target | \\\"Draft\\\" | Phase 1 MVP, September 2026 | Overview updated |\\n\\nIt also introduces facts not present in the spec: the 10 product principles, the privacy/access model (portable/shared facts vs tenant-private data; access derives from participation + role), success measures (\u00a713), non-functional requirements (\u00a714), and the product claims guardrails (\u00a716) \u2014 e.g., never call self-declared data \\\"verified\\\", don't claim government endorsement, and reserve \\\"immutable\\\" for protected audit records only.\\n\\n### `assureworx-platform-spec.md`\\n\\n**Source:** `Raw/assureworx-platform-spec.html` \u00b7 v1.1 \u00b7 June 2026 \u00b7 status \\\"Draft \u2014 Awaiting Dev Review\\\"\\n\\nThe technical source. Superseded on product, commercial, and terminology matters, but **still authoritative for the data model, backend/frontend architecture, and infrastructure** until code exists to replace it.\\n\\nThe record's core contribution is a 13-row **section map** that routes each spec section to its wiki page:\\n\\n| Spec \u00a7 | Topic | Wiki page |\\n| --- | --- | --- |\\n| 1 | System Overview | `overview.md` |\\n| 2 | Entity Hierarchy | `product/entity-hierarchy.md` |\\n| 3 | User Roles &amp; Access Control | `product/roles-and-permissions.md` |\\n| 4 | Data Model (core tables) | `architecture/data-model.md` |\\n| 5.1 | Authentication &amp; Identity | `architecture/integrations.md#authentication--identity` |\\n| 5.2 | Onboarding &amp; Request Chain | `product/onboarding-and-request-chain.md` |\\n| 5.3 | Compliance Management | `product/compliance-and-verification.md` |\\n| 5.4\u20135.9 | Projects/Sites, Directory, Training, Reporting, Incidents, Notifications | `product/modules.md` |\\n| 5.10 | Billing &amp; Subscriptions | `product/billing.md` |\\n| 6 | API Integrations | `architecture/integrations.md` |\\n| 7 | Frontend Architecture | `architecture/frontend.md` |\\n| 8 | Backend Architecture | `architecture/backend.md` |\\n| 9 | Infrastructure &amp; Deployment | `architecture/infrastructure.md` |\\n| 10 | Security &amp; Compliance | `architecture/security.md` |\\n| 11 | Trade Classification Reference | `product/trade-classifications.md` |\\n| 12 | Phase Scope | `decisions/open-questions.md#phase-scope` |\\n| 13 | Decisions &amp; Open Questions | `decisions/index.md`, `decisions/open-questions.md` |\\n\\nKey technical facts extracted here: multi-tenant B2B SaaS with two user groups (Tier 1 orgs and Trades), full Azure stack in Australia East with mandatory data residency, Next.js frontend + Express.js 5 backend + PostgreSQL Flexible Server, the \\\"Assureworx Verified\\\" lifecycle (Pending \u2192 Verified \u2192 Suspended with daily expiry checks), repository-layer tenant isolation with RLS-protected immutable audit log, and Stripe billing.\\n\\nThe record also flags open technical items (TBC KYC provider, TBC email provider, ABR GUID application, undocumented `users` / `projects` / `sites` tables) and a notable anomaly: the HTML references a `FRONTEND/assureworks-crm` logo path, implying a sibling or earlier frontend project exists outside this repo. This is flagged for human confirmation \u2014 the module record does not silently ingest what it cannot verify.\\n\\n## Frontmatter and metadata conventions\\n\\nEvery source record uses a consistent frontmatter schema:\\n\\n| Field | Purpose | Example |\\n| --- | --- | --- |\\n| `type` | Always `source` \u2014 distinguishes these from regular wiki pages | `type: source` |\\n| `status` | Lifecycle state of the record | `status: current` |\\n| `updated` | Last wiki-side update | `updated: 2026-07-20` |\\n| `source_id` | Stable identifier used in cross-references | `source_id: assureworx-product-definition` |\\n| `source_file` | Relative path to the raw upstream file | `source_file: ../../Raw/ASSUREWORX_PRODUCT.md` |\\n| `source_version` | Version of the *raw file* being wrapped | `source_version: v1.0` |\\n| `source_date` | Date of the raw file | `source_date: 2026-07-20` |\\n| `tags` | Classification; `source-of-truth` marks governing documents | `tags: [product, source-of-truth, primary-source]` |\\n\\nWhen a raw file is updated upstream, the source record must be updated to reflect the new `source_version` / `source_date` and the content re-checked for conflicts. Do not edit the `source_*` fields to match wiki edits \u2014 they describe the raw file, not the wiki.\\n\\n## Downstream wiki integration\\n\\nThe source records are the *inputs* to the wiki's product and architecture pages. The dependency flow is:\\n\\n- **Product &amp; architecture pages** consume the extracted facts and conflict resolutions recorded in `sources/`. The `/wiki` pages (e.g., `brand.md`, `overview.md`, `compliance-and-verification.md`) are where that content is normalised into the working wiki reference.\\n- **Decisions pages** (`decisions/open-questions.md`) aggregate the open items surfaced by the source records. Each source record links there for its open questions.\\n- **Glossary and data model pages** hold the terminology and schema decisions that resolve source conflicts (e.g., `tier1_org_id` kept in the data model while prose uses \\\"Principal Contractor\\\").\\n\\nWithin `sources/`, the records cross-reference each other: the Product Definition links to the Brand Guidelines and Platform Spec; the Brand Guidelines link to the Product Definition; the Platform Spec links to the Overview and Index. This creates a small, explicit dependency graph rather than implicit duplication.\\n\\n## Open items\\n\\nThe module deliberately surfaces unresolved questions rather than silently resolving them. Known open items as of the latest records:\\n\\n1. **Missing raw source:** \\\"Assureworx Trade Onboarding Flow v1.0\\\" (June 2026) is referenced by the Product Definition but is not present in `Raw/`. Needs to be added by a human before it can be ingested.\\n2. **Tier 1 pricing conflict:** website says $0 for Principal Contractors; Platform Spec says $5,000 / $10,000 / complimentary. Recorded, not resolved \u2014 tracked in `open-questions.md#commercial--pricing-conflicts`; `product/billing.md` is marked conflicted.\\n3. **Technical TBCs:** KYC provider, email provider, ABR GUID application.\\n4. **Undocumented tables:** `users`, `projects`, `sites` are referenced but not specified in the Platform Spec data model section.\\n5. **Sibling project:** `FRONTEND/assureworks-crm` may exist outside the repo; needs human confirmation on whether to ingest as an additional source.\\n\\n## Contributing to this module\\n\\nWhen adding or updating a source record:\\n\\n1. **Place the raw file first.** The record is a wrapper; it should not contain content not present in the raw file.\\n2. **Preserve the frontmatter schema.** Map the raw file's version/date accurately into `source_version` and `source_date`.\\n3. **Record conflicts explicitly, don't resolve silently.** If a new source disagrees with an existing one, add a row to the appropriate conflict table and link to the open question. Do not simply overwrite the older source's content.\\n4. **Update the downstream pages.** A source record is only useful if the wiki pages it feeds are updated to match. Check the section map and the \\\"Related\\\" links at the bottom of each record for the affected pages.\\n5. **Keep the supersession chain honest.** If a newer source supersedes an older one on a given topic, say so in both records. The Platform Spec's explicit acknowledgment of its own supersession is the pattern to follow.\",\"product-architecture-documentation-wiki\":\"# Product &amp; Architecture Documentation \u2014 wiki\\n\\n# Product &amp; Architecture Documentation \u2014 wiki\\n\\n## Overview\\n\\nThe `wiki/` directory is the Assureworx knowledge base. It documents the platform's product definition, architecture, engineering decisions, and operational runbooks in Markdown with YAML frontmatter. It is the canonical first stop for understanding the system \u2014 `index.md` instructs readers to start there and drill into relevant pages.\\n\\nThe wiki is written to be maintained by both humans and AI agents. Three properties make it more than a passive doc dump:\\n\\n1. **A maintenance schema.** The root `AGENTS.md` defines the schema; every page carries YAML frontmatter with `type`, `status`, and `updated` fields.\\n2. **A decision record.** Product and architecture changes are captured as numbered ADRs and tracked open questions (`Q1`\u2013`Q31`), not lost in conversation.\\n3. **Machine-checked accuracy.** A migration ledger detects schema drift, probe scripts verify documented behaviours end-to-end, and a CI-checked progress script keeps the sibling ISMS wiki honest.\\n\\nThe wiki also maintains a disciplined boundary with the security management system: security *architecture* is documented here; security *governance, controls, and evidence* live in the sibling `isms/` wiki under its own schema.\\n\\n## Repository layout\\n\\n| Path | Purpose |\\n| --- | --- |\\n| `wiki/index.md` | Content catalog; the entry point for any query |\\n| `wiki/overview.md` | What Assureworx is + current build state |\\n| `wiki/brand.md` | Brand &amp; design system: palette, typography, voice, terminology, claims guardrails |\\n| `wiki/glossary.md` | Domain terms and acronyms; linked on first use across pages |\\n| `wiki/log.md` | Append-only chronological activity record |\\n| `wiki/ways-of-working.md` | Collaborator context, tooling (VS Code Postgres), Azure tenancy notes |\\n| `wiki/product/` | What the platform does: hierarchy, roles, journeys, compliance, billing, modules |\\n| `wiki/architecture/` | How it is built: stack, code map, schema, security, integrations \u2014 plus dated audit reports |\\n| `wiki/decisions/` | ADR register (`0001`\u2013`0032`) and open questions |\\n| `wiki/sources/` | Distilled summaries of the raw source-of-truth documents |\\n| `wiki/runbooks/` | Operational procedures for deployments, Stripe, Entra, wallet passes, etc. |\\n| `isms/` | Sibling ISO 27001 / SOC 2 Type II management system (separate schema) |\\n\\n## Content architecture\\n\\nThe wiki layers flow from source documents down to operational procedure, with the activity log recording every change along the way:\\n\\n```mermaid\\ngraph TD\\n    RAW[\\\"Raw/ source documents\\\"] --&gt; SRC[\\\"sources/ distilled summaries\\\"]\\n    SRC --&gt; PROD[\\\"product/ pages\\\"]\\n    SRC --&gt; ARCH[\\\"architecture/ pages\\\"]\\n    PROD --&gt; DEC[\\\"decisions/ ADRs + open questions\\\"]\\n    ARCH --&gt; DEC\\n    ARCH --&gt; RUN[\\\"runbooks/\\\"]\\n    CODE[\\\"db/migrations, modules, routes\\\"] -- reflected in --&gt; ARCH\\n    LOG[\\\"log.md append-only record\\\"] --&gt; PROD\\n    LOG --&gt; ARCH\\n    LOG --&gt; DEC\\n```\\n\\nThe `sources/` layer is the root of the hierarchy: on conflict, the Product Definition governs. `product/` and `architecture/` pages summarise and interpret the sources; `decisions/` records how those interpretations change over time; `runbooks/` operationalises the architecture. `log.md` is the transaction log of the wiki itself.\\n\\n## Content model\\n\\nEvery page carries YAML frontmatter. Observed `type` values include `product`, `concept`, `index`, and `log`; architecture, decision, and runbook pages follow the same schema per `AGENTS.md`.\\n\\n```yaml\\n---\\ntype: product        # content category\\nstatus: current      # current | proposed | accepted | open\\nupdated: 2026-08-04  # ISO date, maintained on every edit\\nsources: [assureworx-brand-guidelines]   # optional, links to sources/\\ntags: [brand, design-system, voice, tokens]\\n---\\n```\\n\\nStatus discipline matters: ADRs carry `proposed` or `accepted`; open questions stay `open` until resolved and annotated. The wiki actively corrects stale statuses \u2014 for example, an audit pass fixed pages that still labelled ADR-0012 \\\"proposed\\\" after it had shipped.\\n\\n## Source layer\\n\\n`wiki/sources/` holds distilled summaries of the three governing documents, which arrive as raw files in `Raw/`:\\n\\n- `assureworx-product-definition.md` \u2014 Product Definition v1.0, the product source of truth\\n- `assureworx-brand-guidelines.md` \u2014 Brand Guidelines v1.0\\n- `assureworx-platform-spec.md` \u2014 Platform Technical Spec v1.1, the technical source\\n\\nThe summaries are the contract between the original documents and the rest of the wiki. When a decision contradicts a source (e.g., ADR-0023 replacing the spec's pricing model), the ADR records the supersession explicitly rather than silently editing the source.\\n\\n## Product documentation\\n\\n`wiki/product/` answers *what the platform does*. Core pages include:\\n\\n- **entity-hierarchy** \u2014 Tier 1 \u2192 projects/sites \u2192 trade network \u2192 companies \u2192 individuals\\n- **roles-and-permissions** \u2014 the five roles, scopes, multi-tenant boundaries\\n- **onboarding-and-request-chain** \u2014 request types, entry flows, profile portability\\n- **compliance-and-verification** \u2014 the Verified lifecycle, expiry monitoring, hard/soft blocks\\n- **engagement-requirements** \u2014 the \\\"Tom Delaney problem\\\": requirement sets scoped to org/project/site/placement, computed readiness\\n- **billing** \u2014 per-verification pricing, payer/beneficiary separation, money tables\\n- **identity-and-portability** \u2014 the Assureworx ID and the `D01`\u2013`D05` resolution ladder\\n- Journey pages (PC create project, PC request, employer-pays-for-crew, employee verification)\\n\\nThese pages reflect the *current* position, not the original spec: every superseding ADR (pricing, verification scope, payment provider, document extraction) is folded into the relevant product page, and the page links back to the decision.\\n\\n## Architecture documentation\\n\\n`wiki/architecture/` answers *how it is built*. It contains two kinds of documents:\\n\\n**Evergreen reference pages** \u2014 stack, code-map, frontend, backend, data-model, infrastructure, security, integrations, document-extraction, identity-verification. Two of these are *living documents* that must track the codebase continuously:\\n\\n- **`schema-mapping.md`** \u2014 described in the index as \\\"the brain\\\". It is the physical\u2192logical dictionary for the obfuscated Postgres schema: 33 core tables, the FK spine, code values, and RLS. It is mirrored live in the Platform Console **ERD** tab via `GET /api/v1/admin/schema`, which builds the model from `information_schema` + `pg_constraint` overlaid with `meta.map_dict`. Metadata is exposed to the app through `core.fn_map_dict()` and `core.fn_schema_migrations()` \u2014 `SECURITY DEFINER` projections added in migration `019` because the `meta` schema itself is revoked from the application role.\\n- **`data-classification-catalogue.md`** \u2014 a living data catalogue classifying every column, endpoint, and supplier as GP / CIC / C / PII / SPII. It enumerates all 114 HTTP routes and every `core.*`/`meta.*` column, and is regenerated by `scripts/gen-data-catalogue.mjs` after bulk schema changes. A Cursor rule (`.cursor/rules/data-classification-catalogue.mdc`) and both `AGENTS.md` files instruct agents to update it on any schema or API change.\\n\\n**Dated audit reports** \u2014 one-off, timestamped assessments: `ux-ui-audit-2026-07-24.md`, `platform-audit-2026-07-25.md`, `security-audit-2026-07-25.md`, `full-codebase-audit-2026-07-28.md`, `production-readiness-qa-audit-2026-08-01.md`, and others. These follow a consistent naming pattern (`-.md`), record findings with severity, and mark remediation status. The index carries terse status annotations for rapid triage, e.g. \\\"all gates fail; Stripe recheck recorded\\\".\\n\\n## Decision records\\n\\n`wiki/decisions/` is the governance backbone. It contains:\\n\\n- **`index.md`** \u2014 the ADR register, spanning ADR-0001 through ADR-0032.\\n- **Numbered ADRs** \u2014 `NNNN-kebab-case.md`. Each records the decision, its rationale, the migration or code it touched, and its relationship to other ADRs. The register uses explicit `supersedes` and `amends` edges: ADR-0023 supersedes ADR-0004's subscription pricing; ADR-0021 is amended by ADR-0028; ADR-0032 amends ADR-0019 decision 7.\\n- **`open-questions.md`** \u2014 numbered `Q1`\u2013`Q31`, annotated as they resolve (Q1 closed by ADR-0014, Q3 received, Q26 answered with a concrete retention setting). The file also tracks asset and scope gaps, such as the missing master logo flagged in `brand.md`.\\n\\nDecisions are recorded with their evidence. For example, ADR-0014 (Didit identity verification) cites 15 fact-check corrections against the vendor brief; ADR-0020 pins the Azure Document Intelligence model ID and API version as source constants because \\\"the model id is the price\\\". The ADR pages are the audit trail for *why* the platform is shaped as it is.\\n\\n## Runbooks\\n\\n`wiki/runbooks/` holds operational procedures for infrastructure and provider tasks. Each runbook leads with the script or command it wraps:\\n\\n- `azure-dev-deploy.md` \u2014 `scripts/deploy-dev.ps1` / `deploy-dev.bat` (ACR build \u2192 Container App update \u2192 smoke test)\\n- `stripe-setup.md` \u2014 sandbox, restricted keys, webhook events, go-live checklist\\n- `entra-external-id-dev.md` \u2014 CIAM app registration, user flows, `scripts/entra-app-reg.ps1`\\n- `wallet-passes.md` \u2014 Apple/Google Wallet issuer setup\\n- `vscode-postgres-setup.md` \u2014 connecting Cursor/VS Code to Azure Postgres via Entra\\n- `reset-test-identity.md` \u2014 `scripts/reset-test-identity.bat`\\n- `dev-custom-domains.md` \u2014 `scripts/bind-dev-domains.ps1`\\n- `ciam-company-branding.md` \u2014 `scripts/apply-ciam-branding.ps1`\\n- Plus ACS email, Redis/payment reliability, Didit retention, and privacy-counsel briefs\\n\\nThe index explicitly lists runbooks \\\"to be created as the codebase grows\\\" \u2014 the runbook layer is expected to expand alongside operational reality.\\n\\n## Activity log\\n\\n`wiki/log.md` is the append-only, chronological record of wiki and platform activity. Newest entries go at the bottom; the file is greppable via `grep \\\"^## \\\\[\\\" wiki/log.md | tail -5`.\\n\\nEntries use a typed header \u2014 `[YYYY-MM-DD] type | Summary` \u2014 where type is one of:\\n\\n| Type | Meaning |\\n| --- | --- |\\n| `init` / `ingest` | Wiki bootstrap, source document ingestion |\\n| `decision` | ADR accepted or open question resolved |\\n| `build` | Feature shipped |\\n| `query` | Investigation or audit performed |\\n| `lint` | Remediation, hardening, consistency pass |\\n| `note` | Environment, tooling, or operational fact recorded |\\n| `ops` | Live infrastructure change |\\n\\nEach entry lists the files it touched, so the log doubles as a change history for both the wiki and the platform. Audit passes are logged with their evidence locations (e.g., `.impeccable/audit-2026-07-24/screenshots/`).\\n\\n## Accuracy &amp; consistency mechanisms\\n\\nThe wiki does not trust itself. Several mechanisms keep documentation aligned with the codebase:\\n\\n- **Migration ledger.** Migrations are applied in filename order by `scripts/migrate.mjs`, each in its own transaction, and recorded in `meta.schema_migration` with a SHA-256 of the file. Editing an applied migration is reported as `DRIFT` and never silently re-run. The log records the failure mode this prevents: a skipped migration caused a 500 that was invisible until the ledger existed.\\n- **Verification probes.** Backend probe scripts assert documented behaviour against real code: `verify:identity` (39 assertions), `probe:webhook` (13), `probe-engagement-requirements.ts` (the Tom Delaney scenario end-to-end), `probe-extraction.ts` (document-extraction calibration), `probe-cloud-read.ts` (live Azure Document Intelligence). The log records bugs these probes caught, including a `seq_n` tie in consent ordering and a date-shift bug from `toISOString()`.\\n- **Audit passes with contradiction callouts.** Dated audit documents cross-check the wiki against live code and Azure state, and file contradictions *on the affected pages* as callouts \u2014 e.g., three contradictions filed against `security.md`, stale ADR-0012 status corrected in `overview.md` and `identity-and-portability.md`.\\n- **CI-checked rollups.** `scripts/isms-progress.mjs` computes ISMS plan completion from task checkboxes and rewrites progress blocks; `--check` mode fails CI if the rollups drift.\\n- **Status annotations in the index.** The index marks high-signal pages with bold verdicts (\\\"all gates fail\\\", \\\"approved; self-pay complete\\\", \\\"the brain\\\") so readers can triage without opening every page.\\n\\n## Relationship to the ISMS wiki\\n\\nThe `isms/` directory is a sibling wiki with its own `AGENTS.md`. It holds the ISO 27001 / SOC 2 Type II management system: the Statement of Applicability (93 Annex A controls), SOC 2 criteria mapping, risk register, data inventory, evidence register, policies, and workstream plans.\\n\\nThe boundary is explicit: **security architecture** (tenant isolation, auth, audit events, document security) is documented in `wiki/architecture/security.md`; **security governance, controls, and evidence** are documented in `isms/`. The two intersect at the data inventory \u2014 `isms/data-inventory.md` uses the same classification labels as the wiki's data catalogue \u2014 and at supplier/sub-processor posture, where ISMS pages track provider retention and consent obligations that originate in ADRs.\\n\\n## Tooling\\n\\n| Command / script | Purpose |\\n| --- | --- |\\n| `npm run migrate` / `npm run migrate:status` | Apply/track migrations via `scripts/migrate.mjs`; SHA-256 ledger in `meta.schema_migration`, `DRIFT` detection |\\n| `scripts/gen-data-catalogue.mjs` | Regenerate `data-classification-catalogue.md` after bulk schema changes (then hand-check classes) |\\n| `scripts/isms-progress.mjs` | Recompute ISMS plan progress; `--check` fails CI on drift |\\n| `npm run -w backend verify:identity` | 39-assertion identity verification suite |\\n| `npm run -w backend probe:webhook` | 13-assertion webhook probe against a running server |\\n| `backend/scripts/probe-engagement-requirements.ts` | End-to-end engagement-requirements probe (Tom Delaney scenario) |\\n| `backend/scripts/probe-extraction.ts` | Document-extraction calibration probe |\\n| `backend/scripts/probe-cloud-read.ts` | Live Azure Document Intelligence probe |\\n| `backend/scripts/mint-invite.mjs` / `purge-party.mjs` | Test identity lifecycle helpers |\\n| `scripts/deploy-dev.ps1` / `.bat` | DEV redeploy: build/push images, update Container Apps, smoke-test |\\n| `scripts/reset-test-identity.bat` | Wipe a test email from local DB + Azure DEV + Entra CIAM |\\n\\n## Maintenance rules\\n\\nThe following conventions are observable across the wiki and enforced by its schema:\\n\\n- **Every page has frontmatter** with `type`, `status`, and `updated`; new pages must be added to `index.md` with a one-line description.\\n- **Record decisions as ADRs.** A decision is a numbered ADR in `decisions/` linked from the register \u2014 not a paragraph in a product page. Open questions are numbered and annotated when resolved.\\n- **Keep the log.** Every material change gets a typed `## [date] type | Summary` entry at the bottom of `log.md`, listing files touched.\\n- **Never edit an applied migration.** Add a new file in `db/migrations/` and run `npm run migrate`; the ledger reports drift and stops silent re-runs.\\n- **Keep the living documents current.** Any schema or API change requires updating `schema-mapping.md` and `data-classification-catalogue.md` (via `scripts/gen-data-catalogue.mjs` where applicable).\\n- **Use the terminology discipline.** \\\"Assureworx\\\" (never `AssureWorx`), \\\"Principal Contractor\\\" publicly vs \\\"Tier 1\\\" technically, Australian English, and never status-by-colour alone \u2014 these rules in `brand.md` are normative for both the frontend and the wiki itself.\\n- **Flag contradictions, don't silently paper over them.** Audit findings are filed as dated reports with callouts on the affected pages, and unresolved gaps are tracked in `decisions/open-questions.md`.\",\"product-architecture-documentation\":\"# Product &amp; Architecture Documentation\\n\\n# Product &amp; Architecture Documentation\\n\\n## Overview\\n\\nThe Product &amp; Architecture Documentation module is the complete written definition of the Assureworx platform \u2014 a multi-tenant B2B SaaS compliance network for the Australian construction and trades industry, where Principal Contractors verify workers across project and site networks and trades carry a single portable compliance profile.\\n\\nThe module spans the product's entire knowledge lifecycle: raw upstream source documents, normalized product and architecture records, architectural decisions, operational runbooks, agent governance contracts, and the implementation layout of the executable applications. Its own pages are declarative \u2014 no cross-module calls, no execution flows detected \u2014 but their influence on the codebase is binding: each document constrains how the system is built, maintained, or operated.\\n\\n## How the sub-modules fit together\\n\\n```mermaid\\ngraph TD\\n    subgraph Wiki[\\\"wiki \u2014 the knowledge base (schema governed by AGENTS.md)\\\"]\\n        Sources[sources \u2014 version-anchored records]\\n        WProduct[product \u2014 entity model, roles, journeys]\\n        WArch[architecture \u2014 system &amp; data model]\\n        Decisions[decisions \u2014 ADR log]\\n        Runbooks[runbooks \u2014 operations]\\n    end\\n\\n    Raw[Raw \u2014 raw brand &amp; product sources] --&gt; Sources\\n    Sources --&gt; WProduct\\n    Sources --&gt; WArch\\n    Decisions --&gt; WProduct\\n    Decisions --&gt; WArch\\n    PRODUCT[PRODUCT.md] --&gt; WProduct\\n    DESIGN[DESIGN.md] --&gt; Frontend[frontend]\\n    WArch --&gt; Backend[backend]\\n    Runbooks -. operates .-&gt; Backend\\n    AGENTS[AGENTS.md] -. governs .-&gt; Wiki\\n```\\n\\n### Product knowledge pipeline\\n\\nThe core flow is a knowledge pipeline. [Raw](raw.md) holds the unprocessed working source of truth \u2014 brand and product documents that can conflict with each other. [sources](sources.md) ingests those files into curated source records that anchor versions and encode precedence rules (\\\"which document wins\\\"). The [wiki](wiki.md) knowledge base then consumes those records: its [product](product.md) pages describe the entity model, roles, journeys, verification rules, identity model, and billing, while its [architecture](architecture.md) pages record repository layout, backend module structure, the data model, and the Azure estates. [decisions](decisions.md) \u2014 the ADR log \u2014 records *why* the system is shaped as it is and is cited by both product and architecture pages. [runbooks](runbooks.md) form the operational layer, coupling each procedure to the scripts and infrastructure it drives.\\n\\n### Governance and entry points\\n\\n[AGENTS.md](agents.md) defines the \\\"LLM Wiki\\\" operating contract: the schema and workflow that make the wiki a compounding, agent-maintainable artifact \u2014 humans curate raw sources and ask questions; the LLM agent owns the wiki layer. [CLAUDE.md](claude.md) is the GitNexus integration contract, teaching agents which code-intelligence tooling is mandatory and which is prohibited. [README.md](readme.md) is the repository's entry point and navigation hub for humans. [PRODUCT.md](product-definition.md) is the source-of-truth product definition \u2014 positioning, users, design principles, and accessibility \u2014 while [DESIGN.md](design.md) specifies the visual language and sanctioned component patterns for the frontend shell.\\n\\n### Implementation layout\\n\\n[backend](backend.md) is the Phase 1 Express 5 API built as a modular monolith: a single deployable process with bounded modules that can be extracted into separate Container Apps later. [frontend](frontend.md) is the Next.js (App Router) client serving Principal Contractor and Trade personas in one shell, currently standalone and designed to communicate with the backend exclusively via `/api/v1`.\\n\\n## Key workflows\\n\\n1. **Ingestion and normalization** \u2014 Raw documents are wrapped by sources, version-anchored, conflict-resolved, and routed into the wiki's product and architecture pages.\\n2. **Change propagation** \u2014 Architecture pages are living documents: several carry maintenance obligations requiring docs to change in the same change as the code, under the schema defined in AGENTS.md.\\n3. **Decision traceability** \u2014 Design shifts are captured as numbered ADRs; product and architecture pages cite them so the rationale and cost behind any choice stay recoverable.\\n4. **Design-to-implementation handoff** \u2014 PRODUCT.md and DESIGN.md constrain frontend work; architecture pages and ADRs shape the backend's modular structure.\\n5. **Operations** \u2014 Runbooks invoke their paired scripts against Azure infrastructure for repeatable deployment, identity wiring, payments, wallet passes, and compliance evidence.\",\"projects-sites-engagements-backend-src\":\"# Projects, Sites &amp; Engagements \u2014 backend-src\\n\\n## Projects, Sites &amp; Engagements \u2014 backend-src\\n\\nThis module contains the two shared backend services that power the Projects, Sites &amp; Engagements domain:\\n\\n- **`geocode.ts`** \u2014 resolves free-text site addresses into map coordinates via Nominatim (OpenStreetMap), with rate limiting and an in-process cache.\\n- **`requirements.ts`** \u2014 the engagement-readiness engine. It computes the effective requirement set for a placement (a trade on a site under a project), derives the placement's readiness state, and persists/audits state transitions on `t_link`.\\n\\nThe requirements service is the substantive piece. It implements ADR-0005 layers 4\u20135 (engagement-specific requirements) per ADR-0018, replacing the historical behaviour where `t_link.state_cd` was a straight copy of `t_party.status_cd`. The geocoder is a supporting utility used when site records are created or updated.\\n\\n---\\n\\n## 1. Geocoding service (`geocode.ts`)\\n\\n### Purpose\\n\\nTurn Australian site addresses into `{ lat, lng, label }` coordinates for map pins. The service is deliberately conservative with the upstream API: it rate-limits to ~1 request/second, caches every result (including misses) in-process, and degrades through progressively coarser fallback queries.\\n\\n### Public API\\n\\n| Function | Signature | Description |\\n|---|---|---|\\n| `geocodePlace` | `(query: string) =&gt; Promise` | Resolves a free-text address to coordinates. Returns `null` for empty/too-short queries, failed lookups, or invalid responses. |\\n| `buildSiteQuery` | `(parts: { address?, locality?, region?, postcode? }) =&gt; string \\\\| null` | Joins non-empty site address parts into a single query string. Returns `null` when every part is empty. |\\n\\n```ts\\ntype GeocodeHit = {\\n  lat: number;\\n  lng: number;\\n  label: string;\\n};\\n```\\n\\n### How it works\\n\\n1. **Normalise and cache-check.** `normalizeQuery` collapses whitespace, trims, and lowercases the input. The cache is a module-level `Map`. A cached `null` is treated as a miss that won't be re-attempted in-process \u2014 failed lookups are cached as failures. Queries shorter than 2 characters are rejected outright.\\n\\n2. **Rate-limited upstream call.** `nominatimSearch` awaits `throttle()`, which enforces a minimum 1100 ms gap between requests (`MIN_GAP_MS`). The request is sent to `https://nominatim.openstreetmap.org/search` with:\\n   - `q` \u2014 the query, with `\\\", Australia\\\"` appended unless it already contains \\\"Australia\\\"\\n   - `format=json`, `limit=1`, `countrycodes=au`\\n   - a descriptive `User-Agent` identifying the platform\\n\\n   Non-OK responses return `null`; the first row's `lat`/`lon` strings are converted to numbers and validated with `Number.isFinite`.\\n\\n3. **Fallback chain.** If the full address produces no hit, `geocodePlace` tries progressively coarser queries:\\n   - **Suburb/state tail** \u2014 split on commas and drop the first part (the street number/address), so a misspelled street still resolves via `suburb, state`.\\n   - **Postcode** \u2014 extract the first 4-digit sequence with `/\\\\b\\\\d{4}\\\\b/` and geocode just the postcode. The comment in code notes this is the most resilient fallback for legacy values with misspelled or abbreviated suburb names.\\n\\n4. **Cache and return.** The winning hit (or `null`) is stored under the normalized key before being returned.\\n\\n`buildSiteQuery` is the complement: it trims and joins `address`, `locality`, `region`, `postcode` with `\\\", \\\"`, filtering empties. Suburb plus state alone is considered sufficient for a useful pin.\\n\\n---\\n\\n## 2. Engagement requirements service (`requirements.ts`)\\n\\n### Purpose\\n\\nAnswer the question the portable profile cannot: *\\\"is this trade ready for **this** job?\\\"* A carpenter verified for carpentry is not thereby certified for electrical work, but historically `t_link.state_cd` mirrored `t_party.status_cd` and reported `ready` on sites the trade was not qualified for. This service computes the effective requirement set for each placement and derives a readiness state from it.\\n\\n### Domain model: five requirement sources\\n\\nEffective requirements for a placement are the union of:\\n\\n| Source | Scope | Attribution rank |\\n|---|---|---|\\n| `placement` | one-off condition on this trade at this site | 5 (narrowest) |\\n| `site` | every placement under this site | 4 |\\n| `project` | every placement under this project | 3 |\\n| `organisation` | every engagement under this principal contractor | 2 |\\n| `portable` | the trade's own required evidence (universal + trade tags) | 1 |\\n\\nTwo ordering rules govern the merge:\\n\\n- **Harshest block wins.** `BLOCK_RANK` is `hard: 3, soft: 2, advisory: 1`. A requirement demanded by two sources survives once, carrying the harshest block.\\n- **Narrowest source wins for attribution.** `SOURCE_RANK` determines who the trade is told is asking.\\n\\nA requirement scoped to a classification (`applies_tag_cd`) only reaches placements engaged with that tag, and an item that applies to everyone outranks a classification-scoped one during merges.\\n\\n```mermaid\\nflowchart LR\\n    Portable[portabletrade evidence] --&gt; Merge[merge into effective set]\\n    Org[organisationprincipal-wide set] --&gt; Merge\\n    Proj[project set] --&gt; Merge\\n    Site[site set] --&gt; Merge\\n    Place[placement set] --&gt; Merge\\n    Merge --&gt; Gaps[hardGapCount / softGapCount]\\n    Gaps --&gt; Derive[deriveReadiness]\\n```\\n\\n### Readiness derivation\\n\\n```ts\\nderiveReadiness(partyStatusCd: string, hardGaps: number, softGaps: number): ReadinessState\\n```\\n\\n| Condition | State |\\n|---|---|\\n| `partyStatusCd === \\\"S04\\\"` (suspended profile) | `blocked` |\\n| `hardGaps &gt; 0` | `blocked` |\\n| `partyStatusCd === \\\"S01\\\"` (unfinished profile) | `pending` |\\n| `partyStatusCd === \\\"S03\\\"` or `softGaps &gt; 0` | `action_required` |\\n| otherwise | `ready` |\\n\\nThe layering is strictly additive: engagement requirements (layers 4\u20135) add to the portable profile (layers 1\u20133), never excuse it. A suspended profile blocks regardless of paperwork; an unfinished profile stays pending however complete the engagement evidence is. Conversely, when no engagement requirements are configured, this reduces exactly to the legacy `READINESS_BY_STATUS` mapping \u2014 so existing placements do not move when the feature ships.\\n\\n### Key helpers\\n\\n- `requirementKey(classCd, courseId)` \u2014 map key: `doc:${classCd}` or `course:${courseId}`.\\n- `merge(into, next)` \u2014 folds a requirement into the accumulator, keeping the harshest block and narrowest source on collision.\\n- `rowsOf(res)` \u2014 casts the untyped `Queryable.query` result rows in one place rather than per call.\\n\\n### Resolution: `resolveEngagementReadiness`\\n\\n```ts\\nresolveEngagementReadiness(db: Queryable, linkIds: string[]): Promise&gt;\\n```\\n\\nBatched deliberately: a site board renders hundreds of placements, and per-link work is a map lookup once the (roughly seven) batched queries have run. Cost is independent of the number of links.\\n\\n**Queries:**\\n\\n1. **Links** \u2014 `t_link` rows for the requested IDs (`id`, `owner_party_id`, `node_id`, `party_id`, `state_cd`), excluding soft-deleted rows.\\n2. **Node ancestry** \u2014 a recursive CTE over `t_node` collects each node plus every ancestor (nearest first). The `ancestry(nodeId)` helper then walks parents through the in-memory `nodeById` map. This is how a site requirement and the project requirement above it both apply.\\n3. **Link tags** \u2014 `t_link_tag` rows for the requested links, grouped into `tagsByLink`; these are the engaged trade classifications.\\n4. **Requirement sets + items** \u2014 one query joining `t_req_set`, `t_req_item`, and `t_course`, selecting active sets (`status_cd = 'RS1'`) owned by the relevant parties where the set is:\\n   - organisation-wide (`node_id IS NULL AND link_id IS NULL`), **or**\\n   - node-bound (site/project), **or**\\n   - link-bound (placement)\\n5. **Evidence** \u2014 `getEvidenceForParties(db, partyIds)` loads each party's evidence rows; `loadArtifactClasses(db)` loads artifact class metadata (display names and default block).\\n6. **Course completions** \u2014 `t_result` joined to `t_task`, distinct per party/course, where `passed_flag = TRUE`.\\n7. **Party statuses** \u2014 `t_party.status_cd` for every involved party.\\n\\n**Per-link assembly:**\\n\\nFor each link, the function:\\n\\n- computes the node chain, engaged tags, per-class evidence map, and completed course set;\\n- seeds the merged map with the trade's **portable** evidence rows where `required` is true (document requirements with source `portable`);\\n- iterates requirement rows, filtering to the link's owner, and classifies each row's source:\\n  - `req.link_id` \u2192 `placement` (must match this link)\\n  - `req.node_id` \u2192 `project` if the node kind is `N01`, else `site` (must appear in this link's ancestry)\\n  - neither \u2192 `organisation`\\n- applies the `applies_tag_cd` scope check \u2014 a classification-scoped item only reaches placements engaged with that tag;\\n- handles the two item kinds:\\n  - **`X01` document** \u2014 status comes from the evidence map (`missing` if absent); met when status is `verified` or `expiring_soon` (`MET_EVIDENCE`);\\n  - **`X02` course** \u2014 status is `completed` / `not_completed` from the completion set;\\n- sorts requirements by label, computes gaps and gap counts, and stores the `EngagementReadiness` object keyed by link ID.\\n\\nThe returned `EngagementReadiness` contains `linkId`, `partyId`, `state`, `engagedTags`, the full sorted `requirements` list, the unmet `gaps`, and the `hardGapCount` / `softGapCount`.\\n\\n### Single-placement wrapper\\n\\n```ts\\nresolveEngagementReadinessForLink(db, linkId): Promise\\n```\\n\\nDelegates to `resolveEngagementReadiness([linkId])` and returns just that link's result. Used primarily by the `probe-engagement-requirements.ts` script.\\n\\n### Persistence: `evaluateLinks`\\n\\n```ts\\nevaluateLinks(tx: Queryable, linkIds: string[], actorId?: string | null): Promise\\n```\\n\\nRecomputes readiness for the given placements and persists it:\\n\\n1. Resolve readiness via `resolveEngagementReadiness`.\\n2. Load current `t_link` rows (`state_cd`, `gap_hard_n`, `gap_soft_n`).\\n3. For each row, map the resolved state to its stored code via `LINK_STATE_CD` and compare against the current values.\\n4. **UPDATE** `t_link`:\\n   - `state_cd = $2::varchar` \u2014 the explicit cast is deliberate; per the code comment, without it Postgres deduces `varchar` from the `SET` clause and `text` from the comparison and refuses the statement.\\n   - `state_at` is refreshed only when the state actually changed.\\n   - `gap_hard_n` / `gap_soft_n` are always written.\\n   - `evaluated_at` is refreshed **even when nothing moved**, so a stale board is distinguishable from a board that is genuinely settled.\\n5. If the state or gap counts changed, emit an audit event (`verb: \\\"link.readiness_changed\\\"`) recording:\\n   - the previous and new state (`from` uses the stored `state_cd` code; `to` uses the resolved readiness state),\\n   - the gap counts,\\n   - the full list of unmet requirements (`key`, `label`, `source`, `block`, `status`) \u2014 this makes the audit trail defensible without re-resolving the rule set as it stood at the time.\\n\\nReturns the number of placements whose state actually moved.\\n\\n### Re-evaluate all placements for a party\\n\\n```ts\\nevaluatePartyEngagements(tx: Queryable, partyId: string, actorId?: string | null): Promise\\n```\\n\\nSelects all live `t_link` rows for the party and delegates to `evaluateLinks`. This is the entry point used when a trade's **portable** status changes \u2014 every live placement must be re-derived, because a suspended or pending profile blocks regardless of engagement paperwork.\\n\\n### Misc exports\\n\\n- `engagedTagNames(tagCodes)` \u2014 maps classification codes to display names via `TAG_NAMES`, falling back to the raw code.\\n- `isoDate` \u2014 re-exported from `evidence.ts` so callers formatting a gap list need only one import.\\n\\n---\\n\\n## 3. Integration points\\n\\n```mermaid\\nflowchart TD\\n    VS[evaluateAndPersistStatusverified-status.ts] --&gt; EPE[evaluatePartyEngagements]\\n    PS[placeEmployeeOnSiteemployee-placement.ts] --&gt; EL[evaluateLinks]\\n    NM[nodes module] --&gt; EL\\n    NM --&gt; GP[geocodePlace]\\n    JH[job handlers] --&gt; GP\\n    EPE --&gt; EL\\n    EL --&gt; RER[resolveEngagementReadiness]\\n    RER --&gt; AUD[audit: link.readiness_changed]\\n    RER --&gt; EV[getEvidenceForParties / loadArtifactClasses]\\n```\\n\\n| Caller | Callee | When |\\n|---|---|---|\\n| `evaluateAndPersistStatus` (`shared/services/verified-status.ts`) | `evaluatePartyEngagements` | A trade's portable verification status changes; all their live placements must be re-derived. |\\n| `placeEmployeeOnSite` (`shared/services/employee-placement.ts`) | `evaluateLinks` | A placement is created or modified; its readiness is computed immediately. |\\n| `modules/nodes/index.ts` | `evaluateLinks`, `geocodePlace` | Node mutations \u2014 site/project changes can alter the applicable requirement set, and site address changes need a fresh map pin. |\\n| `registerJobHandlers` (`shared/jobs/handlers.ts`) | `geocodePlace` | Geocoding site records in the background. |\\n| `probe-engagement-requirements.ts` (script) | `resolveEngagementReadinessForLink`, `evaluateLinks` | Operational debugging of a single placement's effective requirements. |\\n\\nThe separation of entry points matters operationally: a portable status change and a missing site document are different events, and the audit trail records which one moved a placement (and why).\",\"projects-sites-engagements-frontend-src\":\"# Projects, Sites &amp; Engagements \u2014 frontend-src\\n\\n# Projects, Sites &amp; Engagements \u2014 Frontend Module\\n\\n## Overview\\n\\nThis module is the operational control surface for Assureworx portals. It lets principals and admins see how projects, sites, contractors, and tradespeople are tracking against compliance requirements, then act on that data: drill into a project, start a new project with a guided wizard, add sites, stage contractors, and send onboarding invitations.\\n\\nThe module lives under `frontend/src/app/(app)/` and is built from three cooperating layers:\\n\\n- **Server components** (`page.tsx`) that enforce portal access and preload initial data over the Assureworx REST API.\\n- **Client components** (`DashboardClient.tsx`, `ProjectsClient.tsx`, `CreateProjectWizard.tsx`) that hydrate that data via `useApi`, maintain client state, and issue mutations.\\n- **Shared UI/infra** (`useApi`, `useSession`, `SiteMapBoardLazy`, `project-wizard-drafts`, geo/format utilities) that the page components compose.\\n\\n## Architecture\\n\\n```mermaid\\nflowchart TD\\n  subgraph server[\\\"Server components (RSC)\\\"]\\n    DP[DashboardPage] --&gt; RP[requirePortal]\\n    PP[ProjectsPage] --&gt; RP\\n    DP --&gt; LOAD[loadAppPage / loadAppPageOptional]\\n    PP --&gt; LOAD\\n    LOAD --&gt; API[REST API]\\n  end\\n\\n  subgraph client[\\\"Client components\\\"]\\n    DP --&gt; DC[DashboardClient]\\n    PP --&gt; PCB[ProjectsClient \u2192 ProjectsBoard]\\n    DC --&gt; UA[useApi]\\n    PCB --&gt; UA\\n    DC --&gt; VC[VolumeBar \u00b7 StatusBar \u00b7 StatusGauge \u00b7 Legend]\\n    DC --&gt; SM[SiteMapBoardLazy]\\n    PCB --&gt; WIZ[CreateProjectWizard]\\n    WIZ --&gt; DRAFTS[project-wizard-drafts]\\n    PCB --&gt; SM\\n  end\\n```\\n\\nAll three entry pages follow the same pattern: the server component calls `requirePortal(...)` to gate access, fetches initial payloads in parallel with `Promise.all`, and passes them as props to a client component. The client component then re-fetches through `useApi` with the same endpoints, using the server payload as `initialData` so the first paint is instant.\\n\\n## Server entry points\\n\\n### `(app)/dashboard/page.tsx`\\n\\n```ts\\nawait requirePortal(\\\"principal\\\");\\nconst [initialData, initialProjects, initialSites] = await Promise.all([\\n  loadAppPage(\\\"/reporting/dashboard\\\"),\\n  loadAppPageOptional(\\\"/nodes/projects\\\"),\\n  loadAppPageOptional(\\\"/nodes/sites\\\"),\\n]);\\n```\\n\\n- `requirePortal(\\\"principal\\\")` restricts the dashboard to principal-portal sessions.\\n- The dashboard summary is **required** (`loadAppPage` throws on failure); projects and sites are **optional** (`loadAppPageOptional` returns `null`), because a portal may not have projects yet.\\n- The `null` distinction is preserved into `DashboardClient`: when `initialProjects`/`initialSites` are `null`, the client calls `useApi` without `initialData`; otherwise it passes the server data so hydration is seamless.\\n\\n### `(app)/projects/page.tsx`\\n\\nSame pattern with `requirePortal` and `loadAppPage`/`loadAppPageOptional`, feeding `ProjectsClient`. The projects page is also the host for the create/edit wizard, toggled via the `?new=1` search parameter.\\n\\n## Dashboard \u2014 `DashboardClient.tsx`\\n\\n### Data model and normalization\\n\\n`DashboardClient` receives a `DashboardSummary` containing network party counts, portfolio readiness, trade type counts, top companies, and evidence attention items. It normalizes several raw shapes into a small set of internal types:\\n\\n- **`Readiness`** \u2014 `{ ready, pending, blocked, action_required }`, used for projects, sites, and companies.\\n- **`StatusSplit`** \u2014 `{ verified, awaiting, outstanding }`, used for party status (contractors/tradespeople and per-trade splits).\\n- **`DashFilter`** \u2014 a discriminated union describing the active cross-filter (`partyStatus`, `evidence`, `tradeType`, `readiness`).\\n\\nHelper functions keep the conversions explicit:\\n\\n- `tradeSplit(type)` maps a `TradeTypeCount` into a `StatusSplit` \u2014 note `pending \u2192 awaiting` and `actionRequired + suspended \u2192 outstanding`.\\n- `readinessMetric(r, state)` merges `blocked` into `action_required`, so \\\"blocked\\\" never appears as a separate colour in the UI.\\n- `readinessTotal(r)` and `splitTotal(s)` sum their respective buckets.\\n- `readyPct(r)` computes the readiness percentage for company meta lines.\\n- `partyStatusFromReadiness` / `readinessFromPartyStatus` translate between the two axes so every panel reacts to the same selection.\\n\\n### Unified cross-filter\\n\\nThe dashboard's key design decision is a single `DashFilter` state shared by all panels. Clicking a segment on the contractor gauge, a trade row, a readiness bar, or an evidence item updates one filter; every panel recomputes through `useMemo` against that filter.\\n\\n```ts\\nconst activePartyStatus = filter?.kind === \\\"partyStatus\\\" ? ... : filter?.kind === \\\"evidence\\\" ? ... : null;\\nconst activeReadiness = filter?.kind === \\\"readiness\\\" ? ... : partyStatusFromReadiness(activePartyStatus) : null;\\nconst activeEvidence = filter?.kind === \\\"evidence\\\" ? filter.bucket : null;\\n```\\n\\n`evidenceBucketsForFilter(filter)` maps any filter state to the evidence buckets that should be visible, so for example an \\\"awaiting\\\" party-status filter also highlights missing and expiring evidence. `toggleFilter` implements click-again-to-clear semantics, and `filterLabel` produces the human-readable chip text shown in the filter banner.\\n\\n### Panels and visual components\\n\\n| Component | Purpose |\\n|---|---|\\n| `VolumeBar` | Segmented horizontal bar for readiness (`ready` / `pending` / `action_required`). Segments are buttons; clicking sets a readiness filter. Supports an `activeState` that isolates one segment and a `showCounts` flag for small numeric labels. |\\n| `StatusBar` | Same segmented-bar pattern for `StatusSplit` values (verified / awaiting / outstanding), used in the trade network rail. |\\n| `StatusGauge` | SVG half-circle gauge for contractor/tradesperson headline KPIs. Renders stacked arcs per status, dims inactive arcs, and exposes a hover/focus tooltip (`role=\\\"tooltip\\\"`) whose rows are filter buttons. |\\n| `Legend` | Colour legend rendered as static spans or toggle buttons, normalizing `blocked` to `action_required`. |\\n| `KpiButton` | Evidence KPI tiles (Expiring / Missing / Failed-expired) with an active/pressed state. |\\n\\n### Data derivation (all `useMemo`)\\n\\n- `projectRows` \u2014 top 8 projects, sorted by the active readiness metric (or total volume when unfiltered); supports drill-down to per-site rows via `expandedProjectId`.\\n- `companyRows` \u2014 top 6 contractors by readiness volume.\\n- `tradeTypes` \u2014 top 8 trades, filtered to the selected trade, sorted by the active party-status metric.\\n- `filteredEvidence` \u2014 up to 8 evidence items, sourced from `data.evidence.items` or synthesized from the legacy `expiringSoon` / `expiredCount` fields.\\n- `mapPlaces` \u2014 sites converted to `MapPlace` objects for `SiteMapBoardLazy`, filtered by readiness state via `siteMatchesReadiness`.\\n- `sitesByProject` \u2014 a `Map` for the project drill-down.\\n\\n### Action menu and chrome\\n\\n- **Quick actions** \u2014 a `+` button opens a portal-rendered menu (`actionsSheet` via `usePresence`) with role-based items: `Create project` (only `tier1_admin`), `Manage projects`, `Send request` (only when `canSendRequests(me)`), `Manage trades`. Clicking navigates via `router.push`.\\n- **Greeting** \u2014 `greetingFor(name)` computes a time-of-day greeting, rendered into the top bar via `useTopBarTitleSlot` + `createPortal`.\\n- **Activity rail** \u2014 `ActivityRail` is pinned/unpinned with `useMediaQuery(\\\"(max-width: 1279px)\\\")` disabling pinning on small screens.\\n- **PerfProfiler** wraps the whole tree for performance instrumentation.\\n\\n## Projects index \u2014 `ProjectsClient.tsx`\\n\\n`ProjectsBoard` renders the project list (or a map view) and hosts `CreateProjectWizard`. Notable details from the call graph and source:\\n\\n- View mode (`\\\"sites\\\" | \\\"map\\\"`) persists to `localStorage` under `VIEW_KEY = \\\"awx.projects.view\\\"`; the dashboard's \\\"Open map\\\" link navigates to `/projects?view=map`, which is read via `useSearchParams`.\\n- The list rows use `plural` and `formatShortDate` for metadata, and the wizard is mounted when `?new=1` is present.\\n- The whole board is wrapped in `Suspense` (needed because `useSearchParams` requires it in Next.js App Router).\\n\\n## Create/Edit Project Wizard \u2014 `CreateProjectWizard.tsx`\\n\\nThe wizard is a four-step flow (`STEPS`): **Project details \u2192 Site Requirements \u2192 Add contractors \u2192 Review**. It handles both creation and editing (`editProjectId` prop).\\n\\n```mermaid\\nflowchart LR\\n  S1[Step 1 \u00b7 Project details] --&gt; S2[Step 2 \u00b7 Site Requirements]\\n  S2 --&gt; S3[Step 3 \u00b7 Add contractors]\\n  S3 --&gt; S4[Step 4 \u00b7 Review &amp; confirm]\\n  S1 --&gt; D[Auto-save draft \u2192 localStorage]\\n  S2 --&gt; D\\n  S3 --&gt; D\\n  S4 --&gt; API2[POST /requests/project-invitePOST /nodes/sites/:id/assign]\\n  S1 --&gt; API[POST/PATCH /nodes/projectsPOST /nodes/projects/:id/sites]\\n```\\n\\n### Step 1 \u2014 Project details &amp; locations\\n\\n- The form is a `CreateProjectBody` (`name`, `refCode`, `description`, `startsOn`, `endsOn`) with end-date validation via `projectDateError`.\\n- Locations are `WizardLocationDraft[]` entries. `saveProjectAndLocations` creates or patches the project first, then persists each new location through `persistLocation` \u2192 `POST /nodes/projects/{id}/sites`. `validateLocation` enforces a site name and at least a suburb or a four-digit postcode.\\n- The step is only submittable when a project name is present and dates are valid. After saving, it advances to Step 2.\\n\\n### Step 2 \u2014 Site Requirements\\n\\n- **Onboarding scope** radio group: `project` (contractor accepts and assigns trades project-wide) vs `project_site` (contractor assigns staff per site).\\n- Requirement catalogue fetched once from `GET /requests/project-invite-options`, split into insurance keys and company-specific keys. `COMPANY_CATEGORIES` filters the catalogue down to `business`, `insurance`, `safety`, plus the always-included `identity`, `abn`, and `trade_types` keys.\\n- Trade-specific requirements: selecting a trade's `tagCode` via `toggleTag` seeds `tradeDocs[tagCode]` with that trade's required document class codes; `toggleTradeDoc` flips individual documents between required/optional.\\n- Step 2 cannot be passed without at least one company requirement selected.\\n\\n### Step 3 \u2014 Add contractors\\n\\n- Directory search is debounced 250 ms against `GET /directory?pageSize=50&amp;q=...`. Results are `DirectoryRow[]` rendered with `PartyIdentity`.\\n- **Network parties** (already on Assureworx) are added directly to the invitation list via `addNetworkToList`. **Non-network parties** go through the invite form (`addInviteToList`) with name/email/phone.\\n- The invitation list is a table of `InvitationListItem` entries. In `project_site` mode each row has per-site checkboxes (`toggleItemSite`, `selectAllSitesForItem`). Duplicates are rejected via `listedPartyIds` / `listedEmails`.\\n- `defaultSiteIds()` implements the onboarding-scope difference: `project` scope yields `[]`, `project_site` scope pre-selects all persisted sites.\\n\\n### Step 4 \u2014 Review &amp; confirm\\n\\n`confirmAndSend` performs the final mutations:\\n\\n1. **Assign network parties** \u2014 for each network list item, `POST /nodes/sites/{siteId}/assign`; \\\"already assigned\\\" errors are treated as success.\\n2. **Send invitations** \u2014 invitees are grouped by target node (project or site) and sent in one `POST /requests/project-invite` per group, with `companyRequirementKeys` and `tradeBundles()` in the body. Counts of `sent` / `failed` are aggregated from `CreateProjectInviteResult`.\\n3. The active draft is cleared and a summary (e.g. `\\\"3 assignments | 2 invites sent | 1 failed\\\"`) is shown on the review screen.\\n\\n### Draft persistence\\n\\nThe wizard auto-saves to `localStorage` through the `project-wizard-drafts` library:\\n\\n- A 300 ms debounced effect serializes the entire wizard state (`step`, `form`, `locations`, `onboardingScope`, `companyKeys`, `selectedTags`, `tradeDocs`, `invitationList` \u2014 with phone numbers deliberately excluded) and calls `upsertProjectWizardDraft`.\\n- On mount (create mode) it restores the active draft via `getActiveProjectWizardDraftId` / `loadProjectWizardDraft`, or starts fresh with `newEmptyWizardDraft()`.\\n- The draft bar shows save status (\\\"Draft saved\\\" / \\\"Draft restored\\\"), a picker of saved drafts (`listProjectWizardDrafts`), and `Discard draft` / `Start new draft` actions.\\n- Drafts are org-scoped (keyed by `orgId`) and are cleared after a successful `confirmAndSend`.\\n\\n### Edit mode\\n\\nWhen `editProjectId` is set, `loadingEdit` shows a skeleton while the component fetches `GET /nodes/projects/{id}` plus `GET /nodes/sites/{id}` for every site to reconstruct the assignment list (`networkAssigned`). The wizard then behaves identically to create mode but patches the existing project instead of creating it.\\n\\n## Related detail components\\n\\nThe call graph shows two additional components that belong to this module's domain but are defined in `projects/[id]/` and `sites/[sid]/`:\\n\\n- **`ProjectDetailClient`** \u2014 drills into a single project; uses `SiteWorkforceManager` for site-level workforce/assignment management, `ProjectInviteComposer` for sending further invitations, and `formatShortDate` / `plural` for metadata.\\n- **`SiteDetailClient`** \u2014 single-site view, also composing `SiteWorkforceManager`.\\n- **`EngagementList`** (shared component) \u2014 renders engagement records with `initialsFromName` avatars and `formatDate`; used from the profile and jobs pages, which is where the \\\"Engagements\\\" part of this module surfaces outside the project context.\\n\\n## Shared infrastructure\\n\\n| Utility | Role |\\n|---|---|\\n| `useApi` | Client-side data hook. Accepts `{ initialData }` to hydrate from the server payload, then revalidates/fetches; exposes `{ data, loading, error }`. Used by `DashboardClient`, `ProjectsClient`, `SiteAssignments`, and `ProjectInviteComposer`. |\\n| `requirePortal` / `loadAppPage` / `loadAppPageOptional` | Server-side gate and data-loading helpers. `Optional` variants return `null` instead of throwing, letting pages render empty states. |\\n| `useSession` / `canSendRequests` | Permission checks: `tier1_admin` can create projects; `canSendRequests` gates invite creation. |\\n| `SiteMapBoardLazy` | Lazy-loaded map board shared by the dashboard preview, the projects map view, and the project detail page. Accepts `MapPlace[]`, a `selectedId`, an `onSelect` callback, and `mode`/`embedded` props. |\\n| `project-wizard-drafts` | LocalStorage draft store for the wizard (index, upsert, read, discard, legacy migration via `migrateLegacyProjectDraft`). |\\n| `geo` / `format` | `geocodeQueryForSite`, `formatSiteAddress`, `formatShortDate`, `plural`, `formatAbn` used across list rows, map pins, and metadata. |\\n| `PerfProfiler` | Wraps dashboard rendering for performance instrumentation. |\\n\\n## API surface\\n\\nEndpoints consumed by this module:\\n\\n| Endpoint | Method | Used by |\\n|---|---|---|\\n| `/reporting/dashboard` | GET | `DashboardPage` / `DashboardClient` |\\n| `/nodes/projects` | GET, POST | Projects list, wizard create |\\n| `/nodes/projects/{id}` | GET, PATCH | Wizard edit mode, detail page |\\n| `/nodes/sites` | GET | Site data for dashboard map/list |\\n| `/nodes/projects/{id}/sites` | POST | Wizard location persistence |\\n| `/nodes/sites/{id}` | GET | Wizard edit mode site detail |\\n| `/nodes/sites/{id}/assign` | POST | Confirm step: assign network parties |\\n| `/requests/project-invite-options` | GET | Step 2 requirement catalogue |\\n| `/requests/project-invite` | POST | Confirm step: send invitations |\\n| `/directory?pageSize=50&amp;q=...` | GET | Step 3 contractor search |\\n\\n## Key behavioural notes\\n\\n- **\\\"Blocked\\\" never appears as a UI colour.** It is merged into `action_required` for both metrics (`readinessMetric`) and legend display, so the visual language stays at three states: green / amber / red.\\n- **Readiness and party-status are the same axis.** The dashboard translates between them (`partyStatusFromReadiness`, `readinessFromPartyStatus`), so clicking a verified segment on the contractor gauge also filters the project readiness list, and vice versa.\\n- **All list panels cap their output** (8 projects, 6 companies, 8 trades, 8 evidence items) with `slice(0, N)`; the \\\"View all\\\" links escape to the full pages.\\n- **Evidence date formatting is hard-coded** via `MONTH_SHORT` + `dayParts` because Node ICU and browsers disagree on `en-AU` short month names \u2014 a deliberate consistency hack.\\n- **Drafts intentionally exclude phone numbers** (`invitationList.map(({ phone: _p, ...rest }) =&gt; rest)`) \u2014 phone is treated as sensitive and never persisted.\",\"projects-sites-engagements\":\"# Projects, Sites &amp; Engagements\\n\\n# Projects, Sites &amp; Engagements\\n\\nThe Projects, Sites &amp; Engagements module is the operational core of Assureworx: it models the hierarchy of **projects \u2192 sites \u2192 engagements**, tracks compliance readiness for every placement, and gives principals and admins the UI to manage that lifecycle.\\n\\nThe module is split into two sub-modules:\\n\\n- [Backend services](backend-src.md) \u2014 the geocoding and engagement-readiness engine.\\n- [Frontend control surface](frontend-src.md) \u2014 the portal pages, wizards, dashboards, and client components that consume those services.\\n\\n## How the sub-modules fit together\\n\\nThe backend side is not a standalone API; it exposes logic that the frontend relies on directly:\\n\\n- **`geocode.ts`** turns free-text site addresses into map coordinates. The frontend calls this when creating a project or adding a site, then stores the coordinates for map-based views.\\n- **`requirements.ts`** is the engagement-readiness engine. It derives the effective requirement set for a trade-on-site placement, computes the placement's readiness state, and persists/audits state transitions on `t_link`. The frontend surfaces this as the status badges, progress bars, and readiness totals shown on dashboards, project boards, and site pages.\\n\\nThe frontend sub-module is organized in layers:\\n\\n- **Server components** (e.g. `projects/page.tsx`, `sites/[sid]/page.tsx`) enforce portal access and preload data over the REST API.\\n- **Client components** hydrate the interactive views: `DashboardClient`, `ProjectsClient`, `CreateProjectWizard`, `ProjectDetailClient`, `SiteWorkforceManager`, and `SiteMapBoard`.\\n- **Shared frontend utilities** (draft persistence, geo helpers, evidence filtering) support those components.\\n\\nTogether, the two halves form a loop: the UI lets principals stage contractors and add sites; the backend evaluates the resulting engagements; and the UI reads that evaluation back to show who is ready, who isn't, and what evidence is missing.\\n\\n## Key workflows\\n\\n- **Create a project with sites** \u2014 `CreateProjectWizard` steps through project details, site selection, and contractor staging. It persists drafts locally, resolves new site addresses through the geocoding service, and saves everything in one flow.\\n- **Readiness dashboard** \u2014 `DashboardClient` aggregates placements from the requirements service, computes readiness percentages per company/site, and lets users filter by evidence buckets or party readiness status.\\n- **Project drill-down** \u2014 `ProjectDetailClient` manages site additions and the `ProjectInviteComposer` sends onboarding invitations, with tag toggling shared across the wizard and composer.\\n- **Site operations** \u2014 `SiteDetailClient` drives the `SiteWorkforceManager` for staffing, while `SiteMapBoard` displays assignments on a map using geocoded coordinates \u2014 backed by the same `geo.ts` utilities.\\n\\n## Architecture overview\\n\\n```mermaid\\nflowchart LR\\n    subgraph Frontend\\n        W[CreateProjectWizard] --&gt; G[geocode.ts API]\\n        D[DashboardClient] --&gt; R[requirements.ts API]\\n        P[ProjectDetailClient] --&gt; R\\n        S[SiteDetailClient / SiteMapBoard] --&gt; G\\n    end\\n    subgraph Backend\\n        G --&gt; Geocode[geocode.ts service]\\n        R --&gt; Req[requirements.ts service]\\n    end\\n    Req --&gt; DB[(t_link state)]\\n    G --&gt; Map[(Coordinates)]\\n```\\n\\nThe frontend pages never touch the readiness logic directly \u2014 they call the backend services, which own the state transitions and audit trail. The geocoding service is a lighter dependency, but it is what makes the site-map and location-based views possible.\",\"projects-sites-requests-backend-src\":\"# Projects, Sites &amp; Requests \u2014 backend-src\\n\\n# Projects, Sites &amp; Requests \u2014 Backend Module\\n\\n## Overview\\n\\nThis module is the operational hub for the physical side of the platform: it manages the projects and sites a principal contractor runs, the trade parties assigned to those sites, and the invitation workflow that brings new trades onto the platform. It also owns the geocoding path that turns site addresses into map pins for portfolio boards.\\n\\nThe module spans three files:\\n\\n- **`src/modules/nodes/index.ts`** \u2014 exports `nodesRouter`: project CRUD, site CRUD, site assignment/unassignment, and the batch geocoding endpoint.\\n- **`src/modules/requests/index.ts`** \u2014 exports `requestsRouter`: onboarding/connection requests, project-originated bulk invites, verification templates, and resend/decline/revoke actions.\\n- **`src/shared/services/geocode.ts`** \u2014 rate-limited, cached Nominatim lookup shared by the batch geocoding endpoint, site create/update, and the background job queue.\\n\\nBoth routers authenticate through `requireAuth` + `resolveTenantContext`. The nodes router additionally requires `tier1_admin` or `tier1_user` at the router level; the requests router applies role checks per route.\\n\\n## Data Model\\n\\nProjects, sites, assignments, and requests map to a small set of core tables (referenced via the `T` schema wrapper from `shared/schema/tables.ts`):\\n\\n```mermaid\\ngraph LR\\n    Project[\\\"Project(node, kind_cd N01)\\\"]\\n    Site[\\\"Site(node, kind_cd N02)\\\"]\\n    Party[\\\"Party(trade company / individual)\\\"]\\n    Link[\\\"Link(assignment)\\\"]\\n    Request[\\\"Request(invite / connection)\\\"]\\n    ReqSet[\\\"ReqSet + ReqItems(engagement requirements)\\\"]\\n    Tag[\\\"Tag(trade classification)\\\"]\\n\\n    Project --&gt;|\\\"parent_node_id\\\"| Site\\n    Site --&gt;|\\\"node_id\\\"| Link\\n    Party --&gt;|\\\"party_id\\\"| Link\\n    Request --&gt;|\\\"target_party_id\\\"| Party\\n    Request --&gt;|\\\"node_id\\\"| Site\\n    ReqSet --&gt;|\\\"node_id\\\"| Site\\n    ReqSet --&gt;|\\\"applies_tag_cd\\\"| Tag\\n```\\n\\n- **Projects and sites are both rows in `T.node`**, distinguished by `kind_cd` (`N01` project, `N02` site). A site's `parent_node_id` points at its project; both are scoped by `owner_party_id`.\\n- **Assignments are rows in `T.link`**, connecting a site (`node_id`) to a party. The link's `state_cd` is the readiness state of that placement.\\n- **Requests are rows in `T.request`** \u2014 an onboarding invite for a new identity, a connection request for an existing one, or a sub-request from a trade org that inherits the principal's verification template.\\n- **Requirement sets** (`T.reqSet` / `T.reqItem`) are attached to a node by the project-invite flow. Items are either company-level (`applies_tag_cd IS NULL`) or trade-scoped (reference a `T.tag` code), and all currently use `block_cd 'B01'`.\\n\\n## Projects &amp; Sites \u2014 `nodesRouter`\\n\\nAll routes behind `requireAuth` \u2192 `resolveTenantContext` \u2192 `requireRole(\\\"tier1_admin\\\", \\\"tier1_user\\\")`. Mutations also require `tier1_admin`.\\n\\n### Endpoints\\n\\n| Method &amp; path | Access | Purpose |\\n|---|---|---|\\n| `GET /health` | public | Liveness probe; returns `{ module: \\\"nodes\\\", status: \\\"wired\\\" }`. |\\n| `GET /projects` | tier1_admin, tier1_user | List projects with site count, distinct assigned trades, and readiness buckets. |\\n| `POST /projects` | tier1_admin + `mutationLimiter` | Create a project. |\\n| `PATCH /projects/:id` | tier1_admin + `mutationLimiter` | Update a project. Uses the same `createProjectSchema` as POST \u2014 **`name` is required**; there is no partial-update schema. |\\n| `GET /projects/:id` | tier1_admin, tier1_user | Project detail (`ProjectDetail`) with per-site `SiteSummary` rows. |\\n| `POST /projects/:id/sites` | tier1_admin + `mutationLimiter` | Create a site under the project; enqueues geocoding when the derived address query is non-trivial. |\\n| `PATCH /sites/:id` | tier1_admin + `mutationLimiter` | Update a site; clears `attrs.geo` and conditionally re-enqueues geocoding. |\\n| `GET /sites` | tier1_admin, tier1_user | Portfolio board \u2014 all tenant sites with project context and assignment counts (`LIMIT 500`). |\\n| `GET /sites/:id` | tier1_admin, tier1_user | Full `SiteDetail` including assignments, party status, and classifications. |\\n| `POST /geocode` | tier1_admin, tier1_user + `geocodeLimiter` | Batch geocode up to 40 places with a bounded synchronous fan-out. |\\n| `POST /sites/:id/assign` | tier1_admin + `mutationLimiter` | Assign a party to a site with network, status, and evidence hard-block checks. |\\n| `DELETE /links/:id` | tier1_admin | Soft-delete an assignment (audited as `trade.unassigned`). |\\n\\n### Read models &amp; aggregation\\n\\n**`projectRows(tx, scope, projectId?)`** is the shared project read model. It loads node rows plus:\\n\\n- `site_count`, computed by a correlated subquery over live child nodes.\\n- Assignment aggregates from `T.link`. The link query maps every link up to its project with `COALESCE(sn.parent_node_id, l.node_id, sn.id)`, so links attached directly to the project node **or** to any site under it roll into the same project bucket. The aggregate tracks a distinct set of assigned parties and a readiness counter keyed through `LINK_STATE[state_cd]` into the shape `{ ready, pending, blocked, action_required }` (the `emptyReadiness()` initial value).\\n\\n**`siteSummaries(tx, scope, projectId)`** returns per-site rows for a project with `assigned`, `ready` (`state_cd 'J02'`), and `blocked` (`state_cd 'J03'`) link counts, plus coordinates extracted from the `attrs` JSONB column by **`geoFromAttrs`**.\\n\\n**`siteDetail(tx, scope, siteId)`** builds the full `SiteDetail`: site fields, the parent project's name, and `assignments` \u2014 one `SiteAssignment` per active link, joined to the party for name/kind/photo/status, with tag names resolved through `T.partyTag` and `TAG_NAMES`.\\n\\n### Project &amp; site CRUD notes\\n\\n- Date fields use **`calendarDateSchema`**: strict `YYYY-MM-DD`, validated as a real calendar date, with a `superRefine` that rejects `endsOn &lt; startsOn`.\\n- Creating a site computes a geocode query from `[address, locality, region, postcode]` (trimmed, non-empty parts joined with `\\\", \\\"`). If the result is at least 2 characters, the handler calls `enqueueSiteGeocode({ siteId, scopeOrgId, query })` after the transaction.\\n- Updating a site **always removes `attrs.geo`** (`attrs = COALESCE(attrs, '{}'::jsonb) - 'geo'`). The re-geocode decision is based only on the submitted body. A patch that only changes the name, for example, clears the cached coordinates without scheduling a re-geocode.\\n\\n### Site assignment flow\\n\\n`POST /sites/:id/assign` runs inside a `withScope` transaction and enforces, in order:\\n\\n1. **Site ownership** \u2014 the site must exist, be `kind_cd 'N02'`, and belong to `ctx.scopeOrgId`.\\n2. **Network membership** \u2014 `isInNetwork(tx, scopeOrgId, partyId)`; failures return `NOT_IN_NETWORK`.\\n3. **Duplicate check** \u2014 an active link for the same site/party returns `ALREADY_ASSIGNED`.\\n4. **Party status** \u2014 a suspended party (`status_cd 'S04'`) is a hard block: `PARTY_SUSPENDED`.\\n5. **Evidence hard block** \u2014 `getEvidenceForParty` + `loadArtifactClasses` find required artifact classes whose `blockCd === 'B01'` are `missing`, `expired`, or `rejected`. Any match raises `HARD_BLOCK` and names the missing documents. There is no override path yet.\\n6. **Insert the link** with initial readiness `state_cd = READINESS_BY_STATUS[party.status_cd]` (fallback `'J01'`).\\n7. **Engagement tags** \u2014 `tagCodes` from the request body are copied into `T.linkTag` (`ON CONFLICT DO NOTHING`). These define what the principal engaged the party for; requirement evaluation treats them as the audience for trade-scoped requirements (ADR-0018).\\n8. **Audit** \u2014 `trade.assigned` with the party name, initial readiness, and engagement tags.\\n9. **Evaluate** \u2014 `evaluateLinks(tx, [linkId], ctx.actorId)` runs immediately after insert so the board reflects the readiness state without waiting for a background pass.\\n\\nUnassignment (`DELETE /links/:id`) soft-deletes the link (`deleted_at = NOW()`) and audits `trade.unassigned`.\\n\\n### Batch geocoding\\n\\n`POST /geocode` accepts up to 40 `{ id, query }` pairs and returns `GeocodePlaceResult[]`. The flow is deliberately bounded so a map board first-paint never serialises dozens of external HTTP calls:\\n\\n```mermaid\\nflowchart TD\\n    A[\\\"POST /geocode (\u2264 40 places)\\\"] --&gt; B{\\\"DB has cached geofor normalized query?\\\"}\\n    B --&gt;|yes| C[\\\"Return stored coords\\\"]\\n    B --&gt;|no| D{\\\"miss index &lt; SYNC_CAP (3)?\\\"}\\n    D --&gt;|yes| E[\\\"geocodePlace()Nominatim + fallbacks\\\"]\\n    D --&gt;|no| F[\\\"Enqueue background job\\\"]\\n    E --&gt; G{\\\"hit?\\\"}\\n    G --&gt;|yes| H[\\\"Persist geo to attrs\\\"]\\n    H --&gt; J[\\\"Return coords\\\"]\\n    G --&gt;|no| I[\\\"Return null coords\\\"]\\n    F --&gt; I\\n    C --&gt; J\\n    I --&gt; J\\n```\\n\\nDetails worth knowing:\\n\\n- The first DB round-trip reads `attrs.geo` for any ids matching `NODE_ID_RE` (UUID pattern). A stored geo is reused **only** when `geo.query` equals the current query normalized by `normalizeGeoQuery` (lowercase, collapsed whitespace).\\n- The first 3 misses go through `geocodePlace` synchronously (`SYNC_CAP = 3`). Remaining misses are handed to `enqueueSiteGeocode` for background processing and get a placeholder `{ lat: null, lng: null, label: null }` in the response.\\n- Sync hits are persisted into the node's `attrs` JSONB as `{ lat, lng, label, query }` (query stored in normalized form), so subsequent renders hit the DB path.\\n- Client ids that don't match `NODE_ID_RE` are treated as ephemeral: they receive results but never trigger DB writes or background jobs.\\n\\nSite create and update use the same persistence path via `enqueueSiteGeocode`, so `geocodePlace` is also the entry point for the background job handler (`registerJobHandlers`).\\n\\n## Requests &amp; Invitations \u2014 `requestsRouter`\\n\\nThe requests router applies `requireAuth` + `resolveTenantContext`; role checks are per-route.\\n\\n### Endpoints\\n\\n| Method &amp; path | Access | Purpose |\\n|---|---|---|\\n| `GET /health` | public | Liveness probe. |\\n| `GET /` | tier1_admin, tier1_user, trade_company_admin, trade_individual | Paginated request list (default page 1 / pageSize 100, max 200). |\\n| `POST /abn-lookup` | any authenticated + `abrLookupLimiter` | ABN lookup via the shared `abnVerifier`, with the shared `query` adapter \u2014 same verifier as onboarding. |\\n| `GET /verification-template` | tier1_admin, trade_company_admin, platform_admin | Read the org's verification template. |\\n| `PUT /verification-template` | same | Save the template; unknown keys \u2192 400 `UNKNOWN_VERIFICATION_REQUIREMENT`. |\\n| `POST /` | tier1_admin (direct kinds); tier1_admin / trade_company_admin (`sub_request`) + `inviteLimiter` | Create a single request/invite. |\\n| `GET /project-invite-options` | tier1_admin, tier1_user | Composer catalogue: company verification keys, default keys, trade type classifications. |\\n| `POST /project-invite` | tier1_admin + `inviteLimiter` | Bulk invite (1\u201350 recipients) plus upsert of a project requirement set. |\\n| `POST /:id/resend` | sender org + `inviteResendLimiter` | Mint a new token, extend expiry to 14 days, re-deliver email + SMS. |\\n| `POST /:id/decline` | sender org | Mark a pending request as declined. |\\n| `DELETE /:id` | sender org | Soft-delete (revoke) a request. |\\n\\n### Request rows, tokens, and visibility\\n\\n`REQUEST_SELECT` is the shared SQL fragment that joins `T.request` to the origin party, root party, and optional target node. **`toRow`** maps a DB row to the API `RequestRow`:\\n\\n- `kind` and `status` map through `REQUEST_KIND` / `REQUEST_STATUS`.\\n- `targetName` comes from the `note_txt` column, which stores the recipient's display name. (The optional `note` field in the create schema is accepted but not persisted.)\\n- The invite `token` and derived `inviteUrl` are emitted **only for pending requests**. The URL is `${config.publicAppUrl}/verify/i/${token}` (`INVITE_PATH`), pointing at the mobile verification journey.\\n\\nThe list endpoint branches on role:\\n\\n- **tier1_admin / tier1_user** query with `withScope(ctx.scopeOrgId)` and filter `origin_party_id = $1 OR root_party_id = $1`.\\n- **trade_company_admin / trade_individual** query with `withBypass(ctx.orgId)` and filter `origin_party_id = $1 OR target_party_id = $1`. The trade org is the target of inbound requests, not origin/root, so RLS would hide them; repository-level filtering is the enforcement.\\n- Any other role gets `403`.\\n\\nAfter mapping, **`canSeeInviteToken(role, originPartyId, orgId)`** strips `token`/`inviteUrl` unless the caller belongs to the origin org and holds one of `tier1_admin`, `trade_company_admin`, `trade_individual`, or `platform_admin`. In particular, `tier1_user` never sees live invite tokens.\\n\\n### Creating a single request\\n\\n`POST /` accepts `kind: \\\"direct_company\\\" | \\\"direct_individual\\\" | \\\"sub_request\\\"`, plus email, optional name/ABN/phone, and either `nodeId` or `verificationRequirementKeys`. The create flow runs in a `withScope` transaction:\\n\\n1. **Identity portability (ADR-0012)** \u2014 `resolveIdentity(tx, { email, abn })` checks whether the recipient already has a profile. If so, `kind_cd` becomes `\\\"Q04\\\"` (connection request) instead of the onboarding kind, and the recipient gets a one-step connect rather than a wizard. The audit delta records `recognisedAs` and `matchedOn`.\\n2. **Verification keys** \u2014 keys come from the body (validated via `validateVerificationKeys`) or fall back to the sender's saved template (`getVerificationTemplate`). For **sub-requests**, the principal's template is unioned in: `[...new Set([...pcKeys, ...senderKeys])]` \u2014 a sub-request can never drop below the principal's verification floor.\\n3. **Duplicate guards** \u2014 an active network relation (`T.rel`, kinds `L02`/`L03`, status `RS1`) rejects with `ALREADY_IN_NETWORK`; an existing pending request to the same email rejects with `ALREADY_INVITED`.\\n4. **Sub-request chain resolution** \u2014 for `sub_request` from a non-principal org, root and parent are resolved with `withBypass` before the main transaction: the root is the `to_party_id` of the org's active `L02` relation; the parent request is the earliest completed request targeting the org (`status_cd 'U03'`). The root becomes the new request's `root_party_id`.\\n5. **Token + insert** \u2014 a 32-byte random hex token with `expires_at = NOW() + INTERVAL '14 days'`. Phone numbers are normalized to E.164 via `normalisePhoneToE164`; invalid numbers raise `INVALID_PHONE`.\\n6. **Internal message** \u2014 a `T.msg` row records the send; subject/body differ depending on whether the recipient was already known.\\n7. **Audit** \u2014 `request.sent` with kind, verification keys, and match details.\\n\\nAfter the transaction, delivery is **email-first with best-effort SMS**:\\n\\n- `emailTradeInvite` builds bodies through `inviteEmailBodies`, queues via `queueEmail` with an idempotency key (`emailInviteIdemKey(\\\"request\\\", requestId, token)`), and returns whether the email was accepted.\\n- `smsTradeInvite` stages an SMS via `stageInviteSms` (under `withBypass`), pumps it through `pumpSms`, and swallows all errors \u2014 SMS failure never blocks or fails the invite.\\n\\n### Project bulk invites\\n\\n`POST /project-invite` is the project-originated onboarding path. It takes a `nodeId`, up to 50 recipients, company requirement keys, and optional trade bundles (`tagCode` + artifact class codes, max 17 bundles). Validation ensures all artifact classes and tag codes exist and are active.\\n\\n**Phase 1 \u2014 requirement set upsert** (one transaction):\\n\\n- `assertOwnedNode` verifies the node belongs to the tenant.\\n- A `reqSet` named `\\\"Project onboarding requirements\\\"` (`PROJECT_REQ_SET_NAME`) is looked up for the node and either reused or inserted.\\n- Reuse soft-deletes the previous `reqItem`s and re-creates them:\\n  - **Company-level items** \u2014 `kind_cd 'X01'`, `class_cd` from `artifact:`-prefixed verification keys, `applies_tag_cd = NULL`, `block_cd 'B01'`.\\n  - **Trade-scoped items** \u2014 same shape but `applies_tag_cd` set to the bundle's tag code, so the item only applies to placements engaged for that trade.\\n- Audited as `project_invite.requirements_set`.\\n\\n**Phase 2 \u2014 per-recipient invites**:\\n\\nEach recipient is processed in its own `withScope` transaction, mirroring the single-request flow: identity resolution (\u2192 `Q04` connection request if recognised), network and pending-duplicate guards, token insert (14-day expiry), node binding, and `request.sent` audit. Failures are captured per recipient (`{ email, ok: false, error }`) rather than aborting the batch. The response is a `CreateProjectInviteResult` with `nodeId`, `reqSetId`, `sent`, `failed`, and per-recipient `results`.\\n\\n### Verification templates\\n\\n`GET/PUT /verification-template` manage the org's saved requirement set. Access is gated by `canManageVerificationTemplate` (`tier1_admin`, `trade_company_admin`, or `platform_admin`). The PUT path validates keys via `saveVerificationTemplate`, converts its `\\\"Unknown verification requirement: \u2026\\\"` error into a 400, and audits `verification_template.updated`.\\n\\n### Resend, decline, revoke\\n\\n- **`POST /:id/resend`** \u2014 only pending (`U01`) requests. `isInviteResendCoolingDown` blocks double-click spam with `RESEND_COOLDOWN` without minting a new token. Otherwise it mints a new token, extends expiry to 14 days, re-emails, and re-stages SMS.\\n- **`POST /:id/decline`** \u2014 pending \u2192 `U05`, audited.\\n- **`DELETE /:id`** \u2014 soft-delete (revoke), audited.\\n\\nAll three verify the caller is the request's origin org via `loadRequestForSender`.\\n\\n## Geocoding Service \u2014 `shared/services/geocode.ts`\\n\\n`geocodePlace(query)` is the single entry point for both the sync batch path and the background site-geocode job. It is a defensive wrapper over Nominatim:\\n\\n- **Cache** \u2014 an in-process `Map` keyed by `normalizeQuery` (lowercase, collapsed whitespace). Negative results are cached too, so repeated bad addresses don't hit the network.\\n- **Throttle** \u2014 `MIN_GAP_MS = 1100` enforces Nominatim's ~1 req/sec policy; `throttle()` sleeps the remainder before each request.\\n- **Fallback chain** \u2014 if the full query misses, it retries with the tail after the first comma (`parts.slice(1).join(\\\", \\\")`, i.e. suburb/state/postcode), then a bare 4-digit postcode matched from the query. All lookups append `\\\", Australia\\\"` and set `countrycodes=au`.\\n- **Safety** \u2014 queries shorter than 2 characters are rejected; non-finite lat/lng results are treated as misses; all exceptions resolve to a cached `null`.\\n\\n`buildSiteQuery(parts)` composes the query string from address/locality/region/postcode for callers that need it, returning `null` when no parts are present.\\n\\n## Integration Points\\n\\nThe module relies on shared infrastructure rather than implementing its own:\\n\\n- **DB access** \u2014 `withScope(scope, fn)` for tenant-scoped transactions (RLS on `owner_party_id`/scope), `withBypass` for cross-tenant reads that need explicit repository filters, and `query` for unscoped reads (used by the ABN lookup adapter).\\n- **Middleware** \u2014 `requireAuth`, `resolveTenantContext` (populates `req.ctx` with `scopeOrgId`, `orgId`, `orgKind`, `actorId`, `role`), `requireRole`, and the per-route rate limiters (`mutationLimiter`, `geocodeLimiter`, `inviteLimiter`, `inviteResendLimiter`, `abrLookupLimiter`).\\n- **HTTP conventions** \u2014 `h` wraps async handlers for error propagation; `ok`/`created` produce the standard envelope; `badRequest(code, message)`, `notFound`, `forbidden`, and `HttpError` carry API error semantics.\\n- **Shared codes** \u2014 `LINK_STATE`, `PARTY_KIND`, `PARTY_STATUS`, `TAG_NAMES`, `REQUEST_KIND`, `REQUEST_KIND_CD`, `REQUEST_STATUS`, and `ARTIFACT_CLASS_NAMES` map DB codes to display values.\\n- **Shared services** \u2014 `audit`, `evaluateLinks`, `getEvidenceForParty`, `loadArtifactClasses`, `isInNetwork`, `resolveIdentity`, `getVerificationTemplate` / `saveVerificationTemplate` / `validateVerificationKeys`, `queueEmail` + `inviteEmailBodies`, `stageInviteSms` + `pumpSms` + `normalisePhoneToE164`, `abnVerifier`, `config`, `isInviteResendCoolingDown`, `emailInviteIdemKey`, `iso` / `isoDate`.\\n- **Background jobs** \u2014 `enqueueSiteGeocode` pushes geocode work onto the job queue when the sync cap is exceeded or when a site's address changes. The job handler (`registerJobHandlers`) calls back into `geocodePlace`.\\n- **Shared types** \u2014 all payload shapes come from `@assureworx/shared-types` (`ProjectRow`, `ProjectDetail`, `SiteSummary`, `SiteDetail`, `SiteAssignment`, `PortfolioSite`, `GeocodePlaceResult`, `RequestRow`, `ProjectInviteOptions`, `ProjectInviteResultItem`, `CreateProjectInviteResult`, `TradeClassification`, `VerificationTemplate`, `VerifiedStatus`).\",\"projects-sites-requests-frontend-public\":\"# Projects, Sites &amp; Requests \u2014 frontend-public\\n\\n# Projects, Sites &amp; Requests \u2014 frontend-public: Leaflet Map Stylesheet\\n\\n## Overview\\n\\n`frontend/public/leaflet/leaflet.css` is a static stylesheet vendored from the [Leaflet](https://leafletjs.com/) mapping library. It defines the visual and interaction styling for every Leaflet map element used by the public-facing frontend: the map container, tile layers, overlay panes, markers, popups, tooltips, and all map controls (zoom, layers, attribution, scale).\\n\\nThis file contains no application-specific rules. It is the stock Leaflet stylesheet, checked into the repository so the frontend does not depend on a CDN at runtime. It exists to support the module's interactive geospatial views \u2014 displaying projects, sites, and requests as mapped data.\\n\\n## Role in the Module\\n\\nThe **Projects, Sites &amp; Requests** module presents geographic entities on an interactive map. The JavaScript code in the module creates Leaflet map instances and adds markers, overlay layers, and popups. For those elements to render correctly, the Leaflet DOM structure depends on the CSS rules in this file:\\n\\n- **Markers** for project and site locations use `.leaflet-marker-icon` and `.leaflet-shadow-pane` classes.\\n- **Popups** showing request or site details use `.leaflet-popup`, `.leaflet-popup-content-wrapper`, and `.leaflet-popup-tip`.\\n- **Overlay layers** (e.g., site boundaries) use `.leaflet-overlay-pane` and `.leaflet-interactive`.\\n- **Controls** (zoom, layer switcher) use `.leaflet-bar`, `.leaflet-control-zoom-in/out`, and `.leaflet-control-layers`.\\n\\nWithout this file, the map would render unstyled tiles with mispositioned markers and unusable controls.\\n\\n## How It Works: The Pane Layering Model\\n\\nLeaflet organizes map elements into a fixed set of stacked panes. The CSS assigns each pane a `z-index`, which establishes the rendering order of all map content:\\n\\n```mermaid\\ngraph TD\\n    A[.leaflet-map-pane] --&gt; B[.leaflet-tile-panez-index: 200]\\n    A --&gt; C[.leaflet-overlay-panez-index: 400]\\n    A --&gt; D[.leaflet-shadow-panez-index: 500]\\n    A --&gt; E[.leaflet-marker-panez-index: 600]\\n    A --&gt; F[.leaflet-tooltip-panez-index: 650]\\n    A --&gt; G[.leaflet-popup-panez-index: 700]\\n    A --&gt; H[.leaflet-zoom-boxz-index: 800]\\n```\\n\\nTiles render at the bottom, followed by vector overlays (site boundaries, drawn shapes), then marker shadows, markers, tooltips, and finally popups. The zoom box and controls sit above all map content. This ordering is enforced by the `z-index` declarations in the `.leaflet-pane` and `.leaflet-control` rules.\\n\\nThe `position: absolute` rule applied to `.leaflet-pane`, `.leaflet-tile`, `.leaflet-marker-icon`, and related classes is what makes Leaflet's coordinate-based positioning work: every pane is anchored to the map container's top-left origin, and Leaflet's JavaScript translates individual elements within each pane.\\n\\n## File Structure\\n\\nThe stylesheet is organized into logical sections, each addressing a distinct concern:\\n\\n| Section | Rules |\\n|---|---|\\n| Required styles | Positioning, user-select, `max-width` resets for tiles/images |\\n| Pane z-indexing | `.leaflet-pane`, `.leaflet-tile-pane`, `.leaflet-overlay-pane`, etc. |\\n| Control positioning | `.leaflet-top`, `.leaflet-bottom`, `.leaflet-left`, `.leaflet-right` |\\n| Animations | Fade, zoom, and pan transitions (`.leaflet-fade-anim`, `.leaflet-zoom-anim`) |\\n| Cursors | Grab, crosshair, and dragging states (`.leaflet-grab`, `.leaflet-dragging`) |\\n| Markers &amp; overlays | Pointer-events rules for interactivity |\\n| Toolbar &amp; controls | `.leaflet-bar`, zoom buttons, layers control |\\n| Popups | `.leaflet-popup`, content wrapper, tip arrow, close button |\\n| Tooltips | `.leaflet-tooltip` and directional variants |\\n| Print | `@media print` rules for control visibility |\\n\\n## Key Components\\n\\n### Map Container\\n\\n`.leaflet-container` is the root element of every map. The stylesheet sets its `overflow: hidden`, background color (`#ddd`), font family, and touch behavior via `touch-action` properties. It also resets `max-width`/`max-height` on all child images, which prevents the map from breaking when the host page uses global `img { max-width: 100% }` rules.\\n\\n### Tile Layer\\n\\n`.leaflet-tile` elements are hidden by default and shown only when loaded (`.leaflet-tile-loaded`), preventing visual \\\"pop-in\\\" of partially loaded tiles. The file also includes workarounds for Safari retina rendering and a `mix-blend-mode: plus-lighter` rule that addresses a Chrome tile-bleeding bug.\\n\\n### Markers and Overlays\\n\\nMarkers are rendered inside `.leaflet-marker-pane` and their shadows in `.leaflet-shadow-pane`. By default, marker icons and overlay paths have `pointer-events: none`, so they do not intercept map drag gestures. When a marker or path has the `.leaflet-interactive` class, the stylesheet re-enables pointer events, making it clickable/hoverable \u2014 this is how the module attaches click handlers to project and site markers.\\n\\n### Map Controls\\n\\nControls are positioned by a combination of corner containers (`.leaflet-top`, `.leaflet-bottom`, `.leaflet-left`, `.leaflet-right`) and the `.leaflet-control` class. The zoom control (`.leaflet-bar` with `.leaflet-control-zoom-in`/`.leaflet-control-zoom-out`) and the layers control (`.leaflet-control-layers`) are styled here, including touch-device sizing variants. The attribution control gets a semi-transparent white background so it remains legible over map tiles.\\n\\n### Popups\\n\\nPopup structure consists of `.leaflet-popup`, `.leaflet-popup-content-wrapper`, `.leaflet-popup-content`, and `.leaflet-popup-tip` (the arrow pointing at the marker). The tip is rotated 45 degrees, and the wrapper provides the rounded-corner card background. Popups are the primary mechanism for showing project/site/request detail summaries in the module's map views.\\n\\n### Tooltips\\n\\n`.leaflet-tooltip` provides lightweight hover labels with directional variants (`.leaflet-tooltip-top`, `.leaflet-tooltip-bottom`, `.leaflet-tooltip-left`, `.leaflet-tooltip-right`), each with a CSS-only arrow constructed from borders. Tooltips are distinct from popups: they have no close button and are intended for ephemeral hover information.\\n\\n## Integration with the Codebase\\n\\nThis stylesheet is loaded as a static asset in the public frontend, typically referenced alongside the Leaflet JavaScript bundle. The module's map-rendering code depends on these class names being present and styled; the CSS contract is:\\n\\n- Any element with `.leaflet-container` becomes the map viewport.\\n- Child elements created by Leaflet's JavaScript (`L.marker`, `L.popup`, `L.tileLayer`, `L.geoJSON`, etc.) receive the classes defined in this file.\\n- Application code should not override the core positioning or `z-index` rules of this file, as doing so will break the pane ordering and interactivity.\\n\\nThe file has no runtime dependencies: it makes no network requests, defines no JavaScript, and is safe to cache aggressively. It does reference two image assets \u2014 `images/layers.png` and `images/layers-2x.png` \u2014 for the layers control toggle, plus `images/marker-icon.png` as a fallback path hint for default markers. Deployments must include the `images/` directory alongside the CSS file.\\n\\n## Customization\\n\\nThe module does not currently override Leaflet's default theme, but any future theming should target the following rules:\\n\\n- **Map background** \u2014 `.leaflet-container { background: #ddd; }` (visible before tiles load)\\n- **Control chrome** \u2014 `.leaflet-bar`, `.leaflet-control-layers`, including border-radius, box-shadow, and button dimensions\\n- **Popup appearance** \u2014 `.leaflet-popup-content-wrapper`, `.leaflet-popup-tip`, `.leaflet-popup-close-button`\\n- **Tooltip appearance** \u2014 `.leaflet-tooltip` and its directional arrow rules\\n- **Interactive element styling** \u2014 `.leaflet-interactive` (applies to vector overlays such as site boundary polygons)\\n\\nBecause the stylesheet is vendored stock Leaflet, upgrades to Leaflet should replace this file with the matched version's stylesheet. The `leaflet.css` version must align with the Leaflet JavaScript version in use; mismatched versions can cause missing styles for newly added classes.\\n\\n## Browser Compatibility Notes\\n\\nThe stylesheet includes several deliberate compatibility rules worth preserving:\\n\\n- `-ms-touch-action` and `touch-action` variants for Internet Explorer 11 and Edge legacy\\n- `-webkit-` prefixed `transform` and `transition` rules for older Safari\\n- A `@media print` block ensuring control backgrounds are printed (`print-color-adjust: exact`)\\n- The `.leaflet-oldie` rules for IE &lt; 10 (VML shapes, popup tip workaround) are vestigial for modern browsers but harmless\\n\\nRemoving these rules during \\\"cleanup\\\" is not recommended; they are the library's supported compatibility layer.\",\"projects-sites-requests-frontend-src\":\"# Projects, Sites &amp; Requests \u2014 frontend-src\\n\\n# Projects, Sites &amp; Requests \u2014 Frontend Module\\n\\n## Overview\\n\\nThis module implements the project lifecycle in the Assureworx portal: creating and editing projects, registering sites on a map, staging contractor invitations, and sending onboarding requests. It lives under `frontend/src/app/(app)/projects/` and is composed of four client components:\\n\\n| Component | File | Responsibility |\\n|---|---|---|\\n| `ProjectsBoard` | `ProjectsClient.tsx` | Project list / site map dashboard with create &amp; edit entry points |\\n| `CreateProjectWizard` | `CreateProjectWizard.tsx` | Four-step wizard for project creation, site registration, contractor staging, and invitation dispatch |\\n| `ProjectDetailClient` | `[id]/ProjectDetailClient.tsx` | Project workspace: site CRUD, metrics, map, workforce manager, request composer trigger |\\n| `ProjectInviteComposer` | `[id]/ProjectInviteComposer.tsx` | Five-step request composer for sending onboarding invitations |\\n\\nThe module is server-rendered at the page level (initial data passed as props for hydration) and becomes fully interactive on the client via the `useApi` data hook.\\n\\n## Page Hierarchy &amp; Routing\\n\\n```mermaid\\ngraph TD\\n    PP[\\\"projects/page.tsxProjectsPage\\\"] --&gt; PC[\\\"ProjectsClient\\\"]\\n    PC --&gt; PB[\\\"ProjectsBoard\\\"]\\n    PB --&gt; CW[\\\"CreateProjectWizard\\\"]\\n    PB --&gt; SM[\\\"SiteMapBoardLazy\\\"]\\n    PD[\\\"projects/[id]/page.tsxProjectDetailPage\\\"] --&gt; DC[\\\"ProjectDetailClient\\\"]\\n    DC --&gt; IC[\\\"ProjectInviteComposer\\\"]\\n    DC --&gt; WM[\\\"SiteWorkforceManager\\\"]\\n    DC --&gt; SM2[\\\"SiteMapBoardLazy\\\"]\\n```\\n\\nThe server pages (`ProjectsPage`, `ProjectDetailPage`, and the sibling `SiteDetailPage`) call `loadAppPage` / `requirePortal` for session gating and fetch initial data before delegating to the client components.\\n\\n## ProjectsBoard \u2014 List &amp; Map Views\\n\\n`ProjectsBoard` (exported through `ProjectsClient` wrapped in `Suspense`) is the landing view for the module.\\n\\n### Data\\n\\nTwo `useApi` calls run in parallel:\\n\\n- `GET /nodes/projects` \u2192 `ProjectRow[]`\\n- `GET /nodes/sites` \u2192 `PortfolioSite[]`\\n\\nBoth accept `initialData` from the server for SSR hydration and expose `refresh()` for refetching after mutations.\\n\\n### View Modes\\n\\nThe board supports two views, toggled by `viewToggle` and persisted in `localStorage` under `awx.projects.view`:\\n\\n- **sites** \u2014 renders the project list. Each row links to `/projects/{nodeId}`, shows the ref code, site count, assigned trade count, schedule (`formatShortDate`), and a segmented readiness bar (`ready` / `pending` / `action_required` / `blocked`) with a percentage readout.\\n- **map** \u2014 derives `MapPlace[]` from the sites list (using `geocodeQueryForSite` or `formatSiteAddress` for the address, plus assignment/ready/blocked counts as detail text) and renders `SiteMapBoardLazy` in `board` mode with a configurable `selectedId`.\\n\\nThe view mode can also be controlled via the `?view=map|sites` query parameter; the parameter is consumed and stripped from the URL via `router.replace`.\\n\\n### URL-Driven Wizard Entry\\n\\nWhen `canWrite` (`me.active?.role === \\\"tier1_admin\\\"`) is true, the board responds to two query parameters:\\n\\n- `?new=1` \u2014 opens the create wizard with `editProjectId = null`\\n- `?edit=` \u2014 opens the wizard in edit mode (UUID validated with `/^[0-9a-f-]{36}$/i`)\\n\\nIn both cases the parameter is removed from the URL immediately after the wizard opens, so a page refresh doesn't re-open it.\\n\\n### Wizard Lifecycle\\n\\n- **Open create** \u2014 `openCreate()` sets `editProjectId = null`, `creating = true`\\n- **Open edit** \u2014 `openEdit(projectId)` sets `editProjectId` then `creating = true`\\n- **Close** \u2014 `closeWizard()` hides the wizard and refreshes both projects and sites\\n- **Complete** \u2014 `completeWizard(projectId)` refreshes, then `router.push(/projects/${projectId})`\\n\\nThe wizard is remounted per mode via its `key` prop (`create` vs `edit-{id}`), ensuring clean state.\\n\\n## CreateProjectWizard \u2014 Project Creation &amp; Editing\\n\\n`CreateProjectWizard` handles both **create** and **edit** modes. In edit mode it hydrates from `GET /nodes/projects/{editProjectId}`, mapping the project's sites and existing assignments into editor state.\\n\\n### Step Overview\\n\\nThe wizard is a linear stepper with four steps (`STEPS`), rendered with PNG icons, badges (done/active), and a connector line styled via CSS modules:\\n\\n| Step | Title | Purpose |\\n|---|---|---|\\n| 1 | Project details | Project metadata + location registration |\\n| 2 | Site Requirements | Onboarding scope, company requirements, trade-specific documents |\\n| 3 | Add contractors | Network directory search, invitation list, per-site assignment |\\n| 4 | Review | Summary, network assignments, invitation dispatch |\\n\\nNavigation is constrained by `goToStep`: steps beyond 1 require a persisted `projectId` (i.e., the project must exist before adding requirements or contractors).\\n\\n### Step 1 \u2014 Project Details &amp; Locations\\n\\nThe form captures `name` (required), `refCode`, `description`, `startsOn`, and `endsOn`. `projectDateError` validates `endsOn &gt;= startsOn`.\\n\\nThe locations section is conditionally rendered once the user has typed a project name, description, or location content. Each `WizardLocationDraft` has a `tempId` (from `newTempId()`), name, address, locality, region, and postcode.\\n\\nValidation rules (`validateLocation`):\\n\\n- Each named location requires a site name\\n- Suburb **or** postcode is required (\\\"so this site can appear on the map\\\")\\n- Postcodes must be exactly four digits\\n\\nThe locations UI supports one primary location plus additional locations, each removable until persisted (`loc.nodeId` set).\\n\\n**Persistence flow** \u2014 `saveProjectAndLocations`:\\n\\n1. Validates the project dates and all named locations\\n2. Creates the project (`POST /nodes/projects`) or patches it (`PATCH /nodes/projects/{id}`)\\n3. Iterates locations and calls `persistLocation` for any without a `nodeId`, which POSTs to `/nodes/projects/{ownerProjectId}/sites`\\n4. Advances to step 2 with a toast\\n\\nEditing an existing site is not done here \u2014 the wizard treats edit-mode sites as already persisted and only creates new ones.\\n\\n### Step 2 \u2014 Site Requirements\\n\\nThe options catalogue is fetched lazily when `step &gt;= 2` via `GET /requests/project-invite-options` (`ProjectInviteOptions`). It is cached in component state (`inviteOptions`) and reused by steps 3\u20134.\\n\\nKey state:\\n\\n- **`onboardingScope`** \u2014 radio choice between `\\\"project\\\"` and `\\\"project_site\\\"`. This determines how recipients are grouped and whether per-site assignment checkboxes appear later.\\n- **`companyKeys`** \u2014 selected company requirement keys (insurance, business, safety categories plus the always-included `identity`, `abn`, and `trade_types` keys from `companyCatalogue`).\\n- **`selectedTags` / `tradeDocs`** \u2014 trade-specific requirements. `tradeDocs` maps a `tagCode` to the list of selected artifact `classCode`s. When a trade is toggled on for the first time (`toggleTag`), its required documents are auto-selected; the user can then toggle individual documents on/off (required vs optional).\\n\\nThe continue button is disabled until at least one company requirement is selected.\\n\\n### Step 3 \u2014 Add Contractors\\n\\nThe contractor staging area combines a network directory search with a manual invitation list.\\n\\n**Directory search** (`useEffect` on `step === 3` and `tradeQuery`): debounced 250ms, fetches `GET /directory?pageSize=50` with an optional `q` parameter. Results populate `tradeResults` as `DirectoryRow[]`.\\n\\n- Rows already in the invitation list are filtered out (`visibleTrades`)\\n- Each visible row shows `PartyIdentity` (name, photo, ABN via `formatAbn`) and a \\\"On Assureworx network\\\" flag\\n- **Add** \u2192 `addNetworkToList(row)` appends it with `kind: \\\"network\\\"`, `networkFlag: true`, and default site IDs based on `onboardingScope`\\n\\n**Manual invite fallback** \u2014 when a search returns no visible matches, the UI shows `inviteBox` with name/email/phone fields. `addInviteToList` validates the email, de-dupes against `listedEmails`, and appends with `kind: \\\"invite\\\"`. Phone numbers are intentionally excluded from persisted drafts.\\n\\n**Site assignment** \u2014 in `project_site` scope, each invitation-list row gets a site picker (checkboxes per persisted site, plus \\\"Select all sites\\\"). `defaultSiteIds()` returns all persisted sites in site mode, or `[]` in project mode.\\n\\nThe invitation list is rendered in a side panel table (`invitationPanel`) with contractor name, site count, and remove action.\\n\\n### Step 4 \u2014 Review &amp; Confirm\\n\\n`confirmAndSend` performs the final orchestration:\\n\\n1. **Validate** \u2014 company requirements must be selected if any manual invitees exist\\n2. **Assign network parties** \u2014 for each `kind: \\\"network\\\"` item, POSTs to `/nodes/sites/{siteId}/assign` for each target site. Site mode targets `item.siteIds`; project mode targets the first persisted site. An `\\\"already\\\"` error message is treated as a successful assignment (idempotent).\\n3. **Send invitations** \u2014 groups `kind: \\\"invite\\\"` recipients by target node:\\n   - **project scope** \u2192 one group keyed by `projectId`\\n   - **project_site scope** \u2192 groups by each recipient's `siteIds` (falling back to all persisted sites, then `projectId` if none)\\n   - Each group is sent via `POST /requests/project-invite` with `CreateProjectInviteBody` containing `companyRequirementKeys` and `tradeBundles` (tag + artifact class codes)\\n4. **Clean up** \u2014 clears the active draft, refreshes the draft list, and shows a summary of assignments sent / invites sent / failures\\n\\nThe confirm button label adapts: *Confirm &amp; send invitations* (manual invitees), *Confirm &amp; assign* (network-only), or *Confirm project* (no contractors).\\n\\n### Draft Persistence\\n\\nThe wizard auto-saves to `localStorage` via the `project-wizard-drafts` utility (see `src/lib/project-wizard-drafts.ts`). Key behaviors:\\n\\n- A debounced effect (300ms) serializes the entire wizard state \u2014 step, form, locations, scope, requirements, trade docs, and invitation list \u2014 into a `ProjectWizardDraftValue`\\n- Phone numbers are stripped from the invitation list before persistence\\n- Drafts are scoped by `orgId`; `getActiveProjectWizardDraftId` restores the most recent draft on mount for create mode\\n- The draft picker on step 1 lists all saved drafts (`listProjectWizardDrafts`) with timestamp metadata; users can load, discard, or start fresh\\n- Edit mode bypasses draft persistence entirely\\n\\n## ProjectDetailClient \u2014 Project Workspace\\n\\nAfter a project exists, `ProjectDetailClient` is the operational workspace. It fetches `ProjectDetail` via `useApi(\\\"/nodes/projects/{id}\\\", { initialData })`.\\n\\n### Header &amp; Metrics\\n\\n- Title, ref badge, dates, and description\\n- A metric rail showing total sites, site assignments, ready-to-work, and blocked counts (aggregated from the sites list)\\n- A location notice banner appears when some sites lack geocodable addresses; the \\\"Complete location\\\" button pre-fills the site editor for the first missing site\\n\\n### Site Editor\\n\\n`SiteEditorMode` is `\\\"add\\\" | \\\"edit\\\" | null`. Both modes share a `Panel`-hosted form with the same validation as the wizard (name required, suburb/postcode required, 4-digit postcode).\\n\\n- **Add** \u2192 `POST /nodes/projects/{id}/sites`\\n- **Edit** \u2192 `PATCH /nodes/sites/{editingSiteId}`\\n\\nAfter saving, the form closes and `refresh()` re-fetches the project detail.\\n\\n### Site Operations\\n\\nThe site workspace is a two-column grid:\\n\\n- **Left** \u2014 a site register (`siteRegister`) listing each site with status marker (blocked / ready / neutral), address, assignment count, and an \\\"Edit details\\\" action. Selecting a site expands `SiteWorkforceManager` (compact mode) beneath it, which manages per-site workforce assignments.\\n- **Right** \u2014 a `SiteMapBoardLazy` preview with a legend (ready / in progress / blocked).\\n\\nSites can be collapsed to a map-only view via the chevron toggle (`sitesOpen`).\\n\\n### Request Composer Trigger\\n\\nThe \\\"Create request\\\" header button toggles `ProjectInviteComposer` inline. `closeSiteEditor()` is called when opening the composer to avoid stacked editors. After a request is sent, `onSent` refreshes the project detail (so readiness counts reflect new invitations).\\n\\n## ProjectInviteComposer \u2014 Standalone Request Flow\\n\\n`ProjectInviteComposer` is a five-step wizard for sending onboarding requests against an existing project. It shares validation and catalogue-filtering logic with `CreateProjectWizard` but operates on a fully persisted project.\\n\\n### Steps\\n\\n| Step | Title | Content |\\n|---|---|---|\\n| 0 | Site | Bind the request to the whole project or a specific site (`siteNodeId`) |\\n| 1 | Companies | Select companies from the network (filtered to `kind === \\\"trade_company\\\"` with email) + add extra recipients by email |\\n| 2 | Company requirements | Multi-select from `companyCatalogue` (same filter as the wizard); defaults to `options.defaultCompanyKeys` |\\n| 3 | Trade requirements | Trade-specific document bundles; toggling a trade auto-selects required docs |\\n| 4 | Review &amp; send | Summary text then `POST /requests/project-invite` |\\n\\n### Data\\n\\nTwo `useApi` calls fetch on mount:\\n\\n- `GET /requests/project-invite-options` \u2192 `ProjectInviteOptions`\\n- `GET /directory?pageSize=100` \u2192 `DirectoryRow[]` (the trade network)\\n\\nThe composer's `recipients` are derived: network companies matching `selectedPartyIds` (with email, name, ABN, partyId) plus `extraRecipients` (manual entries with just email/name).\\n\\n### Sending\\n\\n`send()` builds `CreateProjectInviteBody` with `nodeId: siteNodeId`, the recipient list, `companyRequirementKeys`, and `tradeBundles`. It reports via toast based on `CreateProjectInviteResult` (`sent` / `failed`), then calls `onSent()` and `onClose()`.\\n\\nStep gating is enforced by `canContinue()`:\\n\\n- Step 0 requires a `siteNodeId`\\n- Step 1 requires at least one recipient\\n- Step 2 requires at least one company requirement\\n\\n## Shared Patterns &amp; Utilities\\n\\n### `useApi` Hook\\n\\nAll server data flows through `useApi`, which accepts `initialData` for SSR hydration and returns `{ data, loading, error, refresh }`. This is the module's primary data-fetching mechanism; the wizard's options and directory lookups also use it indirectly through manual `api.get` calls.\\n\\n### API Client\\n\\nDirect calls use the `api` object (`src/lib/api.ts`), with `ApiClientError` caught for error messages. Key endpoints:\\n\\n| Method | Endpoint | Purpose |\\n|---|---|---|\\n| GET | `/nodes/projects` | Project list |\\n| GET | `/nodes/projects/{id}` | Project detail (with sites) |\\n| POST | `/nodes/projects` | Create project |\\n| PATCH | `/nodes/projects/{id}` | Update project metadata |\\n| GET | `/nodes/sites` | Portfolio site list |\\n| GET | `/nodes/sites/{id}` | Site detail with assignments |\\n| POST | `/nodes/projects/{id}/sites` | Add site |\\n| PATCH | `/nodes/sites/{id}` | Update site |\\n| POST | `/nodes/sites/{id}/assign` | Assign party to site |\\n| GET | `/directory?pageSize=&amp;q=` | Network directory search |\\n| GET | `/requests/project-invite-options` | Requirements catalogue |\\n| POST | `/requests/project-invite` | Send onboarding invite |\\n\\n### Permissions\\n\\nWrite operations (create/edit project, add/edit site, send requests) are gated on `me.active?.role === \\\"tier1_admin\\\"` via `canWrite`. Non-admin users see read-only views: the list board, map, and detail workspace without action buttons.\\n\\n### Validation Rules\\n\\nLocation and site validation is consistent across the wizard and the detail client:\\n\\n1. Site name is required\\n2. Suburb **or** postcode is required for map placement\\n3. Postcodes must be exactly four digits (Australian format)\\n\\n### Formatting Helpers\\n\\n- `formatAbn` \u2014 ABN display in directory rows\\n- `formatShortDate` \u2014 schedule display in list rows and headers\\n- `plural` \u2014 count labels (\\\"3 sites\\\", \\\"1 site\\\")\\n- `formatSiteAddress` / `geocodeQueryForSite` \u2014 map address derivation; `geocodeQueryForSite` is preferred when available (it produces the searchable query), falling back to the formatted address\\n\\n## Relationship to Other Modules\\n\\n- **SiteMapBoardLazy** (`components/domain/`) \u2014 lazy-loaded map used by both the board and the detail page for site visualization\\n- **SiteWorkforceManager** (`components/domain/`) \u2014 embedded in project detail for per-site workforce operations; also used by the site detail page\\n- **Requests list** (`(app)/requests/`) \u2014 the invite composer is the write path that creates what the requests list reads. The composer's `POST /requests/project-invite` and the requirements catalogue are shared with the requests admin UI\\n- **Draft persistence** (`src/lib/project-wizard-drafts.ts`) \u2014 localStorage-backed, org-scoped draft store used exclusively by `CreateProjectWizard`\\n- **Session** (`src/lib/session.ts`) \u2014 provides `me.active.orgId` (scoping drafts and API calls) and `me.active.role` (permission checks)\",\"projects-sites-requests\":\"# Projects, Sites &amp; Requests\\n\\n# Projects, Sites &amp; Requests\\n\\nThe **Projects, Sites &amp; Requests** module is the operational hub for the physical side of the platform. It manages the projects and sites a principal contractor runs, the trade parties assigned to those sites, and the invitation workflow that brings new trades onto the platform \u2014 from project creation through onboarding.\\n\\nThe module spans three layers:\\n\\n- **[Backend API](projects-sites-requests-backend-src.md)** \u2014 `nodesRouter` provides project and site CRUD, site assignment/unassignment, and a batch geocoding endpoint; `requestsRouter` handles onboarding/connection requests, project-originated bulk invites, verification templates, and resend/decline.\\n- **[Frontend workspace](projects-sites-requests-frontend-src.md)** \u2014 the `(app)/projects` and `(app)/requests` client components (`ProjectsBoard`, `CreateProjectWizard`, `ProjectDetailClient`, `RequestsClient`) form the UI layer and call the backend through `useApi`.\\n- **[Leaflet map stylesheet](projects-sites-requests-frontend-public.md)** \u2014 a vendored copy of Leaflet's CSS that styles every map view (site pickers, `SiteMapBoard`, portfolio maps) with tiles, markers, popups, and controls, removing any runtime CDN dependency.\\n\\n## How the sub-modules fit together\\n\\nThe frontend workspace is the entry point; the backend is the source of truth. Project and site CRUD flows go to `nodesRouter`, invitation and request flows go to `requestsRouter`, and geocoding is shared: both the backend's batch geocoding endpoint and the frontend's site-registration step call `geocodePlace` (\u2192 `nominatimSearch`) to turn addresses into map pins. The Leaflet stylesheet is consumed only by the frontend map components and contains no application logic.\\n\\n```mermaid\\nflowchart LR\\n    subgraph FE[\\\"frontend-src \u2014 workspace UI\\\"]\\n        W[\\\"ProjectsBoard \u00b7 CreateProjectWizardProjectDetailClient \u00b7 RequestsClient\\\"]\\n    end\\n    subgraph PUB[\\\"frontend-public\\\"]\\n        L[\\\"leaflet.css\\\"]\\n    end\\n    subgraph BE[\\\"backend-src \u2014 REST API\\\"]\\n        N[\\\"nodesRouterprojects \u00b7 sites \u00b7 assignments \u00b7 batch geocode\\\"]\\n        R[\\\"requestsRouterinvites \u00b7 verifications \u00b7 requests\\\"]\\n    end\\n    G[\\\"geocodePlace / nominatimSearch (shared)\\\"]\\n\\n    W --&gt;|\\\"project &amp; site CRUD, assignments\\\"| N\\n    W --&gt;|\\\"bulk invites, onboarding, verification\\\"| R\\n    N --&gt; G\\n    W --&gt; G\\n    W --&gt;|\\\"map views styled by\\\"| L\\n```\\n\\n## Key workflows across the module\\n\\n**Create a project and invite trades.** `CreateProjectWizard` steps through project details, site registration, contractor staging, and invitation dispatch. Drafts are auto-saved via `upsertProjectWizardDraft` / `loadDraft` / `refreshDraftList` so an unfinished wizard can be resumed. On submission, `saveProjectAndLocations` \u2192 `persistLocation` creates the project and sites through `nodesRouter` (geocoding each address), and the invitation step hands the staged batch to `requestsRouter` for dispatch.\\n\\n**Manage a live project.** `ProjectDetailClient` is the project workspace: it opens the site editor, stages additional trade invites through `ProjectInviteComposer` (with tag filtering via `toggleInList`), and routes site changes back to `nodesRouter` for updates or assignment/unassignment.\\n\\n**Process requests.** `RequestsClient` / `RequestsContent` list incoming and outgoing requests with sorting, verification-requirement toggles, copy-link, and revoke actions \u2014 each mapped to an operation on `requestsRouter`.\\n\\n**Site-level operations.** `SiteDetailPage` \u2192 `SiteDetailClient` \u2192 `SiteWorkforceManager` manages the workforce at a specific site, while `SiteMapBoard` / `SiteAssignments` render the geocoded site positions; the map markup is styled by the vendored `leaflet.css` from frontend-public.\",\"retention-audit-legal-compliance\":\"# Retention, Audit &amp; Legal Compliance\\n\\n# Retention, Audit &amp; Legal Compliance\\n\\n## Overview\\n\\nThis module implements the platform's data-retention and accountability guarantees:\\n\\n- **Account closure and personal erasure** \u2014 lets an individual request closure of their account (ADR-0027), starts erasure immediately, and completes de-identification after a grace period.\\n- **Retention sweeps** \u2014 a background pipeline that enforces retention periods for artifacts, compliance evidence, identity-check outcomes, audit events, consent records, and closed accounts.\\n- **Audit event system** \u2014 an immutable event log (`core.t_event`) that every mutation writes to, with automatic capture of IP, user-agent, and correlation ID.\\n- **Legal frontend pages** \u2014 public Privacy Statement and Terms of Use pages, plus reusable legal links.\\n\\nThe backend lives in `backend/src/modules/retention/` and `backend/src/shared/services/`; the legal pages live in `frontend/src/app/(legal)/` with a reusable `LegalLinks` component.\\n\\n---\\n\\n## Account closure lifecycle\\n\\nThe closure flow is split into three phases:\\n\\n1. **Request** \u2014 the party asks to close their account. Sign-in stops immediately and a first pass of personal erasure runs.\\n2. **Grace period** \u2014 90 days (`retention.closureGraceDays`) during which an admin/ops user can cancel the request.\\n3. **Execute** \u2014 the retention sweeper de-identifies the party row and retires the actor permanently.\\n\\n```mermaid\\nflowchart TB\\n    subgraph Account closure\\n        A[requestAccountClosureparties API] --&gt; B[beginPersonalErasure]\\n        B --&gt; C[\\\"grace period (90 days)\\\"]\\n        C --&gt; D[sweepClosedAccounts]\\n        D --&gt; E[executePersonalErasure]\\n        A -. cancelAccountClosureadmin API .-&gt; C\\n    end\\n    subgraph Retention sweeper\\n        S[startRetentionSweepTimer] --&gt; T[tick]\\n        T --&gt; R[runRetentionSweep]\\n        R --&gt; D\\n        R --&gt; F[\\\"5 other sweep units\\\"]\\n    end\\n```\\n\\n### Requesting closure\\n\\n`requestAccountClosure` (`modules/retention/closure.ts`) is the entry point called by the parties API when a user requests closure from Settings.\\n\\nThe function runs in a `withBypass` transaction and:\\n\\n1. Locks the party row (`FOR UPDATE`) and validates it exists and is an individual account (`kind_cd = 'K03'`). Organisation accounts get a `forbidden` error telling the user to contact support.\\n2. If `closure_requested_at` is already set, it returns the current status (idempotent).\\n3. Marks the party `status_cd = 'S04'`, the actor `status_cd = 'RS2'` (login disabled), and sets `closure_requested_at`.\\n4. Calls `beginPersonalErasure` in the same transaction \u2014 this removes profile reliance data immediately.\\n5. Writes a `account.closure_requested` audit event with the grace period in the delta.\\n6. After commit, calls `bumpSessionVersion(actorId)` and `clearSessionCookie(res)` to kill live sessions.\\n\\n`readClosureStatus` returns `{ requestedAt, graceDays, executeAfter }` and is exposed by the parties API for the Settings screen.\\n\\n### Cancelling within the grace window\\n\\n`cancelAccountClosure` (`modules/retention/closure.ts`) is an admin/ops operation. It only succeeds while `closure_requested_at` is within the grace window; otherwise it throws `NO_CLOSURE`.\\n\\nIt restores:\\n\\n- Party status \u2192 `S03`, clears `closure_requested_at`.\\n- Actor status \u2192 `RS1`, clears `deleted_at`.\\n- Network/employment edges that carry the `holder_erasure` marker \u2192 `RS1`, marker removed.\\n- Party display name back from `\\\"Withdrew their information\\\"` if it was overwritten.\\n\\n**Caveat:** identity-check outcomes destroyed by `beginPersonalErasure` are *not* restored, and soft-deleted artifacts stay soft-deleted. Cancellation restores account access and network visibility, but the user may need to re-run identity verification.\\n\\n### Final erasure\\n\\n`executePersonalErasure` (`modules/retention/erase.ts`) runs after the grace window, driven by `sweepClosedAccounts`. It:\\n\\n- Destroys identity checks again (covers checks created during the grace window).\\n- Soft-deletes any remaining live artifacts.\\n- Sets `display_name` to `\\\"Withdrew their information\\\"` (`WITHDREW_DISPLAY_NAME`), nulls `name_1/name_2/email_txt/phone_txt/image_uri/born_on/locality/postal/region`.\\n- Replaces the party `attrs` with a fresh object containing only the `holder_erasure` marker.\\n- Soft-deletes `partyIdent` and `partyTag` rows.\\n- Marks all `rel` edges `RS2` with the `holder_erasure` marker (kept visible, not deleted).\\n- Soft-deletes `link` placements.\\n- Removes the wallet pass device row.\\n- Soft-deletes the actor (login permanently gone), bumps `session_ver`, and revokes all grants.\\n- Nulls `target_party_id`/`target_email_txt` on pending requests.\\n- Writes a `account.erasure_executed` audit event.\\n\\nThe party row itself is **never soft-deleted** \u2014 it stays so Principal Contractors and employers see the \\\"Withdrew their information\\\" label instead of a silent disappearance.\\n\\n### Erasure policy\\n\\n`ERASURE_POLICY` documents exactly what is removed versus retained. This constant is included in audit event deltas so the record shows what was applied.\\n\\n| Removed immediately / after grace | Retained |\\n|---|---|\\n| Name, email, phone, photo, home locality | Billing / orders / ledger (7 years) |\\n| Identifiers (ABN linkage on the person) | Consent grant and withdraw rows (7 years) |\\n| Trade tags | Audit events including closure itself (24 months) |\\n| Identity check outcomes | De-identified party row labelled for network visibility |\\n| Compliance evidence (soft-delete \u2192 hard-delete after 30 days) | Inactive edges marked `holder_erasure` |\\n| Wallet device registrations, active site placements, pending email-change tokens | \u2014 |\\n\\n`partyHasHolderErasure(attrs, closureRequestedAt)` is the predicate read paths use to detect an erased or partially-erased party.\\n\\n---\\n\\n## Retention sweep pipeline\\n\\n### Scheduler\\n\\n`startRetentionSweepTimer` (`modules/retention/scheduler.ts`) starts a jittered `setInterval` (85\u2013115% of `retention.intervalMinutes`), plus an initial tick after roughly 90 seconds so the first sweep happens soon after boot. The timer is `unref()`'d so it never keeps the process alive.\\n\\nThe scheduler:\\n\\n- Is disabled when `retention.enabled` is false (`RETENTION_SWEEP_ENABLED=0`) or `intervalMinutes &lt;= 0` (`RETENTION_SWEEP_INTERVAL_MIN=0`).\\n- Uses a `running` flag so overlapping ticks are skipped (multiple replicas can run the same code; the flag plus `FOR UPDATE SKIP LOCKED` prevents double work).\\n- Logs a one-line summary whenever anything was deleted or a dry-run is configured.\\n- Is stopped by `stopRetentionSweepTimer`, called from `server.ts` shutdown.\\n\\n### Sweep units\\n\\n`runRetentionSweep` (`modules/retention/sweep.ts`) runs six sweep units sequentially inside a single `withBypass` transaction. Each unit accepts `{ limit?, dryRun?, now? }` and returns a count. `batchLimit` clamps the batch to 1\u2013200 (default 50).\\n\\n| Unit | What it does | Retention rule | Locking |\\n|---|---|---|---|\\n| `sweepSoftDeletedArtifacts` | Removes the blob from storage, then hard-deletes the artifact row via `core.fn_hard_delete_artifacts` | Soft-deleted &gt; 30 days (`artifactSoftDeleteDays`) | `FOR UPDATE SKIP LOCKED` |\\n| `sweepStaleEvidence` | Soft-deletes live compliance evidence when the party has had no live network/project link for the period | 7 years (`evidenceYears`) | `FOR UPDATE SKIP LOCKED` |\\n| `sweepExpiredIdentityChecks` | Destroys settled identity-check outcomes whose identity consent has lapsed or never existed | 24 months (`identityConsentMonths`, APP 11.2 style) | none (uses `DISTINCT party_id`) |\\n| `sweepOldEvents` | Purges audit events via `core.fn_purge_events` | 24 months (`auditMonths`) | DB function |\\n| `sweepOldConsent` | Purges consent ledger rows via `core.fn_purge_consent` | 7 years (`consentYears`) | DB function |\\n| `sweepClosedAccounts` | Runs `executePersonalErasure` for individual accounts past their grace window | 90 days (`closureGraceDays`) | `FOR UPDATE SKIP LOCKED` |\\n\\n#### Notes on ordering and safety\\n\\n- `sweepStaleEvidence` only *soft-deletes* artifacts; the hard-delete happens on a later pass (30 days later) via `sweepSoftDeletedArtifacts`, so there is always a recovery window.\\n- `sweepSoftDeletedArtifacts` removes the storage blob **before** the row. Blob-removal failures that are not \\\"not found\\\" skip that row so the DB row is not orphaned.\\n- Because all units share one transaction, a failure in a later unit rolls back the earlier units. This keeps the sweep atomic per batch.\\n- `destroyIdentityChecks` (from `modules/identity/service.js`) is reused by both the erasure path and `sweepExpiredIdentityChecks` \u2014 the retention logic never re-implements identity-outcome deletion.\\n- The whole pipeline is audit-stamped: each unit that deletes rows writes a `retention.*` audit event with the count and the retention period, so enforcement activity itself is observable.\\n\\n`executeAccountClosure` is a deprecated alias for `executePersonalErasure`, kept only for existing callers/tests.\\n\\n---\\n\\n## Audit event system\\n\\nAudit events are the platform's immutable record of who did what, when, and from where. They are written to `core.t_event` and read back through a keyset-paginated query API.\\n\\n### Writing events\\n\\nThe `audit` function (`shared/services/audit.ts`) appends one event inside the caller's transaction \u2014 it always receives a `Queryable` so the event commits or rolls back with the change it describes. It is called from nearly every mutation module: auth, onboarding, identity, parties, memberships, requests, nodes, support, admin, email-change, employee placement, requirements evaluation, extraction, and the retention sweeps.\\n\\n```ts\\nawait audit(tx, {\\n  actorId: input.actorId,\\n  scopePartyId: input.scopePartyId,\\n  verb: \\\"account.closure_requested\\\",\\n  objType: \\\"party\\\",\\n  objId: input.partyId,\\n  delta: { graceDays: config.retention.closureGraceDays },\\n});\\n```\\n\\nKey behaviors:\\n\\n- `actor_kind_cd` is `user` when `actorId` is set, else `system`.\\n- `delta` is JSON-stringified; `ip_addr`/`corr_id` are cast to `inet`/`uuid` at insert.\\n- System jobs can override the IP by passing `ip`; otherwise the request context supplies it.\\n\\n### Request context propagation\\n\\n`bindAuditContext` (`shared/services/audit-context.ts`) is Express middleware that captures per-request metadata into `AsyncLocalStorage`, so `audit()` can stamp events without threading `Request` through call sites:\\n\\n- **IP** \u2014 from `req.ip` (respects `trust proxy`); IPv4-mapped IPv6 (`::ffff:`) is normalized.\\n- **User-Agent** \u2014 control characters stripped, truncated to 256 chars.\\n- **Correlation ID** \u2014 an incoming `x-request-id` is honored only if it is a valid UUID v1\u2013v5; otherwise a random UUID is generated. The chosen ID is echoed on the response via `x-request-id`.\\n\\n`getAuditRequestContext()` returns the store, or `undefined` outside an HTTP request (e.g. background jobs).\\n\\n### Querying and exporting\\n\\n`shared/services/audit-query.ts` provides read paths:\\n\\n- `listAuditEvents` \u2014 keyset pagination over `(at, id)`, newest first. Filters: `scopePartyId`, `objType`, `objId`, `verb`, and `before` (exclusive ISO timestamp for the next page). Limit is clamped 1\u2013200 (default 50). Tenant isolation relies on RLS plus an explicit `scope_party_id` filter.\\n- `exportAuditEvents` \u2014 same shape but uses `from` (inclusive) / `to` (exclusive) ranges, with a hard cap of 10,000 rows.\\n- `eventsToCsv` \u2014 flattens rows to CSV for the reporting module.\\n\\nRows are mapped to `AuditEventItem` with `summary` (human-readable), `actorEmail`, `ip`, and `corrId`. `summariseVerb` resolves verbs against `VERB_SUMMARIES` (a map of ~40 verb codes) and interpolates `delta` details for special cases like `party.status_changed` and `artifact.expired`.\\n\\n### CSV formula-injection protection\\n\\n`csvCell` neutralizes spreadsheet formula/DDE injection (OWASP): any cell starting with `=`, `+`, `-`, `@`, tab, or carriage return gets a tab prefix inside the quotes so Excel/LibreOffice treat it as text. This matters because audit deltas and object IDs are attacker-influenced strings.\\n\\n### Expiry recording\\n\\n`recordNewlyExpiredArtifacts` emits one `artifact.expired` event per newly-expired live document for a party. It is idempotent (skips artifacts that already have such an event) and is called inside the same transaction as status recompute (`evaluateAndPersistStatus` in `shared/services/verified-status.ts`).\\n\\n---\\n\\n## Legal frontend pages\\n\\nThe public legal pages live in the `(legal)` route group:\\n\\n- `app/(legal)/layout.tsx` \u2014 public shell with brand header, `LegalNav`, and a footer containing the company ACN/ABN and Privacy/Terms links.\\n- `app/(legal)/privacy/page.tsx` \u2014 the Privacy Statement (effective 4 August 2026, version `2026-08-04.1`). It documents exactly the retention periods the sweep pipeline enforces: 90-day closure grace, 30-day soft-delete, 7-year evidence/consent/billing retention, 24-month audit retention, and 24-month identity consent.\\n- `app/(legal)/terms/page.tsx` \u2014 the Terms of Use.\\n- `LegalNav.tsx` \u2014 client-side navigation with active-page highlighting.\\n- `components/legal/LegalLinks.tsx` \u2014 a reusable Privacy/Terms link pair used in footers and account sheets, with a `tone` prop for visual density and an `onNavigate` callback.\\n\\nThese pages are static and versioned; the effective date/version constants at the top of each page should be bumped together whenever legal content changes.\\n\\n---\\n\\n## Configuration reference\\n\\nAll values come from the shared config (`shared/config.js`). Env var names are shown where the code references them.\\n\\n| Config key | Default behavior | Purpose |\\n|---|---|---|\\n| `retention.enabled` | on | Master switch for the sweep timer (`RETENTION_SWEEP_ENABLED`) |\\n| `retention.intervalMinutes` | \u2014 | Sweep interval; `&lt;= 0` disables the timer (`RETENTION_SWEEP_INTERVAL_MIN`) |\\n| `retention.dryRun` | off | When true, sweep units report would-be deletions without mutating data |\\n| `retention.closureGraceDays` | 90 | Window between closure request and final erasure |\\n| `retention.artifactSoftDeleteDays` | 30 | Delay between soft-delete and blob removal + hard-delete |\\n| `retention.evidenceYears` | 7 | Age at which stale compliance evidence is soft-deleted |\\n| `retention.auditMonths` | 24 | Audit event purge horizon |\\n| `retention.consentYears` | 7 | Consent ledger purge horizon |\\n| `identityConsentMonths` | 24 | Identity-check outcome retention (in the identity config namespace) |\\n\\n---\\n\\n## Integration map\\n\\n| Caller | What it uses |\\n|---|---|\\n| `modules/parties/index.ts` | `requestAccountClosure`, `readClosureStatus` (Settings screen) |\\n| `modules/admin/index.ts` | `cancelAccountClosure`, `audit` |\\n| `server.ts` | `startRetentionSweepTimer` on boot, `stopRetentionSweepTimer` on shutdown |\\n| `modules/reporting/index.ts` | `listAuditEvents`, `exportAuditEvents`, `eventsToCsv`, `summariseVerb` |\\n| `shared/services/verified-status.ts` | `recordNewlyExpiredArtifacts` |\\n| `modules/identity/service.ts` | `destroyIdentityChecks` (called by erasure + sweep) |\\n| `modules/wallet/pass-id.ts` | `passIdFor` (erasure removes wallet device rows) |\\n| `shared/auth/session.ts` | `bumpSessionVersion`, `clearSessionCookie` (closure kills sessions) |\\n| Nearly every mutation module | `audit` \u2014 auth, onboarding, identity, parties, memberships, requests, nodes, support, admin, email-change, employee placement, requirements, extraction |\\n\\n### Design invariants\\n\\n- **Audit in the same transaction as the change** \u2014 an event never records a change that rolled back.\\n- **Erasure is a transaction** \u2014 `requestAccountClosure` and `executePersonalErasure` each run inside `withBypass`, so state never half-erases.\\n- **Rows outlive people** \u2014 the party row and network edges survive (de-identified / labelled) so organisations see \\\"Withdrew their information\\\" rather than a vanished profile.\\n- **The sweeper is safe to run on every replica** \u2014 jittered intervals, a skip-if-running guard, and `FOR UPDATE SKIP LOCKED` batch claims prevent double deletion.\\n- **Legal text and enforcement stay aligned** \u2014 the retention periods in the Privacy Statement are the same values the sweep pipeline enforces; changes to one should be mirrored in the other.\",\"retention-data-governance\":\"# Retention &amp; Data Governance\\n\\n# Retention &amp; Data Governance\\n\\n## Overview\\n\\nThe Retention module implements ADR-0027, the platform's personal-data retention and erasure schedule. It has two distinct responsibilities:\\n\\n1. **Account closure (reactive)** \u2014 process a person's request to close their account, immediately stop reliance on their personal data, and after a 90-day grace window finish de-identifying the record.\\n2. **Retention sweeps (proactive)** \u2014 run on a timer to purge or retire data that has outlived its legal retention period (stale evidence, expired identity checks, old audit events, old consent rows, and ready-to-execute closures).\\n\\nThe design principle across both paths is the *safest erasure path*: remove personal operating data aggressively, but never silently destroy records the law or the network needs. Billing, consent, and audit rows are retained on their own clocks. Network and employment edges are kept visible but marked with a `holder_erasure` attribute so employers and Principal Contractors see *\\\"Withdrew their information\\\"* instead of a vanished profile.\\n\\n## Key Concepts\\n\\n- **Grace period** \u2014 after a closure request, the account enters a 90-day (`closureGraceDays`) window during which an operator can cancel the closure. After the window, the sweeper executes final erasure.\\n- **`holder_erasure` marker** \u2014 a JSON attribute attached to party rows and edges indicating the holder requested erasure. Used to keep network visibility while suppressing personal data.\\n- **`WITHDREW_DISPLAY_NAME`** \u2014 the canonical placeholder display name (`\\\"Withdrew their information\\\"`) applied after final erasure.\\n- **`ERASURE_POLICY`** \u2014 a constant describing, for auditability, exactly which data categories are removed versus retained. The closure audit events embed this policy so the erasure decision is itself on the record.\\n- **Status codes** \u2014 the module drives a small state machine: party `S03` (active) \u2194 `S04` (closure requested/erased); actor `RS1` (active) \u2194 `RS2` (disabled); relationship `RS1` (active) \u2194 `RS2` (inactive). These codes are shared with the rest of the platform, so a closed account is immediately treated as disabled by auth and query paths.\\n\\n## Configuration\\n\\nAll values have Privacy Statement-aligned defaults and can be overridden via environment configuration:\\n\\n| Key | Default | Meaning |\\n| --- | --- | --- |\\n| `config.retention.closureGraceDays` | 90 | Window between closure request and final erasure |\\n| `config.retention.artifactSoftDeleteDays` | 30 | Age at which soft-deleted artifacts are blob-deleted and hard-deleted |\\n| `config.retention.evidenceYears` | 7 | Age at which stale compliance evidence is soft-deleted |\\n| `config.retention.auditMonths` | 24 | Retention period for audit events |\\n| `config.retention.consentYears` | 7 | Retention period for consent ledger rows |\\n| `config.retention.enabled` | `false` | Master switch; requires `RETENTION_SWEEP_ENABLED` |\\n| `config.retention.dryRun` | env-dependent | When true, sweeps report counts but mutate nothing |\\n| `config.retention.intervalMinutes` | env-dependent | Sweep timer interval; 0 disables the timer |\\n| `config.identityConsentMonths` | env-dependent | Age after which settled identity checks without live consent are destroyed |\\n\\n## Account Closure Lifecycle\\n\\n```mermaid\\nflowchart LR\\n    A[Active accountS03 / RS1] --&gt;|requestAccountClosure| B[Closure requestedS04 / RS2 + beginPersonalErasure]\\n    B --&gt;|\\\"cancelAccountClosurewithin 90 days\\\"| A\\n    B --&gt;|90-day grace elapses| C[sweepClosedAccountsexecutePersonalErasure]\\n    C --&gt; D[De-identified rowdisplay_name = Withdrew their information]\\n```\\n\\n### Reading closure status\\n\\n**`readClosureStatus(tx, partyId): Promise`**\\n\\nReads `closure_requested_at` for a party and computes `requestedAt`, the configured `graceDays`, and `executeAfter` (request time + grace period). Used by the parties routes and by the closure functions themselves to return a consistent status shape.\\n\\n### Requesting closure\\n\\n**`requestAccountClosure(input, res?): Promise`**\\n\\nThe entry point called from the parties routes when an individual requests closure. Runs inside `withBypass` (bypasses row-level security) in a single transaction:\\n\\n1. Locks the party row with `FOR UPDATE`; throws `NOT_FOUND` if missing, `FORBIDDEN` if the party is not an individual (`kind_cd !== 'K03'`).\\n2. If a closure request already exists, returns the current status (idempotent).\\n3. Marks the party `S04` and sets `closure_requested_at = NOW()`.\\n4. Disables all of the party's actors (`RS2`).\\n5. Calls `beginPersonalErasure` \u2014 the immediate erasure pass.\\n6. Writes an `account.closure_requested` audit event.\\n7. After commit, calls `bumpSessionVersion(actorId)` to invalidate all of the user's sessions, and clears the session cookie on the response if provided.\\n\\nThe immediate erasure is deliberately partial; it stops *reliance* on the data. Final de-identification is deferred to the sweeper.\\n\\n### Cancelling closure\\n\\n**`cancelAccountClosure(input): Promise`**\\n\\nOps/admin route (exposed via the admin module). Only valid while the closure is inside the grace window \u2014 the SQL guard `closure_requested_at &gt; NOW() - grace interval` enforces this. On success:\\n\\n- Clears `closure_requested_at`, returns party to `S03`.\\n- Reactivates actors (`RS1`, clears `deleted_at`).\\n- Restores relationships that were marked with `holder_erasure`, removing that attribute and returning them to `RS1`. This deliberately does **not** restore employer de-registrations \u2014 only holder-erasure edges.\\n- Removes `holder_erasure` from the party row and restores the display name if it was overwritten with the withdrew label.\\n- Audits `account.closure_cancelled`.\\n\\nIf no cancellable closure exists, throws `NO_CLOSURE`.\\n\\n## Erasure\\n\\n### Immediate erasure \u2014 `beginPersonalErasure(tx, input): Promise`\\n\\nCalled synchronously from `requestAccountClosure`. It stops all operational dependence on the person's data:\\n\\n- Destroys identity check outcomes via `destroyIdentityChecks` (reason `account_closure_requested`, skipping status recomputation since the caller already updated statuses).\\n- Soft-deletes all of the party's artifacts (`deleted_at = NOW()`).\\n- Nulls `image_uri`, `phone_txt`, `locality_txt`, `postal_cd`, `region_cd` on the party row, and stamps `holder_erasure`.\\n- Marks all relationships touching the party as `RS2` with `holder_erasure` \u2014 edges remain visible so counterparties see the withdrawal.\\n- Ends active site placements (`link` rows) with `deleted_at` and `holder_erasure`.\\n- Deletes pending email-change tokens.\\n- Deletes the party's wallet pass device.\\n- Audits `account.erasure_begun`, embedding the full `ERASURE_POLICY` in the delta.\\n\\nNote what it does **not** do: it leaves name/email on the row, leaves the actor row live (only status `RS2`), and does not touch billing, consent, or audit records. Login is disabled regardless because the actor status is `RS2`.\\n\\n### Final erasure \u2014 `executePersonalErasure(tx, partyId): Promise`\\n\\nCalled by `sweepClosedAccounts` after the grace window, and by `executeAccountClosure` (a deprecated alias retained for tests/callers). Completes de-identification while preserving the network-visible stub:\\n\\n- Re-runs `destroyIdentityChecks` (reason `account_closure`).\\n- Soft-deletes any remaining live artifacts.\\n- Destroys all personal columns on the party row: `name_1`, `name_2`, `email_txt`, `phone_txt`, `image_uri`, `born_on`, `locality_txt`, `postal_cd`, `region_cd`. Sets `display_name` to `WITHDREW_DISPLAY_NAME` and replaces `attrs` with a `holder_erasure` object (preserving the original marker's timestamp if one existed).\\n- Soft-deletes party identifiers (`party_ident`) and trade tags (`party_tag`).\\n- Re-marks relationships `RS2` and ends any remaining placements.\\n- Deletes the wallet pass device.\\n- Disables actors: status `RS2`, email nulled, `session_ver` bumped, `deleted_at` set \u2014 this is what permanently revokes login.\\n- Soft-deletes consent grants.\\n- Unlinks any requests targeting the party.\\n- Audits `account.erasure_executed`.\\n\\nThe party row itself is **not** soft-deleted. It remains as a de-identified stub so the network still resolves the person's identity to *\\\"Withdrew their information.\\\"*\\n\\n### Helper\\n\\n**`partyHasHolderErasure(attrs, closureRequestedAt): boolean`** \u2014 returns `true` if the party has begun or completed erasure. Used by query paths to decide whether to suppress personal fields.\\n\\n## Retention Sweeps\\n\\n**`runRetentionSweep(opts?: { dryRun?, now?, limit? }): Promise`** is the orchestrator. It runs all six phases inside a single `withBypass` transaction so a sweep pass is atomic. Each phase claims a bounded batch (`limit`, default 50, max 200) using `FOR UPDATE SKIP LOCKED` where applicable; overlapping sweeps on other replicas naturally claim disjoint rows.\\n\\n| Phase | Function | Target | Trigger | Action |\\n| --- | --- | --- | --- | --- |\\n| 1 | `sweepSoftDeletedArtifacts` | Artifacts already soft-deleted | `deleted_at` older than `artifactSoftDeleteDays` (30) | Remove blob from storage, then hard-delete row via `fn_hard_delete_artifacts` |\\n| 2 | `sweepStaleEvidence` | Live compliance evidence | Uploaded 7+ years ago **and** no live relationship/placement activity in that window | Soft-delete (hard-delete follows via phase 1) |\\n| 3 | `sweepExpiredIdentityChecks` | Settled identity checks (`C02`, status `H02`\u2013`H04`) | Older than `identityConsentMonths` and no current `N01` consent | Destroy outcomes via `destroyIdentityChecks` |\\n| 4 | `sweepOldEvents` | Audit events | Older than `auditMonths` (24) | Purge via `fn_purge_events` |\\n| 5 | `sweepOldConsent` | Consent ledger | Granted more than `consentYears` (7) ago | Purge via `fn_purge_consent` |\\n| 6 | `sweepClosedAccounts` | Individual parties (`K03`) with closure requested | `closure_requested_at` older than `closureGraceDays` (90) | `executePersonalErasure` per party |\\n\\nEach phase that mutates rows writes a corresponding `retention.*` audit event (`retention.artifacts_hard_deleted`, `retention.evidence_soft_deleted`, `retention.events_purged`, `retention.consent_purged`). Phase 3 (identity checks) returns the count from `destroyIdentityChecks` and does not write a separate audit event; the identity service handles its own audit trail.\\n\\nBlob deletion in phase 1 tolerates already-missing objects (`not found`, `404`, `ENOENT`) but skips the row if any other storage error occurs, so a transient storage failure doesn't orphan rows. Heavy blob deletes happen inline rather than via the job queue to keep local/filesystem storage mode simple; the batch cap keeps each tick short.\\n\\nThe summary type is `RetentionSweepSummary`:\\n\\n```ts\\ntype RetentionSweepSummary = {\\n  artifactsHardDeleted: number;\\n  artifactsSoftDeleted: number;\\n  checksDestroyed: number;\\n  eventsPurged: number;\\n  consentPurged: number;\\n  closuresExecuted: number;\\n  dryRun: boolean;\\n};\\n```\\n\\nIn dry-run mode, each phase reports what it *would* process without mutating rows, and the orchestrator surfaces `dryRun: true` so callers/logs can distinguish a dry run.\\n\\n## Scheduler\\n\\n**`startRetentionSweepTimer(): void`** and **`stopRetentionSweepTimer(): void`** manage the sweep loop. The timer follows the same shape as the identity/payment reconcile timers:\\n\\n- Each replica runs the timer; `tick` guards against overlapping passes with a `running` flag, and `SKIP LOCKED` claims in the sweep phases prevent cross-replica double-processing.\\n- The interval is jittered (`\u00b115%`) to avoid thundering-herd alignment across replicas.\\n- The timer is unref'd so it doesn't hold the process open; an initial tick runs after a jittered ~90-second startup delay.\\n- The timer only starts when `config.retention.enabled` is true and `intervalMinutes &gt; 0`. Otherwise it logs why it stayed disabled.\\n- `tick` logs a compact summary line when any work was done (or when in dry-run mode) and logs the error message on failure without crashing the process.\\n\\n`server.ts` calls `startRetentionSweepTimer()` at boot and `stopRetentionSweepTimer()` on shutdown.\\n\\n## Integration Points\\n\\n- **Parties routes** (`modules/parties/index.ts`) \u2014 expose `readClosureStatus` and `requestAccountClosure` to the API.\\n- **Admin routes** (`modules/admin/index.ts`) \u2014 expose `cancelAccountClosure` to ops/admin.\\n- **Server lifecycle** (`server.ts`) \u2014 starts and stops the sweep timer.\\n- **Identity service** (`modules/identity/service.ts`) \u2014 `destroyIdentityChecks` is reused by both erasure paths and the expired-check sweep; it centralizes the destruction of check outcomes.\\n- **Wallet module** (`modules/wallet/pass-id.ts`) \u2014 `passIdFor` derives the deterministic pass device serial that erasure deletes.\\n- **Artifact storage** (`shared/services/artifact-storage.ts`) \u2014 used by `sweepSoftDeletedArtifacts` to remove blobs before hard-deleting rows.\\n- **Audit service** (`shared/services/audit.ts`) \u2014 every mutation path writes an audit event so the retention decisions themselves are retained.\\n- **Session auth** (`shared/auth/session.ts`) \u2014 closure bumps `session_ver` and clears the session cookie, immediately logging the user out.\\n- **Database pool** (`shared/db/pool.ts`) \u2014 `withBypass` wraps all multi-statement operations so retention logic runs with row-level security bypassed, since system sweeps must see and modify data regardless of the calling context.\\n\\n## Operational Notes\\n\\n- **Safety defaults** \u2014 the retention policy only becomes active when `RETENTION_SWEEP_ENABLED` is explicitly set; the test suite asserts the module stays off otherwise.\\n- **Idempotency** \u2014 `requestAccountClosure` is safe to call twice (returns existing status); `executePersonalErasure`-style updates are re-runnable because they null columns and set markers rather than deleting rows.\\n- **Batch sizes** \u2014 every sweep phase clamps its limit to `[1, 200]`. If a phase consistently hits the limit, the next tick continues from where the last left off; nothing is starved because rows are ordered by age.\\n- **Deprecated API** \u2014 `executeAccountClosure` is a thin alias for `executePersonalErasure`, kept only for existing callers and tests; new code should call `executePersonalErasure` directly.\",\"security-compliance-documentation-archive\":\"# Security &amp; Compliance Documentation \u2014 archive\\n\\n# Security &amp; Compliance Documentation \u2014 Archive (`isms/archive/`)\\n\\n## Overview\\n\\nThe `isms/archive/` module is the designated destination for **superseded controlled Markdown sources** within the ISMS (Information Security Management System) documentation repository. It preserves the last approved version of documents that have been formally replaced, along with the approval record and a reference to the replacement.\\n\\nThis module has no executable code. It contains no internal functions, no outgoing or incoming calls, and no execution flows. It is purely a governance-controlled storage location whose behavior is enforced by the document control procedure, not by runtime logic.\\n\\n## Purpose\\n\\nThe archive exists to satisfy two requirements:\\n\\n1. **Traceability** \u2014 When a controlled document is replaced, the previous approved version must remain available for audit and historical reference.\\n2. **Controlled retention** \u2014 Superseded documents must not be deleted, but they also must not remain in the active document set where they could be mistaken for current.\\n\\nThe module enforces a clear separation: only documents whose replacement has been **approved** may be moved here. Anything else stays in its original location.\\n\\n## Archival Workflow\\n\\nThe process for archiving a superseded document is as follows:\\n\\n```mermaid\\nflowchart LR\\n    A[Replacement document approved] --&gt; B[Move superseded Markdown sourceto isms/archive/]\\n    B --&gt; C[Preserve last approved version]\\n    B --&gt; D[Attach approval record]\\n    B --&gt; E[Add reference to replacement]\\n    B --&gt; F[Archive generated Word copy underdeliverables/Assureworx_ISMS/99_Superseded]\\n```\\n\\nEach step is mandatory:\\n\\n1. **Approval must precede archiving.** A document may only be moved to `isms/archive/` after the replacement has been approved. Archiving is never a precursor to approval.\\n2. **Move the superseded Markdown source** into this directory. The document's history in version control remains intact, but its active location changes.\\n3. **Keep the last approved version** unchanged. Do not edit the archived document to add status banners, update metadata, or modify content after archiving.\\n4. **Preserve the approval record.** The archived document must retain or be accompanied by the record of the approval that superseded it.\\n5. **Add a replacement reference.** The archived document must indicate which document replaced it, so readers can navigate from the obsolete version to the current one.\\n6. **Archive generated Word release copies** to `deliverables/Assureworx_ISMS/99_Superseded`. Word copies do not live in this module \u2014 only Markdown sources do.\\n\\n## Repository Layout\\n\\n| Path | Purpose |\\n|------|---------|\\n| `isms/archive/` | Superseded controlled Markdown sources |\\n| `deliverables/Assureworx_ISMS/99_Superseded/` | Generated Word release copies of superseded documents |\\n\\nThe Markdown sources and the Word deliverables are intentionally separated: the archive module holds the source of truth for version control, while the deliverables path holds the published release artifacts.\\n\\n## Relationship to Active Documentation\\n\\nThis module is reachable from, and governed by, two documents in the `isms/governance/` area:\\n\\n- **`governance/document-register.md`** \u2014 The document register tracks all controlled documents, including their status. A document moved to `isms/archive/` should have its status updated in the register to reflect that it is superseded.\\n- **`governance/document-control-and-approval-procedure.md`** \u2014 The control procedure defines the approval workflow that must be completed before a document is eligible for archiving.\\n\\nWhen working with this module, refer to both documents to confirm the current procedural requirements.\\n\\n## Contribution Guidelines\\n\\nTo archive a document:\\n\\n1. Confirm the replacement is approved \u2014 check the approval record if in doubt.\\n2. Move the Markdown source into `isms/archive/` with `git mv` so history is preserved.\\n3. Ensure the archived document contains or is accompanied by the approval record and a replacement reference.\\n4. Update the document register (`governance/document-register.md`) to reflect the new status and location.\\n5. Archive the Word release copy under `deliverables/Assureworx_ISMS/99_Superseded/` if one exists.\\n\\nDo **not**:\\n\\n- Delete superseded documents.\\n- Edit archived documents after they are placed here.\\n- Archive documents whose replacement has not been approved.\\n- Place active documents in this directory.\\n\\n## Module Metadata\\n\\n| Field | Value |\\n|-------|-------|\\n| Type | `isms` |\\n| Status | `current` |\\n| Last updated | 2026-07-28 |\\n| Owner | Assureworx directors |\\n| Next review | 2027-07-28 |\\n\\nThis metadata is maintained in the module README and should be kept current as part of the document review cycle.\",\"security-compliance-documentation-assurance\":\"# Security &amp; Compliance Documentation \u2014 assurance\\n\\n# Security &amp; Compliance Documentation \u2014 assurance\\n\\n## Purpose\\n\\nThe `isms/assurance` module is the factual backbone of the Assureworx ISMS. Its job is to connect material security statements to the concrete artefacts that substantiate them \u2014 source code, database migrations, deployment configuration, posture documents, and known gaps. The module makes a deliberate distinction between what is *actually true* about the platform and what is merely intended or still open.\\n\\nThe core artefact is the **control truth register** (`control-truth-register.json`), a machine-readable set of claims called *truths*. Each truth asserts something material about the system, cites the file and marker that prove it (or the policy that shows the gap), and carries ownership and review metadata. A validation script checks that the cited evidence still exists and that reviews are current.\\n\\nAs `index.md` states plainly: the register is a review aid. It does not prove deployment or control operation by itself \u2014 it is the structured layer that points at the evidence, so humans and the assurance script can verify it.\\n\\n## Module layout\\n\\n```\\nisms/assurance/\\n\u251c\u2500\u2500 index.md                     # Entry point, module metadata, links to related ISMS docs\\n\u251c\u2500\u2500 control-truth-register.json  # The structured claim register (schema_version: 1)\\n\u2514\u2500\u2500 scripts/isms-truth-check.mjs # Validates referenced files, markers, and review dates\\n```\\n\\nThe module receives no runtime calls and makes none. It is a documentation-and-verification leaf in the repository: the script consumes the register, and the register points outward at the rest of the codebase.\\n\\n## The control truth register\\n\\nThe register is a single JSON file with `schema_version`, an `updated` date, and an array of `claims`. Each claim is one independently verifiable security statement.\\n\\n### Claim schema\\n\\n| Field | Meaning |\\n|---|---|\\n| `id` | Stable identifier (`TRUTH 001` \u2026 `TRUTH 024`) |\\n| `statement` | The material claim, written so a reader can judge true/false |\\n| `classification` | `current_fact` or `known_gap` |\\n| `status` | Verification state (see below) |\\n| `owner` | Responsible role, e.g. `Lead Engineer`, `Directors` |\\n| `last_reviewed` | Date of the most recent review |\\n| `review_interval_days` | Review cadence \u2014 `90` for code-verified facts, `30` for gaps and supplier/deployment-dependent items |\\n| `policy_refs` | Policy, plan, posture, SOA, or architecture documents the claim relates to |\\n| `evidence` | One or more `path` + `contains` pairs |\\n\\n### Classification and status\\n\\n`classification` answers *is this a fact about the current system, or a missing control?*\\n\\n- `current_fact` \u2014 the statement describes something that holds today. Its status is one of:\\n  - `verified_in_code` \u2014 proven by a marker in the cited source or migration (e.g. TRUTH 001, TRUTH 014).\\n  - `requires_deployment_verification` \u2014 confirmed by inspection of deployed Azure resources, not source (e.g. TRUTH 013, key-based auth disabled).\\n  - `requires_deployment_and_supplier_review` \u2014 depends on a third party\u2019s deployment and contractual posture (e.g. TRUTH 006, Didit media handling).\\n- `known_gap` \u2014 the statement describes a control that does **not** exist yet. Status is always `open` (e.g. TRUTH 007, no branch protection; TRUTH 017, no APP 8 compliance for Stripe).\\n\\n### Evidence model\\n\\nEach evidence entry is deliberately simple:\\n\\n```json\\n{\\n  \\\"path\\\": \\\"backend/src/shared/config.ts\\\",\\n  \\\"contains\\\": \\\"ALLOW_DEV_AUTH cannot be enabled\\\"\\n}\\n```\\n\\nThe `path` locates the file; the `contains` value is a distinctive substring that must appear in that file. This design makes evidence mechanically checkable: a script can verify existence of the file and presence of the substring without parsing the language. Evidence targets include:\\n\\n- Backend source: `backend/src/shared/config.ts`, `backend/src/modules/payments/provider/stripe.ts`, `backend/src/shared/services/extraction/cloud-read.ts`\\n- Database migrations: `db/migrations/002_enterprise_core.sql`, `008_security_hardening.sql`, `024_payment_orders.sql`\\n- Frontend components: `frontend/src/components/verify/IdentityHandoff.tsx`, `frontend/src/components/admin/PaymentsConsole.tsx`\\n- Test files used as behavioural evidence: `flow.dbtest.ts`, `bulk.dbtest.ts`, `boot.test.ts`\\n- Posture and planning documents: `isms/stripe-posture.md`, `isms/doc-intel-posture.md`, `isms/didit-posture.md`\\n\\n## Verification script\\n\\n`scripts/isms-truth-check.mjs` performs three validations over the register:\\n\\n1. **Referenced files exist** \u2014 every `evidence.path` and `policy_refs` path resolves.\\n2. **Implementation markers are present** \u2014 each `evidence.contains` substring is found in the cited file.\\n3. **Review dates are current** \u2014 `last_reviewed` plus `review_interval_days` has not elapsed.\\n\\nA truth whose evidence marker has drifted out of the code fails the check, which is the mechanism that forces the register to stay honest: when a control changes, either the code or the truth must be updated. This makes the register a practical change-management tripwire rather than a static document.\\n\\n## What the truths cover\\n\\nThe 24 truths group into a small number of security themes.\\n\\n### Configuration and environment safety\\n\\n- TRUTH 001 \u2014 dev authentication cannot be enabled on test/production environments (`config.ts`, `ALLOW_DEV_AUTH cannot be enabled`).\\n- TRUTH 002 \u2014 secure deployments use the `__Host-awx_session` cookie name prefix.\\n- TRUTH 016 \u2014 a live payment provider key is refused outside the production tier; the application refuses to start if a live key is configured and the declared environment is not production.\\n\\n### Database security\\n\\n- TRUTH 003 \u2014 tenant-scoped tables use PostgreSQL row-level security; selected sensitive tables `FORCE ROW LEVEL SECURITY`, with policies created across migrations `008`, `009`, and `014`.\\n- TRUTH 004 \u2014 application access cannot update or delete the core audit table `core.t_event`; the migration both revokes the privileges and installs a `BEFORE UPDATE OR DELETE` guard.\\n- TRUTH 019 \u2014 a verification cycle cannot be funded twice: a partial unique index (`ux_item_funded`) refuses a second live funding even if application checks are bypassed.\\n\\n### Evidence and identity integrity\\n\\n- TRUTH 005 \u2014 uploaded compliance evidence is SHA-256 hashed before its record is written (`createHash(\\\"sha256\\\")` in `artifacts/index.ts`).\\n- TRUTH 006 \u2014 identity document and selfie capture is hosted by Didit; Assureworx retains the outcome and limited metadata, not the captured media.\\n- TRUTH 010 \u2014 photo identity documents are excluded from every document reading tier via a hardcoded exclusion set (`NEVER_EXTRACT`, class code `A002`).\\n- TRUTH 011 \u2014 the document reading model (`prebuilt-read`) and API version (`2024-11-30`) are fixed in source and cannot be changed via configuration.\\n- TRUTH 012 \u2014 usage of the third-party document reading service is reserved against a recorded cap before any document leaves the application, and every billable read is written to the audit log (`reserveUnits(...)`, `CLOUD_READ_VERB`).\\n- TRUTH 013 \u2014 key-based authentication to the document reading service is disabled; access uses `DefaultAzureCredential`. This is an Azure resource setting confirmed by inspection rather than source.\\n\\n### Payment processing\\n\\n- TRUTH 014 \u2014 card data never reaches Assureworx systems; payment is entered on a Stripe-hosted page and no Assureworx endpoint renders a card field or receives card data.\\n- TRUTH 015 \u2014 when the payment provider is unconfigured, the platform reports `PAYMENTS_NOT_CONFIGURED`; there is no mock payment provider reachable from configuration.\\n- TRUTH 018 \u2014 a payment is recorded as settled only on a signature-verified provider event or reconciliation query; returning from the hosted page never marks an order paid.\\n- TRUTH 020 \u2014 paying for verification does not confer verified status; the payment path funds work, and the existing verification process decides the outcome.\\n- TRUTH 021 \u2014 a verified person never sees the amount charged, the payer, or a provider reference \u2014 only that it is funded, by whom at organisation level, and the internal order number.\\n- TRUTH 022 \u2014 an organisation can only buy verification for current employees; the paying organisation comes from the authenticated session, never the request body, and a set containing any non-employee creates no order and takes no money.\\n- TRUTH 023 \u2014 an employer cannot retroactively change what staff were asked to supply; selection is snapshotted at invitation and acceptance, and the salaried-employee exclusion for ABN collection is enforced server-side.\\n- TRUTH 024 \u2014 refunds are reconciled per refund with individual retry schedules; a never-attempted refund is processed before one that has failed repeatedly, and attempt counts are shown on the operator console.\\n\\n### Known gaps\\n\\nThe register tracks open gaps as first-class claims with the same schema as verified facts:\\n\\n- TRUTH 007 \u2014 no branch protection or required review; automated checks run on push and PR, but a change can still reach `main` unreviewed.\\n- TRUTH 008 \u2014 separate TEST and PROD environments are not recorded as deployed resources.\\n- TRUTH 009 \u2014 Didit processing location, contractual terms, and assurance remain open supplier questions.\\n- TRUTH 017 \u2014 Stripe processes payments in the United States; this is an APP 8 overseas disclosure with no DPA, no filed attestation, and no named disclosure in the privacy policy.\\n\\nGaps carry a 30-day review interval and an explicit owner, so they remain on the radar of the directors and lead engineer rather than decaying into noise. Their `policy_refs` connect them to the plans and registers where remediation work lives (change management, environments, supplier management, risk register).\\n\\n## How it fits into the codebase\\n\\n```mermaid\\nflowchart LR\\n    A[Source code &amp; migrations] --&gt;|contains markers| B[Evidence entries]\\n    C[Posture &amp; deployment docs] --&gt;|contains markers| B\\n    B --&gt;|substantiate| D[Truth claims]\\n    D --&gt;|policy_refs| E[ISMS policies, SOA, plans]\\n    D --&gt;|known_gap| F[Risk register &amp; remediation plans]\\n    G[isms-truth-check.mjs] --&gt;|validates| B\\n    G --&gt;|validates| D\\n```\\n\\nThe register sits between the codebase and the ISMS documents. It references outward to:\\n\\n- **Backend, frontend, and migration files** as evidence anchors.\\n- **Test files** as behavioural proof (e.g. `bulk.dbtest.ts`, `refund.dbtest.ts`) \u2014 these are particularly strong evidence because they encode the invariant as an executable assertion.\\n- **Policy and planning documents** via `policy_refs`: `isms/policies/*`, `isms/soa.md`, `isms/evidence-register.md`, `isms/risk-register.md`, the posture docs (`stripe-posture.md`, `doc-intel-posture.md`, `didit-posture.md`), and wiki decision/architecture records.\\n\\nThere are no incoming calls from application code, and the only consumer is `isms-truth-check.mjs`. The trust model is one-directional: code is the ground truth, the register points at it, and the script keeps the pointers honest.\\n\\n## Working with the register\\n\\nTo add or update a truth:\\n\\n1. **Write the statement** as a falsifiable claim about the system.\\n2. **Classify it** \u2014 `current_fact` if it holds today, `known_gap` if it describes a missing control.\\n3. **Choose evidence** \u2014 for code facts, pick the file and a distinctive `contains` marker that would disappear if the control changed. Prefer the most specific anchor (a function name, a SQL clause, a constant) over a generic one.\\n4. **Set the review interval** \u2014 90 days for code-verified facts, 30 days for gaps, deployment-dependent items, and supplier questions.\\n5. **Run the check** \u2014 `scripts/isms-truth-check.mjs` must pass, which means every cited file and marker must exist and review dates must be current.\\n\\nWhen a code change alters a security control, the relevant truth should fail the marker check \u2014 that failure is the signal to review the claim and update either the code, the evidence, or the statement itself.\",\"security-compliance-documentation-evidence\":\"# Security &amp; Compliance Documentation \u2014 evidence\\n\\n# Evidence Storage Rules Module\\n\\n## Overview\\n\\nThe `isms/evidence/README.md` file defines the storage boundary and handling rules for evidence and document sources in the ISMS repository. It is not executable code \u2014 it is a policy document that tells contributors what may be committed to this directory, what must be routed elsewhere, and how sensitive records are tracked.\\n\\nThe module serves two functions:\\n\\n1. **Guardrail** \u2014 prevents sensitive material from being committed to the standard repository.\\n2. **Pointer** \u2014 directs contributors to the restricted evidence repository and the register that records controlled access to it.\\n\\n## Repository Boundaries\\n\\n```mermaid\\nflowchart LR\\n    A[Evidence or document source created] --&gt; B{Run data classification}\\n    B --&gt;|Non-sensitive| C[Store in isms/evidence/]\\n    B --&gt;|Sensitive| D[Store in restricted evidence repository]\\n    D --&gt; E[Create entry in evidence-register.md]\\n    E --&gt; F[Link, owner, date, retention rule]\\n```\\n\\n### Prohibited content\\n\\nThe `isms/evidence/` directory **must not** contain:\\n\\n- Signed confidentiality agreements\\n- Identity information (personal identifiable data)\\n- Secrets or credentials\\n- Customer evidence\\n- Sensitive operating records\\n\\nThese items belong in the restricted evidence repository selected by the directors. The selection of that repository is a governance decision, not a technical one \u2014 the README defers to the directors and does not name a specific system.\\n\\n### Allowed content\\n\\n- Document sources\\n- Evidence references (links and pointers, not the underlying sensitive material)\\n- Non-sensitive operational records\\n\\n## How It Works\\n\\nThis module has no runtime behavior. The call graph confirms there are no internal, outgoing, or incoming calls, and no execution flows. Its mechanism is entirely procedural:\\n\\n1. A contributor produces evidence or a document source.\\n2. The contributor applies the [Data Classification and Handling Policy](../policies/data-classification-and-handling-policy.md) to determine sensitivity.\\n3. If non-sensitive, the file is stored directly in the repository.\\n4. If sensitive, the file is stored in the restricted evidence repository, and a controlled reference is added to [`evidence-register.md`](../evidence-register.md) containing:\\n   - the controlled link\\n   - the owner\\n   - the date\\n   - the retention rule\\n\\nThis makes the README the entry point for the evidence-handling workflow. The actual enforcement is manual, supported by the document control procedure.\\n\\n## Key Components\\n\\n### Frontmatter metadata\\n\\nThe file carries YAML frontmatter used for governance tracking:\\n\\n| Field | Value | Meaning |\\n|---|---|---|\\n| `type` | `isms` | Belongs to the ISMS documentation set |\\n| `status` | `current` | Active and authoritative |\\n| `updated` | `2026-07-28` | Last content update |\\n| `owner` | `Assureworx directors` | Accountable party for the policy |\\n| `review` | `2026-08-28` | Scheduled next review date |\\n\\n### Referenced files\\n\\n| File | Role |\\n|---|---|\\n| [`evidence-register.md`](../evidence-register.md) | Register of controlled links to restricted evidence; records owner, date, and retention rule for each entry |\\n| [`governance/document-control-and-approval-procedure.md`](../governance/document-control-and-approval-procedure.md) | Defines how documents are controlled, versioned, and approved |\\n| [`policies/data-classification-and-handling-policy.md`](../policies/data-classification-and-handling-policy.md) | Provides the classification criteria used to decide where evidence is stored |\\n\\n## Integration with the Codebase\\n\\nThis module connects to the rest of the repository through documentation links, not imports or function calls. It sits at the intersection of two flows:\\n\\n- **Classification flow** \u2014 evidence enters the repository via the data classification policy, which determines whether it stays local or is routed to the restricted repository.\\n- **Control flow** \u2014 sensitive evidence receives a tracked entry in the evidence register, governed by the document control and approval procedure.\\n\\nA contributor working in this repository should read this README before committing any evidence. The practical contract is: **if it is sensitive, it does not go here; it goes to the restricted repository and is referenced from the register.**\\n\\n## Maintenance\\n\\nThe file is owned by the Assureworx directors and requires a review by `2026-08-28`. Updates should preserve the frontmatter structure and keep the `updated` and `review` dates in sync with the actual review cycle. Changes to the restricted repository location or the register format should be reflected here and in the related governance documents.\",\"security-compliance-documentation-exports\":\"# Security &amp; Compliance Documentation \u2014 exports\\n\\n# Security &amp; Compliance Documentation \u2014 exports\\n\\n## Module Overview\\n\\nThe `isms/exports` module governs how shareable releases of the ISMS Markdown vault are produced. It is a process specification, not executable code: the call graph analysis reports no internal, outgoing, or incoming calls, and no execution flows. The module consists of a single document \u2014 `README.md` \u2014 that defines the release-generation workflow for all ISMS documentation.\\n\\n## Purpose\\n\\nThe Markdown vault is the controlled source of truth for the ISMS. External stakeholders such as auditors, customers, and management typically consume documents in Word format rather than raw Markdown. The exports module governs the conversion of controlled Markdown source into shareable Word releases under `deliverables/Assureworx_ISMS`.\\n\\nThe module enforces one critical invariant:\\n\\n&gt; **Release copies are derived artifacts. They must never be edited directly.**\\n\\nAccepted review changes must always be applied to the Markdown source first; the version is then updated, the release regenerated, and the result recorded. This prevents the common failure mode in which a reviewer edits a release copy, the source falls out of sync, and the next regeneration silently discards the review feedback.\\n\\n## Architecture\\n\\nThe export process is a linear, manual workflow owned by the Assureworx directors. There is no automation, build pipeline, or runtime dependency \u2014 a document owner executes the workflow whenever an approved change is accepted.\\n\\n```mermaid\\nflowchart LR\\n    A[Markdown vaultcontrolled source] --&gt; B[Apply accepted reviewchanges to Markdown]\\n    B --&gt; C[Update version]\\n    C --&gt; D[Regenerate Word releasedeliverables/Assureworx_ISMS]\\n    D --&gt; E[Record release indocument register]\\n    E --&gt; A\\n```\\n\\nThe loop from the register back to the vault indicates that each regenerated release becomes the baseline for the next review cycle.\\n\\n## Key Components\\n\\n### Markdown Vault (Controlled Source)\\n\\nThe authoritative set of Markdown documents that constitute the ISMS. All content changes originate here. The exports module assumes the vault is the single source of truth: any change to a release must first exist as a change to the corresponding Markdown document.\\n\\n### Release Output \u2014 `deliverables/Assureworx_ISMS`\\n\\nThe directory where generated Word releases are written. Its contents are derived artifacts, reproducible from the Markdown source plus the current version number. Release copies must not be modified in place.\\n\\n### Document Register\\n\\nLocated at `../governance/document-register.md`, this file records each release as it is generated. Recording a release after generation provides traceability: for any released document, the register identifies which version was current, when it was produced, and which source state it derives from.\\n\\n### Document Control and Approval Procedure\\n\\nLocated at `../governance/document-control-and-approval-procedure.md`, this procedure defines the review and approval process that changes must pass through before they are accepted. The exports module is the downstream half of that process: the procedure determines what changes are accepted, and the exports workflow turns those accepted changes into new releases.\\n\\n## Release Workflow\\n\\nTo produce a new shareable release:\\n\\n1. **Apply accepted review changes to the Markdown source.** Review feedback must land in the vault, not in an existing Word release. If a reviewer marked up a release copy, port those edits back to the corresponding Markdown document first.\\n2. **Update the version.** Bump the version number in the Markdown source metadata to reflect the new state.\\n3. **Regenerate the release.** Generate the updated Word document under `deliverables/Assureworx_ISMS` from the revised Markdown.\\n4. **Record the release in the document register.** Add an entry to `../governance/document-register.md` capturing the new version and its generation date.\\n\\nThe module does not prescribe any specific Markdown-to-Word conversion tooling; the README governs only the workflow and its constraints.\\n\\n## Integration with the Vault\\n\\nThis module has no runtime integration. The call graph is empty:\\n\\n- **Internal calls:** none\\n- **Outgoing calls:** none\\n- **Incoming calls:** none\\n- **Execution flows:** none detected\\n\\nIts only connections are documentation links to two governance documents:\\n\\n- `../governance/document-register.md` \u2014 the release record\\n- `../governance/document-control-and-approval-procedure.md` \u2014 the upstream approval process\\n\\nThe module's front matter identifies it as `type: isms`, `status: current`, owned by the Assureworx directors, with the next review due 2027-07-28.\\n\\n## Contribution Guidelines\\n\\nWhen contributing to this module:\\n\\n- **Do not edit files under `deliverables/Assureworx_ISMS`.** Treat them as build artifacts. Any manual edit to a release copy will be lost on the next regeneration and creates the illusion that the source changed when it did not.\\n- **Do not bypass the document register.** A release that is not recorded is untracked and should be considered non-authoritative.\\n- **Keep the README accurate.** If the release-generation process changes, update this module's `README.md` so the workflow documented here stays consistent with the procedure referenced in governance.\",\"security-compliance-documentation-governance\":\"# Security &amp; Compliance Documentation \u2014 governance\\n\\n# Security &amp; Compliance Documentation \u2014 governance\\n\\n## Module overview\\n\\nThe `isms/governance` module is the control layer of the Assureworx ISMS vault. It contains the documents that decide how the ISMS itself is run: what the company's proportionate security approach is, how controlled documents are approved and versioned, and how the company prevents policies and public statements from asserting controls that do not exist.\\n\\nThe module is declarative \u2014 every artifact is a Markdown file with YAML frontmatter \u2014 but it is not inert. It defines:\\n\\n- the **document identity and lifecycle contract** that every controlled document in the vault must follow (ISMS GOV 003);\\n- the **approval instruments** used to route the first policy set through director and shareholder sign-off (ISMS GOV 001 and ISMS GOV 002);\\n- the **register of record** mapping every controlled document to its ID, version, status, owner, and review date (`document-register.md`);\\n- the **factual assurance rules** that tie policy statements back to code, configuration, and dated operational evidence, including an automated check (ISMS GOV 004).\\n\\nBecause this module defines the rules for all other ISMS documents, most changes to the vault flow through it: documents are created under these conventions, reviewed under these procedures, approved through these records, and validated by the truth check.\\n\\n## Module contents\\n\\n| File | Document ID | Type | Role |\\n| --- | --- | --- | --- |\\n| `shareholder-brief.md` | ISMS GOV 001 | `isms` | Strategic brief; proposes the proportionate approach and requests seven director decisions |\\n| `approval-record.md` | ISMS GOV 002 | `register` | Signed record approving the brief, the first policy set, and the coordinator appointment |\\n| `document-control-and-approval-procedure.md` | ISMS GOV 003 | `control` | Lifecycle rules: statuses, versions, review steps, change triggers, file structure |\\n| `factual-assurance-procedure.md` | ISMS GOV 004 | `control` | Rules for classifying factual statements, source priority, and truth-check integration |\\n| `document-register.md` | \u2014 | `register` | Index of all controlled documents with approval status and review dates |\\n\\n## Document metadata contract\\n\\nEvery file in the governance module carries YAML frontmatter with the same fields. This is the de facto schema that reviewers and tooling rely on:\\n\\n| Field | Value in this module | Meaning |\\n| --- | --- | --- |\\n| `type` | `isms`, `control`, `register` | Document category: foundational ISMS document, procedure/control, or register/record |\\n| `status` | `draft`, `current` | Frontmatter-level file state; distinct from the in-body controlled status (see lifecycle) |\\n| `updated` | ISO date | Last content update |\\n| `owner` | `Assureworx directors` | Accountable owner |\\n| `prepared_by` | `Farrel Alvis, Director and Lead Engineer` | Author |\\n| `review` | ISO date | Scheduled review date |\\n\\nISMS GOV 003 additionally requires every controlled document to display: title and ID, version, status, owner, preparer, approver, approval date and effective date, next review date, classification, revision history, and the location of its approval record.\\n\\n## Document lifecycle\\n\\nISMS GOV 003 defines the status and version rules that apply to every controlled document:\\n\\n| Status | Version | Meaning |\\n| --- | --- | --- |\\n| Working | 0.1, 0.2, ... | Still being prepared |\\n| For review | 0.1, 0.2, ... | Ready for review but not approved |\\n| Approved | 1.0 | Approved, dated, and in force |\\n| Approved minor update | 1.1, 1.2, ... | Meaning unchanged or risk is low |\\n| Approved major update | 2.0, ... | Material duties, scope, or controls changed |\\n| Superseded | Last approved version retained | Replaced but kept for history |\\n\\nA version bump alone does not approve a document. The transition from `For review` to `Approved` requires a signed approval record.\\n\\n```mermaid\\nstateDiagram-v2\\n    [*] --&gt; Working[\\\"Working:\\\"] 0.1, 0.2...\\n    Working --&gt; ForReview[\\\"ForReview:\\\"] ready for review\\n    ForReview --&gt; Approved[\\\"Approved:\\\"] signed approval record + effective date\\n    Approved --&gt; MinorUpdate[\\\"MinorUpdate:\\\"] 1.1, 1.2...\\n    Approved --&gt; MajorUpdate[\\\"MajorUpdate:\\\"] 2.0+\\n    MinorUpdate --&gt; Approved[\\\"Approved:\\\"] re-approved\\n    MajorUpdate --&gt; Approved[\\\"Approved:\\\"] re-approved\\n    Approved --&gt; Superseded[\\\"Superseded:\\\"] replaced\\n    Superseded --&gt; [*]\\n```\\n\\n## Approval workflow\\n\\nThe approval workflow is the core operational flow of the module.\\n\\nThe **shareholder brief** (GOV 001) is the strategic foundation. It explains why a proportionate ISMS is needed now, what is done now versus deferred until a commercial trigger, when ISO/IEC 27001 certification or a SOC 2 examination becomes worth paying for, and the current implementation position (9 of 201 implementation tasks complete, per the tracker referenced in the brief). It ends with seven decisions requested from the directors, including the choice of ISMS coordinator, the approved evidence repository location, and the security reporting channel.\\n\\nThe **approval record** (GOV 002) is the instrument for those decisions. It packages:\\n\\n- approval of the proportionate approach from GOV 001;\\n- approval of ISMS POL 001 and ISMS POL 002;\\n- approval of the personnel onboarding/offboarding templates and the Confidentiality and NDA (ISMS TPL 001), both conditional on legal review;\\n- appointment of the ISMS coordinator (a practical responsibility, not an executive title);\\n- the reporting obligation for material information security risks and incidents;\\n- the requirement for a directors review within six months of the effective date.\\n\\nOne approval record can route the entire first policy set to all directors via an electronic signature service. Director signatures constitute company approval; shareholder signatures are acknowledgements only and do not confer director authority. The record handler should check the company constitution, shareholder agreement, and the current ASIC officeholder record before finalising approval authority.\\n\\nOnce signed, the workflow proceeds to:\\n\\n1. updating the document register;\\n2. setting each approved document's version to 1.0 and recording the effective date;\\n3. generating clean release copies.\\n\\n## Document review and approval procedure\\n\\nISMS GOV 003 specifies the eight-step workflow for any controlled document:\\n\\n1. The preparer checks content against current code, deployed services, contracts, and public statements.\\n2. A second person reviews material technical claims where practicable.\\n3. The directors review policy, risk, scope, and resource decisions.\\n4. Required legal advice is obtained before legal templates are used.\\n5. Directors record approval in a signed approval record.\\n6. The version becomes 1.0, the effective date is recorded, and a clean release copy is generated.\\n7. The approved document is communicated to people in scope.\\n8. Acknowledgements and operating evidence are retained.\\n\\nReview must be triggered before the scheduled date when product behaviour changes, a new environment/supplier/data type is introduced, a public or contractual claim changes, an incident or control failure occurs, a law or standard changes, an audit identifies a problem, or a director requests a review.\\n\\nThe procedure also fixes the source-of-truth model: the Markdown file in the vault is the controlled source, and Git records every change to it. Word/PDF files are release copies for review, signature, and external sharing; they carry the same document ID, version, and status. Word comments and Track Changes are review input, but accepted changes are applied back to the Markdown source. Signed records and sensitive evidence must not be stored in the public repository \u2014 they belong in the restricted evidence repository, with a reference recorded in the evidence register.\\n\\n## Factual assurance\\n\\nISMS GOV 004 exists to stop policies, customer statements, and audit material from describing a control that does not exist or does not operate as described. Every material security statement must be classified as one of four types:\\n\\n| Type | Meaning | Required support |\\n| --- | --- | --- |\\n| Current fact | True in code or the deployed service now | Code path, configuration, or dated operational evidence |\\n| Policy requirement | A rule the company has approved | Approved policy and communication record |\\n| Planned change | Work the company intends to complete | Work item and owner; never described as current |\\n| Known gap | A control is missing or incomplete | Risk or plan reference and treatment |\\n\\nWhen sources disagree, GOV 004 defines a priority order: verified deployed behaviour &gt; current code and infrastructure configuration &gt; approved policy and decision records &gt; product specifications and contracts &gt; working notes and drafts. Code proves how a feature is written, not that the same version is deployed, monitored, or used correctly \u2014 operational claims need dated evidence.\\n\\nChanges that affect authentication, access, logging, customer data, suppliers, recovery, security claims, or compliance status require an ISMS impact check. The change record must answer whether a policy/procedure, the data inventory, a risk or Statement of Applicability row, a customer statement or contract, or the evidence set needs to change. Where practical, the policy source and the code change are reviewed in the same pull request.\\n\\n### Truth register and automated check\\n\\nMaterial claims are recorded in `isms/assurance/control-truth-register.json`. The automated check runs:\\n\\n```text\\nnode scripts/isms-truth-check.mjs\\n```\\n\\nThe script confirms that cited source files exist, expected implementation markers remain present, and review dates have not expired. It does not replace human review of deployed behaviour or legal statements. A document may move from 0.x to 1.0 only when the factual review is complete, open gaps are stated honestly, the directors have approved it, the approval and effective dates are recorded, and the release copy matches the approved source.\\n\\n## Integration with the rest of the vault\\n\\nThe governance module connects to the broader ISMS vault through explicit references and shared conventions:\\n\\n- **Policies** (`isms/policies/`): ISMS POL 001 and ISMS POL 002 are governed by the GOV 003 lifecycle, approved through the GOV 002 approval record, and tracked in the document register.\\n- **Templates** (`isms/templates/`): ISMS TPL 001 requires Australian legal review before approval; ISMS TPL 002/003 and ISMS REG 004 follow the same lifecycle and are tracked in the register.\\n- **Assurance** (`isms/assurance/`): `control-truth-register.json` is cited by GOV 004 and validated by `scripts/isms-truth-check.mjs`.\\n- **Evidence handling** (`isms/evidence/`): GOV 003 directs signed records and sensitive evidence to the restricted evidence repository; only references are kept in the public vault.\\n- **Architecture wiki** (`wiki/architecture/security.md`): GOV 004 links to the product security architecture as the reference for control design.\\n- **Release delivery** (`deliverables/Assureworx_ISMS/`): GOV 003 defines the release folder structure:\\n\\n| Folder | Purpose |\\n| --- | --- |\\n| `00_Shareholder_Review` | Brief, decisions, and approval record |\\n| `01_Policies` | Controlled policy release copies |\\n| `02_People` | Confidentiality and personnel records |\\n| `03_Plans` | Implementation plans and progress reports |\\n| `99_Superseded` | Retained release copies no longer current |\\n\\n## Operational notes\\n\\n- The governance module has no runtime code and no execution paths of its own. Its integration surface is the conventions it establishes, the register it maintains, and the truth-check script it invokes.\\n- Changing a document ID, file path, frontmatter field, or review date in this module can break cross-references and cause `node scripts/isms-truth-check.mjs` to fail. Treat the document register and frontmatter as a contract.\\n- The frontmatter `status` field (`draft`/`current`) is distinct from the in-body controlled status (`For review`, `Approved`, etc.). Do not confuse them when updating records.\\n- Never commit signed approval records or sensitive evidence to the public repository; record only references to the restricted evidence repository.\\n\\n## Related documentation\\n\\n- `isms/policies/` \u2014 Information Security Policy and Data Classification and Handling Policy\\n- `isms/templates/` \u2014 confidentiality agreement, onboarding/offboarding checklists, policy acknowledgement register\\n- `isms/assurance/` \u2014 control truth register and assurance index\\n- `isms/evidence/` \u2014 evidence handling procedure\\n- `wiki/architecture/security.md` \u2014 product security architecture\\n- `scripts/isms-truth-check.mjs` \u2014 automated truth-register validation\",\"security-compliance-documentation-isms\":\"# Security &amp; Compliance Documentation \u2014 isms\\n\\n# Security &amp; Compliance Documentation \u2014 `isms/`\\n\\n## Overview\\n\\nThe `isms/` directory is a dedicated Obsidian wiki holding the Assureworx Information Security Management System (ISMS). It is the authoritative source for everything required to certify against **ISO/IEC 27001:2022** and pass a **SOC 2 Type II** examination: scope, risk assessment, Statement of Applicability (SoA), policies, evidence registers, and workstream plans.\\n\\nThe module is deliberately *not* a product documentation set. It is an evidence-driven compliance system with strict conventions about what may be claimed as implemented, how progress is measured, and how the wiki stays synchronised with the actual code and Azure estate.\\n\\n```\\nisms/\\n  AGENTS.md              # schema and maintenance rules\\n  index.md               # catalog of every page\\n  log.md                 # append-only chronological activity log\\n  overview.md            # programme status (entry point)\\n  scope.md               # ISO 27001 cl. 4.3 scope statement\\n  soa.md                 # Statement of Applicability \u2014 all 93 Annex A controls\\n  gap-analysis.md        # current state vs required, by clause and theme\\n  soc2-mapping.md        # SOC 2 TSC \u2192 Annex A \u2192 evidence\\n  risk-register.md       # risks, treatment, owner\\n  environments.md        # DEV / TEST / PROD separation, verified state\\n  data-inventory.md      # data classification, PII, flows, residency\\n  didit-posture.md       # identity sub-processor posture\\n  doc-intel-posture.md   # OCR sub-processor posture\\n  stripe-posture.md      # payment sub-processor posture\\n  evidence-register.md   # control \u2192 artefact \u2192 location \u2192 cadence\\n  Dashboard.md           # Obsidian progress dashboard\\n  README_OBSIDIAN.md     # vault setup and sharing instructions\\n  governance/            # sign-off, document control, factual assurance\\n  assurance/             # claim register + automated truth check\\n  policies/              # policy pack register and drafts\\n  templates/             # reusable people-control forms\\n  plans/                 # ten workstreams with computed progress\\n  evidence/              # boundary for sensitive operating evidence\\n  exports/               # release-copy rules\\n  archive/               # superseded-source rules\\n```\\n\\n## Design principles\\n\\n### Two wikis, one codebase\\n\\nThe ISMS is kept physically separate from the product wiki (`wiki/`). The split is a compliance requirement, not an organisational preference:\\n\\n- **`wiki/`** describes *what the product is and how it is built* \u2014 architecture, ADRs, runbooks, security audits.\\n- **`isms/`** describes *how Assureworx governs, secures, and evidences it* \u2014 controls, risks, policies, evidence.\\n\\nThe two cross-link but never duplicate. When the ISMS needs a technical fact, it links to `wiki/architecture/` rather than restating it. When the product wiki needs a control decision, it links into `isms/`. This lets the ISMS be handed to an auditor without exposing product roadmap material, and prevents the ISMS from going stale every time a feature changes.\\n\\n### Evidence beats assertion\\n\\nThe core rule, enforced by convention and by the truth-check script:\\n\\n&gt; **Never claim a control is implemented without a citable artefact.**\\n\\nEvery implemented control must reference a file path, an Azure resource name, a migration, a test file, or a wiki page. \\\"Planned\\\" and \\\"implemented\\\" are distinct states and must never blur. For SOC 2 Type II specifically, every implemented control needs a *recurring* artefact \u2014 a log, a ticket, a signed review \u2014 because the auditor tests operation over a window. One-off statements do not count.\\n\\nUnknowns are flagged with `&gt; **\u2753 Open:**` and filed as tasks in the relevant plan. Contradictions are flagged with `&gt; **\u26a0 Contradiction:**` with both sides cited.\\n\\n### Progress is computed, never hand-written\\n\\nEvery workstream in `plans/` carries a `## Tasks` section of markdown checkboxes. Progress blocks are generated by a script:\\n\\n```bash\\nnode scripts/isms-progress.mjs         # rewrite all progress blocks\\nnode scripts/isms-progress.mjs --check # fail if stale (for CI)\\n```\\n\\nThe script counts `- [x]` against `- [ ]` per plan, rewrites the block between `` and `` in each plan, and rolls totals up into `plans/index.md`, `overview.md`, and `Dashboard.md`. Hand-editing inside those markers is forbidden \u2014 the next run overwrites it.\\n\\nA task is ticked **only** when its evidence exists and is cited on the task line. \\\"Mostly done\\\" stays unticked; the convention is to split it into two tasks.\\n\\n## Page conventions\\n\\nEvery page carries YAML frontmatter:\\n\\n```markdown\\n---\\ntype: isms | policy | plan | register | control | overview | index | log\\nstatus: current | draft | superseded\\nupdated: YYYY-MM-DD\\nowner: \\nprepared_by: \\nreview: YYYY-MM-DD        # next scheduled review date\\n---\\n```\\n\\nThe `type` field drives how the page is treated: `register` pages are living data, `control` pages state a posture in auditor-facing form, `plan` pages carry task checkboxes. Controlled documents (policies, procedures) additionally follow `governance/document-control-and-approval-procedure.md`: Markdown in Git is the controlled source; Word/PDF files are review or release copies.\\n\\n## Key registers\\n\\n### `soa.md` \u2014 Statement of Applicability\\n\\nThe cover document for an audit. All 93 Annex A controls with status (`Implemented`, `Partial`, `Not started`, `N/A`), the artefact evidencing each, and cross-references to the relevant plan and register rows. Status changes propagate to exactly three places at once:\\n\\n1. the control row in `soa.md`\\n2. the matching task in `plans/`\\n3. the artefact row in `evidence-register.md`\\n\\n### `evidence-register.md` \u2014 the audit working document\\n\\nOrganised by evidence cadence, which is the critical column for a Type II examination:\\n\\n| Category | Meaning | Example |\\n| --- | --- | --- |\\n| **Continuous** | Evidenced by configuration plus a population of records | Branch protection + PR population; `core.t_event` rows; `extraction.cloud_read` events |\\n| **Periodic** | Evidenced by dated records for every occurrence in the window | Quarterly access review, annual training, semi-annual restore test |\\n| **Event-driven** | Evidenced when the event occurs | Incident record, offboarding checklist, signed DPA per supplier |\\n\\nThe register tracks whether each artefact is actually being captured. Rows are honestly marked **Not capturing** when a control runs but produces no sampleable artefact \u2014 for example, reconciliation runs on a five-minute timer but its result is not yet written as a dated record.\\n\\n### `environments.md` \u2014 environment separation truth\\n\\nA single source of truth for DEV / TEST / PROD, verified against the live Azure subscription rather than asserted. Records exact resource groups, container-app URLs, auth status, DNS binding state, and the honest conclusion (e.g., \\\"TEST RG exists; PROD is not provisioned; A.8.31 is Partial, not Implemented\\\"). Internal contradictions between this page and the plans are flagged explicitly rather than silently reconciled.\\n\\n### `data-inventory.md` \u2014 data classification and flows\\n\\nRolled-up inventory of every data category: operational classification code, physical store, whether it is personal information, retention period, and notes. Includes inbound/outbound data flows and the cross-border position. The element-level catalogue (every column and endpoint) lives in `wiki/architecture/data-classification-catalogue.md` and is kept current by agents; this register is the auditor-facing roll-up.\\n\\n## Sub-processor posture pages\\n\\nThree pages document the third parties that touch customer data. Each is written in the form an auditor or privacy regulator will ask for, and each includes a sub-processor register entry with any outstanding items explicitly left open:\\n\\n- **`didit-posture.md`** \u2014 identity verification. The architectural win: Assureworx stores only eight outcome fields on `core.t_check` and deliberately never receives document images, numbers, biometrics, or MRZ data. The boundary is enforced in a single file (`backend/src/modules/identity/didit.ts`) and tested by `didit-boundary.test.ts`, which feeds a realistic decision payload and asserts nothing sensitive survives. Open items: processing location, DPA, and security attestation are all unconfirmed.\\n- **`doc-intel-posture.md`** \u2014 Azure AI Document Intelligence OCR. Lower risk than Didit because the resource is inside the existing Azure trust boundary (own subscription, Australia East, managed identity, local auth disabled). Documents the cost-control stack: model pinned in source ($1.50/1,000 pages vs $200 for query fields), page caps, monthly meter, and a circuit breaker so a sick provider never fails an upload.\\n- **`stripe-posture.md`** \u2014 payments. No card data ever transits an Assureworx endpoint (hosted Checkout only); webhook payloads pass an allowlist before storage. Payment processing occurs in the United States regardless of Stripe entity, which is flagged as an APP 8 contradiction against the unqualified Australia East residency claim in `scope.md` \u2014 a customer-contract problem that must be fixed before the first live charge.\\n\\n## Automation\\n\\nThree scripts support the module, all under `scripts/`:\\n\\n| Script | Purpose | Run cadence |\\n| --- | --- | --- |\\n| `isms-progress.mjs` | Computes workstream and dashboard progress from task checkboxes; rewrites progress blocks; `--check` fails on stale blocks for CI | Weekly, and before any audit |\\n| `isms-truth-check.mjs` | Validates material factual claims in `assurance/control-truth-register.json` against cited anchors in code and docs | On any change to a security claim, policy statement, or cited implementation |\\n| `build-isms-starter-docs.py` | Generates branded Word review copies under `deliverables/Assureworx_ISMS/` for director/shareholder review | On controlled document release |\\n\\nThe truth register is the interesting one. Each claim (TRUTH 001 through TRUTH 021 at the time of writing) is a machine-checkable statement about the system \u2014 \\\"no card data ever reaches Assureworx\\\", \\\"A002 photo ID is excluded from every extraction tier\\\", \\\"a verification cycle cannot be funded twice\\\" \u2014 anchored to a specific file or test. The script fails when an anchor stops matching, which turns documentation drift into a CI-detectable error.\\n\\n## Relationship to the wider codebase\\n\\nThe ISMS is documentation, but it is documentation with hard anchors into the code:\\n\\n- **Backend modules as evidence.** Control claims cite specific files: `backend/src/modules/identity/didit.ts` (the vendor boundary), `backend/src/modules/payments/` (ledger, inbox, refunds, reconciliation), `backend/src/modules/retention/` (deletion enforcement), `frontend/src/middleware.ts` (DEV site gate), migrations (`011`, `030`, `035`, \u2026).\\n- **Tests as operating controls.** Isolation suites run as `app_login` (non-superuser) so RLS policies are actually exercised; the Didit boundary test proves data minimisation; payment tests prove authorisation rules. These test files are cited as the evidence for controls like A.8.3, A.8.11, and A.8.12.\\n- **Azure resources as evidence.** Posture pages and register rows cite live resources: `di-aw-dev-aue`, `kv-aw-dev-aue`, `rg-assureworx-test-aue`, with verification dates and the CLI commands used to check them.\\n- **ADRs as decision provenance.** Every material decision is linked to `wiki/decisions/NNNN-*.md` \u2014 ADR-0014/0015 (Didit), ADR-0020 (Document Intelligence), ADR-0027 (retention), ADR-0029 (employer-sponsored bulk verification), and others.\\n- **Product wiki as technical truth.** Architecture, data classification catalogue, runbooks, and security audits are referenced, never duplicated.\\n\\n```mermaid\\ngraph LR\\n    subgraph isms[\\\"isms/ \u2014 compliance documentation\\\"]\\n        REG[\\\"Registersscope \u00b7 soa \u00b7 risk \u00b7 evidence\\\"]\\n        POST[\\\"Posture pagesDidit \u00b7 DocIntel \u00b7 Stripe\\\"]\\n        PLANS[\\\"plans/ \u2014 10 workstreams\\\"]\\n    end\\n    subgraph wiki[\\\"wiki/ \u2014 product documentation\\\"]\\n        ARCH[\\\"Architecture pages +security audits\\\"]\\n        ADR[\\\"ADRs\\\"]\\n    end\\n    subgraph scripts[\\\"scripts/\\\"]\\n        PROG[\\\"isms-progress.mjs\\\"]\\n        TRUTH[\\\"isms-truth-check.mjs\\\"]\\n    end\\n    subgraph code[\\\"Evidence sources\\\"]\\n        SRC[\\\"backend/src modulesmigrations \u00b7 tests\\\"]\\n        AZ[\\\"Azure resources\\\"]\\n    end\\n\\n    REG --&gt;|\\\"technical facts\\\"| ARCH\\n    REG --&gt;|\\\"decision provenance\\\"| ADR\\n    PLANS --&gt;|\\\"task checkboxes\\\"| PROG\\n    PROG --&gt;|\\\"rewrites progress blocks\\\"| PLANS\\n    REG --&gt;|\\\"TRUTH claims\\\"| TRUTH\\n    TRUTH --&gt;|\\\"validates anchors\\\"| SRC\\n    POST --&gt;|\\\"implementation + tests\\\"| SRC\\n    REG --&gt;|\\\"citable artefacts\\\"| SRC\\n    REG --&gt;|\\\"resource evidence\\\"| AZ\\n```\\n\\n## Operating procedures\\n\\n### Weekly\\nRe-run the progress script, append a `## [date] progress` entry to `log.md` recording what moved and what blocked.\\n\\n### On any control change\\nUpdate three places, every time:\\n1. the control row in `soa.md`\\n2. the matching task in `plans/`\\n3. the artefact row in `evidence-register.md`\\n\\nRun `scripts/isms-truth-check.mjs` whenever a security claim, policy statement, public offer, or cited implementation changes. Update `assurance/control-truth-register.json` when a material claim is added, changed, or removed.\\n\\n### On any controlled document change\\nFollow `governance/document-control-and-approval-procedure.md`. Markdown in Git is the controlled source; Word/PDF are review or release copies. An approved document must have a signed approval record, effective date, version, and review date.\\n\\n### On any new risk\\nAdd to `risk-register.md` with owner and treatment. If treatment needs work, add tasks to the relevant plan. New risks have a `R##` identifier (R01 through R29 at the time of writing) so they can be cross-referenced from plans and posture pages.\\n\\n### Before an audit\\nRun the progress script, resolve every `\u2753 Open`, confirm every evidence artefact has a sample from inside the observation window, and produce the SoA as the cover document.\\n\\n## Contribution guidelines\\n\\nWhen working in this module:\\n\\n- **Do not tick a task without cited evidence on the task line.** The evidence may be a file path, a test name, an Azure resource, or a dated log entry \u2014 but it must be there.\\n- **Do not edit inside `` / `` markers.** Re-run `node scripts/isms-progress.mjs` instead.\\n- **Do not upgrade a control status in `soa.md` without updating the matching plan task and evidence row.** The three-place rule is non-negotiable.\\n- **Flag, do not hide.** Unknowns become `&gt; **\u2753 Open:**` blocks and plan tasks; contradictions become `&gt; **\u26a0 Contradiction:**` blocks with both sides cited.\\n- **Prefer the architecture to a policy claim.** If a security property is enforced by code (no identity documents stored, no card data received), say so and cite the enforcing file or test. That is stronger evidence than a policy statement.\\n- **Keep honest language.** \\\"Partial\\\" means partial. \\\"Not capturing\\\" means the control runs but produces no sampleable artefact. A shared Basic-auth password is not A.8.2 closure. Local loopback is not Azure private networking. The wiki's credibility with auditors depends on these distinctions holding.\",\"security-compliance-documentation-legal\":\"# Security &amp; Compliance Documentation \u2014 legal\\n\\n# Security &amp; Compliance Documentation \u2014 legal\\n\\n## Overview\\n\\nThe `docs/legal` module contains the canonical legal documents for the Assureworx platform \u2014 the **Privacy Statement** and the **Terms of Use** \u2014 plus a build script that produces director-pack PDF copies suitable for board distribution.\\n\\nThe module has two distinct layers:\\n\\n1. **Legal content** \u2014 the full text of both documents, embedded in the build script as HTML template literals and mirrored as standalone HTML files in the same directory.\\n2. **PDF build pipeline** \u2014 a dependency-free Node script that regenerates the HTML files from the embedded templates and renders A4 PDFs using a locally installed headless Chrome or Edge.\\n\\n| File | Purpose |\\n|---|---|\\n| `generate-director-pdfs.mjs` | Build script. Source of truth for document text. Regenerates HTML and renders PDFs. |\\n| `Assureworx-Privacy-Statement-2026-08-04.html` | Privacy Statement output (effective 4 August 2026, version 2026-08-04.1). |\\n| `Assureworx-Terms-of-Use-2026-08-04.html` | Terms of Use output (same effective date and version). |\\n| `print-styles.css` | A4 print stylesheet shared by both documents. |\\n\\nThe script is standalone: it uses only Node built-ins (`node:child_process`, `node:fs`, `node:path`) and has no third-party dependencies or `package.json` requirements.\\n\\n## How the pipeline works\\n\\n`generate-director-pdfs.mjs` is a single-pass script that performs three steps for each legal document:\\n\\n1. **Compose** \u2014 the document body (from the embedded `privacyBody` / `termsBody` template literals) is passed to `wrap(title, bodyHtml)`, which assembles the complete HTML document: header block with brand and entity details, director-pack notice, `\n` title, meta line, body content, and footer.\\n2. **Write** \u2014 the composed HTML is written to disk via `writeFileSync`, overwriting the existing HTML file in the same directory. The HTML files are therefore **generated artifacts**, not hand-maintained sources (see [Editing legal text](#editing-legal-text)).\\n3. **Print** \u2014 the script locates an installed Chrome or Edge browser, invokes it in headless mode with `--print-to-pdf`, and writes the PDF next to the HTML file.\\n\\n```mermaid\\nflowchart LR\\n    S[generate-director-pdfs.mjs] --&gt; W[wrap]\\n    B[privacyBody / termsBody] --&gt; W\\n    V[EFFECTIVE / VERSION] --&gt; W\\n    W --&gt; H[writeFileSync]\\n    CSS[print-styles.css] --&gt; H\\n    H --&gt; HTML[HTML files]\\n    HTML --&gt; C[Headless Chrome / Edge]\\n    C --&gt; PDF[PDF files]\\n```\\n\\nAll work happens in the script's own directory (`__dirname`), so inputs and outputs stay colocated in `docs/legal/`.\\n\\n## Key components\\n\\n### Document body templates: `privacyBody` and `termsBody`\\n\\nThe complete body HTML of each legal document lives as a template literal at the top level of the script. These are the **canonical copy** for regeneration:\\n\\n- `privacyBody` \u2014 13 sections covering who Assureworx is, data collection categories, disclosure, overseas processing, cookies, retention periods, security, erasure, complaints, children, and changes.\\n- `termsBody` \u2014 17 sections covering the agreement, Service description, accounts, roles, responsibilities, identity verification, evidence extraction, digital passes, fees, IP, confidentiality, termination, disclaimers, liability caps, indemnity, changes, and governing law.\\n\\nTo update legal copy, edit these template literals and re-run the script. The HTML and PDF outputs will both be refreshed.\\n\\n### `wrap(title, bodyHtml)`\\n\\nA pure function that builds the full HTML document around a body fragment. It takes the document `title` and body HTML and returns a complete `` document containing:\\n\\n- The shared `\n` with the **ASSUREWORX** brand and entity line (ACN / ABN).\\n- The `.notice` banner: *\\\"Director pack copy. Same text as the live Service pages at assureworx.com.au.\\\"*\\n- The `\n` title and `.meta` line (effective date, version, entity).\\n- The body content.\\n- The `.footer` with contact details and \\\"Generated for director distribution\\\".\\n\\n`wrap` pulls the effective date and version from the module-level constants:\\n\\n```js\\nconst EFFECTIVE = \\\"4 August 2026\\\";\\nconst VERSION = \\\"2026-08-04.1\\\";\\n```\\n\\nThese constants flow into the notice banner, the meta line, the footer, and the output filenames. Bumping a version means changing these two values plus the filenames in the `jobs` array.\\n\\n### `jobs` array\\n\\nThe `jobs` array defines the documents to build. Each job specifies:\\n\\n| Field | Purpose | Example |\\n|---|---|---|\\n| `htmlName` | Output HTML filename | `Assureworx-Privacy-Statement-2026-08-04.html` |\\n| `pdfName` | Output PDF filename | `Assureworx-Privacy-Statement-2026-08-04.pdf` |\\n| `title` | Document title used by `wrap` and the browser tab | `Privacy Statement` |\\n| `body` | Body HTML template literal | `privacyBody` |\\n\\nAdding a new legal document to the pack requires only a new body template literal and a corresponding entry in `jobs`.\\n\\n### Browser discovery\\n\\nThe script searches a hardcoded list of Windows install paths for Chrome or Edge:\\n\\n- Chrome under `Program Files`, `Program Files (x86)`, or `%LOCALAPPDATA%`\\n- Edge under `Program Files` or `Program Files (x86)`\\n\\nThe first existing path wins. If none is found, the script prints:\\n\\n```\\nChrome or Edge not found. Install one, then re-run.\\n```\\n\\nand exits with status 1. Note that discovery is **Windows-only** \u2014 there are no macOS or Linux browser paths, so the script is intended to run on a Windows machine with Chrome or Edge installed.\\n\\n### PDF rendering\\n\\nFor each job, the script invokes the discovered browser synchronously via `spawnSync`:\\n\\n```js\\nspawnSync(browser, [\\n  \\\"--headless=new\\\",\\n  \\\"--disable-gpu\\\",\\n  \\\"--no-pdf-header-footer\\\",\\n  `--print-to-pdf=${pdfPath}`,\\n  \\\"--print-to-pdf-no-header\\\",\\n  fileUrl,\\n]);\\n```\\n\\nThe HTML file is passed as a `file:///` URL, with backslashes normalized to forward slashes for Windows paths. The `--print-to-pdf` flags produce a clean A4 document; `--no-pdf-header-footer` / `--print-to-pdf-no-header` suppress the browser's default page header and footer.\\n\\nAfter the browser exits, the script verifies the PDF actually exists. A non-zero exit status or a missing PDF triggers a failure message and `process.exit(1)`.\\n\\n### `print-styles.css`\\n\\nThe shared print stylesheet controls the A4 layout:\\n\\n- `@page` rule: A4 size with 18mm/16mm margins.\\n- Font stack: `\\\"Segoe UI\\\", Calibri, Arial, sans-serif` at 10.5pt, 1.45 line height.\\n- Brand header with a 2px `#0b3d91` rule under the **ASSUREWORX** wordmark.\\n- `page-break-after: avoid` on `h2` and `h3` so headings are not orphaned at page bottoms.\\n- Table styling for the cookie and retention tables in the Privacy Statement.\\n- `.notice` box styling for the director-pack banner.\\n\\nAny layout changes apply to both documents automatically since both link the same stylesheet.\\n\\n## Workflow\\n\\n### Regenerating the director pack\\n\\n```bash\\nnode docs/legal/generate-director-pdfs.mjs\\n```\\n\\nThe script writes both HTML files and both PDFs into `docs/legal/`, printing a confirmation line for each PDF:\\n\\n```\\nWrote docs/legal/Assureworx-Privacy-Statement-2026-08-04.pdf\\nWrote docs/legal/Assureworx-Terms-of-Use-2026-08-04.pdf\\n```\\n\\n### Editing legal text\\n\\n**Edit the template literals in `generate-director-pdfs.mjs`, not the HTML files directly.** The script overwrites the HTML files on every run, so any hand edit to `Assureworx-Privacy-Statement-2026-08-04.html` or `Assureworx-Terms-of-Use-2026-08-04.html` will be lost the next time the script executes.\\n\\nThe committed HTML files still serve a purpose: they make legal content reviewable and diffable in pull requests without running the build.\\n\\n### Publishing a new version\\n\\n1. Update `EFFECTIVE` and `VERSION`.\\n2. Update the `htmlName` and `pdfName` in each `jobs` entry to embed the new date.\\n3. Edit `privacyBody` / `termsBody` as needed.\\n4. Run the script.\\n5. Commit the updated `.mjs`, the regenerated HTML files, and the new PDFs (and remove any superseded files).\\n\\nThe version string follows a `YYYY-MM-DD.N` pattern, where `N` is a same-day revision counter.\\n\\n## Platform requirements\\n\\n- **OS:** Windows (browser discovery only checks Windows install paths).\\n- **Browser:** Google Chrome or Microsoft Edge installed in a standard location.\\n- **Node.js:** any recent version with ES module support (the script uses `import` syntax).\\n\\nNo `npm install` is required.\\n\\n## Relationship to the rest of the codebase\\n\\nThe module is fully self-contained and has no code-level dependencies on the application: nothing imports these files, and the script itself has no incoming or outgoing calls in the codebase.\\n\\nIts connection to the product is **content-level**. The `.notice` banner states the documents are the *\\\"Same text as the live Service pages at assureworx.com.au\\\"* \u2014 meaning the legal terms served by the application and the director-pack PDFs produced here are intended to be identical. This module is the controlled, versioned distribution copy for the board; if the live Service pages drift from these files, the discrepancy needs to be resolved manually.\\n\\nThe script is run on demand (typically as part of a legal release), not as part of CI or application builds.\",\"security-compliance-documentation-plans\":\"# Security &amp; Compliance Documentation \u2014 plans\\n\\n# Security &amp; Compliance Documentation \u2014 `isms/plans`\\n\\n## Overview\\n\\nThe `isms/plans/` module is the execution layer of the Assureworx ISMS documentation. It contains ten workstream plans and one index file that together cover every gap between the current state and a defensible ISO 27001 certification plus a SOC 2 Type II report.\\n\\nEach plan is a Markdown file with three jobs:\\n\\n1. **Define the work** \u2014 a grouped checklist of tasks, each mapped to the ISO 27001 Annex A control, SOC 2 criterion, or ISO clause it satisfies.\\n2. **Track progress** \u2014 checkbox state is the single source of truth for the programme's 0\u2013100% progress figures.\\n3. **Point at evidence** \u2014 every completed task cites the artefact that proves it: a policy document, a code path, a runbook, or an ADR.\\n\\nThe module is documentation-only. It has no runtime code paths or function calls. Its two consumers are the people running the compliance programme and `scripts/isms-progress.mjs`, which parses the checkboxes and renders the progress markers.\\n\\n## How the plans fit into the compliance programme\\n\\nThe plans sit under `isms/plans/` and reference a wider set of ISMS artefacts:\\n\\n```\\nisms/\\n\u251c\u2500\u2500 plans/                  \u2190 this module\\n\u251c\u2500\u2500 scope.md                \u2190 certificate scope, residency commitments\\n\u251c\u2500\u2500 soa.md                  \u2190 Statement of Applicability\\n\u251c\u2500\u2500 risk-register.md        \u2190 risk register\\n\u251c\u2500\u2500 gap-analysis.md         \u2190 source of the gaps the plans close\\n\u251c\u2500\u2500 soc2-mapping.md         \u2190 SOC 2 criterion mapping\\n\u251c\u2500\u2500 evidence-register.md    \u2190 control \u2192 artefact register\\n\u251c\u2500\u2500 data-inventory.md       \u2190 data classification source\\n\u251c\u2500\u2500 policies/               \u2190 policy pack (ISMS POL 001, 002, \u2026)\\n\u251c\u2500\u2500 governance/             \u2190 approval records, document control\\n\u251c\u2500\u2500 templates/              \u2190 NDA, onboarding/offboarding checklists\\n\u2514\u2500\u2500 environments.md         \u2190 DEV/TEST/PROD environment table\\n```\\n\\nPlans also link outward to `wiki/decisions/` (ADRs), `wiki/architecture/` (security audits, environment inventories), and `wiki/runbooks/` (deployment, retention, incident runbooks).\\n\\nThe index (`index.md`) is the entry point. It summarises all ten workstreams, explains the sequencing model, and states the working rules that govern how tasks are ticked.\\n\\n## Module layout\\n\\n| File | Workstream | Focus | Progress |\\n|---|---|---|---|\\n| `index.md` | \u2014 | Sequencing, progress table, working rules | 16% overall (34/218) |\\n| `01-governance.md` | Governance &amp; management system | ISO clauses 4\u201310, scope, roles, risk method, policy pack, review cadence | 0% (0/28) |\\n| `02-access-control.md` | Identity &amp; access control | Access policy, production/privileged access, application auth defects, tenant isolation | 33% (7/21) |\\n| `03-change-management.md` | Change management &amp; secure development | Branching model, CI/CD pipeline, dependency/secret scanning, testing | 8% (2/24) |\\n| `04-environments.md` | Environments &amp; infrastructure as code | DEV/TEST/PROD separation, hardening, Bicep IaC, test data rules | 43% (13/30) |\\n| `05-logging-monitoring.md` | Logging, monitoring &amp; alerting | Audit trail completeness, infrastructure logging, Azure Monitor alert rules | 31% (5/16) |\\n| `06-data-protection.md` | Data protection, privacy &amp; retention | Classification, retention enforcement, APP privacy rights, breach readiness | 33% (7/21) |\\n| `07-supplier-management.md` | Supplier &amp; third-party management | Supplier register, Didit and Stripe assurance, cloud services policy | 0% (0/23) |\\n| `08-resilience.md` | Incident response, backup &amp; continuity | IR plan, restore testing, BIA/RTO/RPO, redundancy | 0% (0/19) |\\n| `09-people.md` | People, endpoints &amp; remote working | Employment lifecycle, awareness training, endpoint baseline | 0% (0/18) |\\n| `10-audit-evidence.md` | Audit readiness &amp; evidence | Evidence capture, internal audit, external pentest, certification path | 0% (0/18) |\\n\\nFiles follow the naming convention `NN-slug.md` with zero-padded numbers. The index's front matter uses `type: index`; every plan uses `type: plan`.\\n\\n## Anatomy of a plan\\n\\nAll ten plans share the same structure:\\n\\n| Section | Purpose |\\n|---|---|\\n| YAML front matter | `type: plan`, `status: current`, `updated` (last content change), `owner: Assureworx directors`, `review` (scheduled review date). The `review` date drives the document review cadence an auditor will ask about. |\\n| Lead paragraph | One-line summary of the workstream's intent. |\\n| `` block | Machine-generated progress line. Never hand-edited. |\\n| **Why this matters** | Audit rationale: which ISO clause / SOC 2 criterion is at stake, what the auditor will actually test, and why the gap exists. Often names the evidence that needs calendar time to accumulate. |\\n| **Tasks** | Grouped checkbox list. Each line is a task, optionally annotated with control mappings and evidence citations. |\\n| **Exit criteria** | Definition of done phrased in auditable terms, e.g. \\\"two access reviews completed inside the window\\\". |\\n| **Related** | Links to the relevant SoA sections, shared artefacts, and wiki documents. |\\n\\n## Task lines and the evidence rule\\n\\nA task is a checklist item. The working rules in `index.md` define a strict contract for ticking one:\\n\\n&gt; A task is ticked only when its evidence exists and is cited on the task line. \\\"Nearly done\\\" stays unticked. Split it into two tasks instead.\\n\\nThis is the module's core invariant. The checkbox is not a status symbol; it is a claim that an artefact exists and is linked. A checked task without an `evidence:` citation violates the module contract.\\n\\nTask lines are pipe-separated. The recognised metadata fields are:\\n\\n| Field | Meaning | Example |\\n|---|---|---|\\n| `control:` | ISO 27001 Annex A control, SOC 2 criterion, or ISO clause | `control: A.5.31`, `control: CC8.1`, `ISO clause 6.1` |\\n| `evidence:` | Path to the proving artefact. Required on checked tasks. | `evidence: backend/src/shared/config.ts` |\\n| `review copy:` | Document under review that the task approves | `review copy: [ISMS POL 001](../policies/information-security-policy.md)` |\\n| `template:` / `register:` | Template or register the task produces | `template: [ISMS GOV 002](../governance/approval-record.md)` |\\n| `ref:` | Finding or ticket reference from an audit or engagement | `ref: S2-2`, `ref: F11` |\\n| `resolves Q##` | Open question this task settles | `resolves Q11` |\\n| `note:` | Constraint or caveat attached to the task | `note: Azure DEV has Managed Redis` |\\n\\nThe `control:` and `ref:` fields create a traceable chain: **audit finding \u2192 control requirement \u2192 task \u2192 evidence**. Plans reference findings and risks by ID (e.g. `F-01`, `F10`, `R14`, `R23`, `Q11`, `Q26`, `S2-2`, `S2-14`) that are defined in the audit documents under `wiki/architecture/` and `wiki/decisions/`.\\n\\n## Progress tracking\\n\\nProgress is derived, never declared. The `` and `` HTML comment blocks in each plan \u2014 and the summary table in `index.md` \u2014 are owned by `scripts/isms-progress.mjs`.\\n\\nThe script:\\n\\n1. Scans every plan in `isms/plans/` for `- [ ]` and `- [x]` checkbox lines.\\n2. Computes done/total per plan and overall.\\n3. Rewrites the progress line inside each plan's comment block.\\n4. Rewrites the table inside `index.md`'s comment block.\\n\\nThe index states the rule explicitly: progress is \\\"computed from the task checkboxes in each plan by `scripts/isms-progress.mjs` and is never edited by hand.\\\" Any hand edit inside a progress block will be overwritten on the next run.\\n\\nThe contributing workflow after changing any task state is:\\n\\n```bash\\nnode scripts/isms-progress.mjs\\n```\\n\\nthen append an entry to `log.md`.\\n\\n## Sequencing model\\n\\nThe index sequences the workstreams into four waves. Two constraints drive the order:\\n\\n1. **Evidence needs calendar time.** SOC 2 Type II samples an observation window, so a recurring control (access review, alert disposition, restore test) must start early enough to produce samples inside the window. Starting late does not just delay the report \u2014 it shortens the usable window.\\n2. **Some work unblocks other work.** Environments cannot be hardened before they exist; change management cannot gate deployments to environments that are not there.\\n\\nThe wave assignments:\\n\\n| Wave | Workstreams | Rationale |\\n|---|---|---|\\n| 1 \u2014 start immediately | 03 Change management, 04 Environments, 01 Governance | 03 is the hardest blocker and slowest to accumulate evidence; 04 resolves the DEV/TEST/PROD contradiction and blocks most of wave 2; 01 is pure documentation with no dependencies that every other workstream references. |\\n| 2 | 02 Access control, 05 Logging &amp; monitoring, 07 Supplier management | 02 needs environments to exist before production access can be modelled; 05 alerts need somewhere to point; 07's Didit questions have long external lead times, so they are raised now even though the workstream sits in wave 2. |\\n| 3 | 06 Data protection, 08 Resilience, 09 People | Retention decisions depend on the classification scheme and legal input; restore testing needs production to exist; 09 is fast and low risk while headcount is small. |\\n| 4 | 10 Audit readiness | Evidence capture must be designed early but only completes once the controls it evidences are operating. |\\n\\nThe dependency structure (edges are the \\\"blocks\\\" relationships stated in the index):\\n\\n```mermaid\\nflowchart TB\\n    subgraph Wave1[\\\"Wave 1 \u2014 start immediately\\\"]\\n        P01[\\\"01 \u00b7 Governance\\\"]\\n        P03[\\\"03 \u00b7 Change management\\\"]\\n        P04[\\\"04 \u00b7 Environments &amp; IaC\\\"]\\n    end\\n    subgraph Wave2[\\\"Wave 2\\\"]\\n        P02[\\\"02 \u00b7 Access control\\\"]\\n        P05[\\\"05 \u00b7 Logging &amp; monitoring\\\"]\\n        P07[\\\"07 \u00b7 Supplier management\\\"]\\n    end\\n    subgraph Wave3[\\\"Wave 3\\\"]\\n        P06[\\\"06 \u00b7 Data protection\\\"]\\n        P08[\\\"08 \u00b7 Resilience\\\"]\\n        P09[\\\"09 \u00b7 People\\\"]\\n    end\\n    subgraph Wave4[\\\"Wave 4\\\"]\\n        P10[\\\"10 \u00b7 Audit readiness\\\"]\\n    end\\n\\n    P04 --&gt; P02\\n    P04 --&gt; P05\\n    P04 --&gt; P08\\n    P01 --&gt; P06\\n    P02 --&gt; P10\\n    P05 --&gt; P10\\n    P08 --&gt; P10\\n```\\n\\nNote that 03 and 04 run in parallel within wave 1, but 03's deploy-gating tasks assume 04's environments exist. Similarly, 07 has an early-start rationale that is not a hard dependency.\\n\\n## Integration with application code\\n\\nAlthough the plans are documentation, they cite concrete code paths as evidence. This is what makes the compliance programme verifiable rather than aspirational: a checked task points at a startup assertion, a SQL migration, an auth gate, or an Azure resource that an auditor can inspect.\\n\\nThe main code touchpoints:\\n\\n**Deployment posture** \u2014 `backend/src/shared/config.ts` contains `ASSUREWORX_ENV` and `assertDeployAuthPosture`, which make `ALLOW_DEV_AUTH` impossible outside local/dev environments via a boot-time assertion rather than an unset variable. `scripts/deploy-dev.ps1` wires the dev environment's auth posture. `scripts/secure-local-infra.ps1` strips `LOAD_TEST_RELAX_LIMITS` from local `.env` files.\\n\\n**Authentication and session security** \u2014 `backend/src/shared/auth/dev-auth-gate.ts` gates local passwordless personas behind `DEV_AUTH_SECRET` via the `X-Assureworx-Dev-Auth` header; `backend/src/shared/auth/otp-callback-auth.ts` validates the OTP callback's OAuth bearer using `ENTRA_OTP_EXTENSION_AUDIENCE`; the session cookie is set via `config.sessionCookie` to `__Host-awx_session` when Secure. Plan 02 tracks live API evidence such as `/api/v1/auth/entra/status` returning `enabled:true, allowDevAuth:false` and `/auth/entra/start` returning a 302 to CIAM.\\n\\n**Audit trail** \u2014 `backend/src/shared/audit-context.ts` exports `bindAuditContext`, which populates the `ip_addr` column on `core.t_event` audit events (previously never written).\\n\\n**Retention and deletion** \u2014 `backend/src/modules/retention/` plus migration `030_retention_and_account_closure.sql` enforce the retention schedule when `RETENTION_SWEEP_ENABLED=1` is set. The hard-deletion account closure path is `POST /parties/me/closure`, executed by the retention sweeper after 90 days, with an admin cancel at `POST /admin/parties/:id/closure/cancel`.\\n\\n**Legal pages** \u2014 `/privacy` and `/terms` in `frontend/src/app/(legal)/` satisfy APP 1 and the SoA's A.5.34.\\n\\nPlans also cite infrastructure evidence: Azure resource groups (`rg-assureworx-dev-aue`, `rg-assureworx-test-aue`), inventory documents under `wiki/architecture/` (`azure-dev-inventory.md`, `azure-test-inventory.md`), alert rules in Azure Monitor (`alert-aw-api-auth-failures-dev-aue`, action group `ag-aw-dev-ops`), and commit hashes on `origin/main` (`201c984`, `fd778e5`).\\n\\n## Contributing to the plans\\n\\nThe working rules in `index.md` are the contribution contract:\\n\\n1. **Tick a task only when its evidence exists and is cited on the task line.** Add the `evidence:` field pointing at the artefact.\\n2. **\\\"Nearly done\\\" stays unticked.** Split it into two tasks instead. A partially complete task is not evidence of a control operating.\\n3. **After ticking anything, run `node scripts/isms-progress.mjs`** and append to `log.md`. This regenerates the progress markers in the plan and the index table.\\n4. **Add new work as a task in an existing plan wherever possible.** Adding new plans dilutes the progress signal.\\n\\nWhen editing a plan, preserve the file conventions: YAML front matter with `updated` and `review` dates, the auto-generated progress block (do not edit inside it), grouped task sections, control mappings on task lines, and the `Related` section linking to the relevant SoA sections and shared artefacts.\",\"security-compliance-documentation-policies\":\"# Security &amp; Compliance Documentation \u2014 policies\\n\\n# Security &amp; Compliance Documentation \u2014 policies module\\n\\n## Overview\\n\\nThe `isms/policies/` module contains Assureworx's highest-level security governance documents: the two formal ISMS policies, the operational alert triage runbook, and the register that tracks every policy the ISMS requires. The module maps documents to ISO/IEC 27001:2022 Annex A controls and SOC 2 criteria, and it enforces a strict discipline: a policy that is \\\"for review\\\" is not an operating control, and only approved, communicated, acknowledged policies may be presented as such.\\n\\nThis is a static documentation module. It contains no executable code, exposes no functions, and has no runtime call graph or execution flow. Its integration surface is the cross-document reference graph \u2014 policies link outward to plans, governance records, the evidence register, and the architecture wiki, and the register ties each policy to specific compliance controls.\\n\\n## Module layout\\n\\n| File | Role | Document ID / Controls | Status |\\n| --- | --- | --- | --- |\\n| `index.md` | Policy register: the control-to-policy map and writing rules | All ISMS controls | Draft |\\n| `information-security-policy.md` | Top-level security policy; establishes direction and core control requirements | ISMS POL 001 \u2014 A.5.1 | For review (v0.2) |\\n| `data-classification-and-handling-policy.md` | Classification, handling, transfer, retention and disposal rules | ISMS POL 002 \u2014 A.5.12\u2013A.5.14, A.5.33, A.5.34; SOC 2 Confidentiality | For review (v0.2) |\\n| `alert-triage.md` | Operational procedure for responding to Azure Monitor alerts | A.8.16, CC7.2 | Draft (v0.1) |\\n\\nThree of the 21 policies in the register have drafted documents. The remaining 18 are tracked as \\\"Not started\\\"; see [Register coverage](#register-coverage).\\n\\n## Document lifecycle\\n\\nEvery file in this module carries YAML frontmatter that drives review scheduling and ownership:\\n\\n| Field | Meaning | Example |\\n| --- | --- | --- |\\n| `type` | Document kind: `policy` or `index` | `type: policy` |\\n| `status` | `draft` while being written; moves to an approved state via the governance process | `status: draft` |\\n| `updated` | Last content change | `updated: 2026-07-28` |\\n| `owner` | Accountable party \u2014 always Assureworx directors for this module | `owner: Assureworx directors` |\\n| `prepared_by` | Working author (policies only) | `prepared_by: Farrel Alvis, Director and Lead Engineer` |\\n| `review` | Scheduled review date | `review: 2027-07-28` |\\n\\nThe formal policies additionally carry an in-body document header (ID, version, status, owner, approver, effective date, next review date) and link to the approval record at `../governance/approval-record.md`. Keep the frontmatter `status` and the in-body `Status` field in sync \u2014 both currently say \\\"draft\\\" / \\\"For review\\\" respectively, and the register mirrors the same state.\\n\\nApproval is a director decision. Review happens annually, or sooner after a material incident, regulatory change, or significant ISMS change.\\n\\n## Policy register (`index.md`)\\n\\n`index.md` is the control map for the entire ISMS policy set. Its register table lists 21 policies, each assigned to its ISO 27001 control(s) \u2014 for example, access control policy \u2192 A.5.15, incident response policy \u2192 A.5.24, logging and monitoring standard \u2192 A.8.15/A.8.16. Owners are uniformly the directors; versions and approval dates are only populated once work begins.\\n\\nThe register also codifies the drafting rules that govern this module:\\n\\n- **State what is actually done.** An aspirational policy is worse than admitting the gap. The register explicitly warns against the pattern seen elsewhere in the product docs, where account lockout and signed document URLs are documented but not implemented.\\n- **Short and specific beats long and generic.** A two-page policy that people follow beats fifteen pages nobody reads.\\n- **Every policy needs an owner, preparer, version, approval date and review date** \u2014 checked before content.\\n- **Reference, do not restate.** Point at `wiki/architecture/` for technical detail so policies do not go stale when implementations change.\\n- **Australian English**, per `wiki/brand.md`.\\n\\n## Information Security Policy (`information-security-policy.md`)\\n\\nISMS POL 001 is the top of the policy hierarchy. Sections 1\u20136 establish purpose, scope, objectives, principles, roles, and risk management. The principles (section 4) are the load-bearing rules:\\n\\n- **4.1 Least privilege and need to know** \u2014 privileged access requires approval, stronger authentication, logging, and periodic review.\\n- **4.2 Secure by design and by default** \u2014 changes are version-controlled, reviewed, tested, and traceable; customer data must not enter lower environments without an approved exception.\\n- **4.3 Data minimisation** \u2014 defers to the Data Classification and Handling Policy for personal/sensitive information.\\n- **4.4 Separation and accountability** \u2014 where headcount makes duty separation impracticable, the responsible director documents the conflict and a compensating control (retrospective review, protected log, or time-limited access).\\n- **4.5 Evidence over assertion** \u2014 an undocumented action is not evidence of control operation.\\n\\nSection 5 assigns roles: directors approve and receive material reports; a named ISMS coordinator (an explicit working responsibility, not an executive title) maintains the policy set, risk register, evidence register, review schedule, and audit actions; system/control owners implement and evidence controls; all personnel have compliance and reporting duties.\\n\\nSection 7 defines the core control requirements \u2014 access/authentication, information handling, development/change, logging/monitoring, suppliers/cloud, and incident/continuity \u2014 each of which points to a dedicated policy or plan elsewhere in the ISMS.\\n\\n## Data Classification and Handling Policy (`data-classification-and-handling-policy.md`)\\n\\nISMS POL 002 defines the four classification classes and their mandatory handling rules:\\n\\n| Class | Definition | Typical examples |\\n| --- | --- | --- |\\n| Restricted | Disclosure could cause serious harm or legal/contractual breach | Secrets, credentials, consent records, health/biometric info |\\n| Confidential | Personal, customer, commercial or compliance info for authorised audiences | Worker contact details, tenant data, audit events, source code |\\n| Internal | Operational, non-public; limited harm if disclosed | Internal procedures, non-sensitive telemetry |\\n| Public | Approved for unrestricted release | Published website content, approved marketing |\\n\\nTwo mechanisms make classification operational:\\n\\n1. **Engineering operational labels** \u2014 a five-label catalogue (`GP`, `CIC`, `C`, `PII`, `SPII`) in Australian English that maps onto the policy classes. The canonical definitions live in `wiki/architecture/data-classification-catalogue.md`, with a combined auditor view in `../data-inventory.md`.\\n2. **Handling requirements matrix** (section 5) \u2014 a control grid covering access, storage, email, external sharing, non-production use, printing, portable media, and disposal, with escalating requirements per class. Notably: Restricted data is prohibited on portable media, production data is prohibited in non-production environments, and Restricted disposal requires verified secure deletion or cryptographic erasure with evidence.\\n\\nSection 4 hard-codes the Privacy Act 1988 (Cth) sensitive-information categories as always-Restricted, and lists six prohibitions \u2014 including never entering health details, biometric data, or credentials into free-text fields like support tickets or telemetry, and stopping handling immediately if sensitive information arrives unexpectedly. The policy records the current design intent that Didit identity checks store only the result and limited metadata, with ongoing review tracked in `../didit-posture.md` and the truth register.\\n\\nSections 7\u201310 cover collection/use, transfer/disclosure, retention/disposal, and security events. The security-events section doubles as the incident-reporting trigger: personnel must stop further disclosure, preserve evidence, report through the designated channel, and never self-investigate. Where personal information may be involved, the incident owner starts the notifiable data breach assessment \u2014 with the statutory 30 days framed explicitly as a maximum, not a target.\\n\\n## Alert triage procedure (`alert-triage.md`)\\n\\nThis is the only operational runbook in the module. It is explicitly a DEV-starter document: its response targets distinguish current DEV reality (Sev 2 acknowledged within one business day) from future PROD requirements (Sev 1 acknowledged within 30 minutes).\\n\\nThe live-rules table is the authoritative list of alert conditions feeding the procedure:\\n\\n| Rule | Signal |\\n| --- | --- |\\n| `alert-aw-api-replicas-dev-aue` | API avg Replicas &gt; 2 for 5m (cost/abuse) |\\n| `alert-aw-web-replicas-dev-aue` | Web avg Replicas &gt; 2 for 5m (cost/abuse) |\\n| `alert-aw-api-restarts-dev-aue` | RestartCount &gt; 3 in 15m (crash loop) |\\n\\nAlerts arrive via action group `ag-aw-dev-ops` in `rg-assureworx-dev-aue`, landing in `tech@assureworx.com.au`. Until a ticket tool exists, each firing is recorded in `../evidence-register.md` under A.8.16 with date, rule name, cause, action taken, and closed-by. The procedure links to plan 05 (logging/monitoring) and the evidence register.\\n\\n## Cross-module relationships\\n\\nThe policies depend on a network of documents outside this module. The core graph:\\n\\n```mermaid\\ngraph LR\\n    subgraph \\\"isms/policies/\\\"\\n        INDEX[\\\"index.mdpolicy register\\\"]\\n        ISP[\\\"information-security-policy.mdISMS POL 001\\\"]\\n        DCH[\\\"data-classification-and-handling-policy.mdISMS POL 002\\\"]\\n        AT[\\\"alert-triage.md\\\"]\\n    end\\n    INDEX --&gt; ISP\\n    INDEX --&gt; DCH\\n    INDEX --&gt; AT\\n    ISP --&gt; DCH\\n    DCH --&gt; CAT[\\\"wiki/architecture/data-classification-catalogue.md\\\"]\\n    DCH --&gt; INV[\\\"data-inventory.md\\\"]\\n    AT --&gt; ER[\\\"evidence-register.md\\\"]\\n    ISP --&gt; GOV[\\\"governance/approval-record.md\\\"]\\n    ISP --&gt; SOA[\\\"soa.md\\\"]\\n```\\n\\nKey external dependencies:\\n\\n- **`../governance/approval-record.md`** \u2014 both formal policies cite this as their approval evidence (ISMS GOV 002).\\n- **`../evidence-register.md`** \u2014 where alert dispositions and policy approval/review acknowledgements are recorded.\\n- **`../plans/`** \u2014 plans carry the implementation work the policies depend on: plan 01 (governance), plan 03 (change management), plan 05 (logging/monitoring), plan 06 (data protection), plan 07 (supplier management).\\n- **`../soa.md` and `../scope.md`** \u2014 the Statement of Applicability and ISMS scope that the policies reference for control and boundary definitions.\\n- **`wiki/architecture/data-classification-catalogue.md`** \u2014 the living technical catalogue that the operational classification labels must stay aligned with.\\n\\n## Known gaps and open prerequisites\\n\\nThe module is candid about what is not yet true. Contributors and auditors should treat these as tracked, not hidden:\\n\\n1. **Approval gap.** All three policies are \\\"For review\\\" / \\\"Not approved\\\". The register states plainly that a review-stage document is not an operating control.\\n2. **PR gate not operating.** POL 001 \u00a77.3 flags that the reviewed pull request and automated code check gate is not yet implemented; it is a blocker in plan 03 and must not be described to customers or auditors as live.\\n3. **Retention schedule undecided.** POL 002 \u00a79 marks the data-inventory retention periods as an open prerequisite \u2014 the policy cannot be fully implemented until the schedule is approved and technically enforced.\\n4. **Pre-approval decisions required.** POL 002 lists three director decisions that must happen before approval: the security reporting channel, the first retention schedule, and the restricted evidence repository.\\n5. **Staffing-dependent items.** The alert triage secondary channel is \\\"add on-call when headcount &gt; 1\\\", and PROD-level Sev 1 targets are explicitly future state.\\n6. **Shared-context items.** The unexplained \\\"To / To\\\" cells in the register's Version/Approved columns for not-started policies are a formatting artifact; the register may warrant a `\u2014` convention.\\n\\n## Contribution checklist\\n\\nTo add or modify a policy in this module:\\n\\n1. Add the frontmatter (`type`, `status`, `updated`, `owner`, `review`, and `prepared_by` for policies).\\n2. Give the document a body header with document ID, version, status, owner, approver, effective date, and next review date, and link the approval record.\\n3. Follow the register's drafting rules: state what is actually done, keep it short, reference technical detail instead of restating it, use Australian English.\\n4. Map the policy to its Annex A / SOC 2 controls in the register table.\\n5. Cross-link related plans, governance records, and evidence \u2014 and update the register's Status/Version/Approved columns when the document state changes.\",\"security-compliance-documentation-src\":\"# Security &amp; Compliance Documentation \u2014 src\\n\\n# Security &amp; Compliance Documentation \u2014 `src`\\n\\nThis module contains the public legal and compliance documents for the Assureworx frontend: the **Privacy Statement** and the **Terms of Use**. It is split across a Next.js route group (`app/(legal)/`) and a shared component (`components/legal/LegalLinks.tsx`).\\n\\nThe legal pages are static, server-rendered content. They make no data-fetching calls, hold no client state, and depend only on Next.js primitives (`Link`, `Metadata`, route-group layout resolution). The only client-side behaviour in the route group is active-link highlighting in `LegalNav`.\\n\\n## Route structure\\n\\nThe `(legal)` directory is a Next.js route group \u2014 the parentheses prevent it from contributing a URL segment. It publishes two routes:\\n\\n| Route | Component | Document |\\n| --- | --- | --- |\\n| `/privacy` | `PrivacyPage` | Privacy Statement |\\n| `/terms` | `TermsPage` | Terms of Use |\\n\\nBoth pages export `metadata` (title + description) through the Next.js `Metadata` type and define module-level `EFFECTIVE` and `VERSION` constants, currently `\\\"4 August 2026\\\"` and `\\\"2026-08-04.1\\\"`, rendered in the document meta line.\\n\\n## Architecture\\n\\n`LegalLayout` provides the shared chrome for both documents: a header with the Assureworx brand (linked to `/auth/login`), the `LegalNav` navigation, the page content, and a footer repeating the Privacy/Terms links alongside company registration details.\\n\\n`LegalLinks` lives outside the route group because it is a cross-cutting presentational component used by auth forms, settings, and the application shell \u2014 not just the legal pages. It always links to the same two public routes and accepts an `onNavigate` callback so callers can dismiss overlays or sheets when a link is activated.\\n\\n```mermaid\\ngraph TD\\n    LegalLayout --&gt; PrivacyPage[\\\"/privacy\\\"]\\n    LegalLayout --&gt; TermsPage[\\\"/terms\\\"]\\n\\n    LegalLinks --&gt; PrivacyPage\\n    LegalLinks --&gt; TermsPage\\n\\n    AccountClient --&gt; LegalLinks\\n    LoginForm --&gt; LegalLinks\\n    RegisterForm --&gt; LegalLinks\\n    TopBar --&gt; LegalLinks\\n    SettingsClient --&gt; LegalLinks\\n    Sidebar --&gt; LegalLinks\\n```\\n\\n## Components\\n\\n### `LegalLayout` \u2014 `app/(legal)/layout.tsx`\\n\\nServer component. Props: `{ children: React.ReactNode }`.\\n\\nRenders:\\n\\n- A header containing a brand `Link` to `/auth/login` with `aria-label=\\\"Assureworx home\\\"`, wrapping an `Image` of `/logo.png` (160\u00d736, priority-loaded).\\n- `LegalNav`.\\n- `\n` containing `children`.\\n- A footer with the copyright line, ACN/ABN, and links to `/privacy` and `/terms`.\\n\\n### `LegalNav` \u2014 `app/(legal)/LegalNav.tsx`\\n\\nClient component \u2014 the only one in the route group. Uses `usePathname()` from `next/navigation` to determine the current route.\\n\\nRenders a `\n` containing:\\n\\n- `Privacy` \u2192 `/privacy`\\n- `Terms` \u2192 `/terms`\\n- `Sign in` \u2192 `/auth/login`\\n\\nThe `Privacy` and `Terms` links set `aria-current=\\\"page\\\"` when `pathname === link.href`, which the CSS module styles with the active background. The link definitions are a `const` array (`LINKS`) with `as const` typing.\\n\\n### `PrivacyPage` \u2014 `app/(legal)/privacy/page.tsx`\\n\\nServer component. Exports `metadata`, `EFFECTIVE`, `VERSION`. Renders the full privacy document in 13 numbered sections.\\n\\nLegal content covers, among other things:\\n\\n- Roles: when Assureworx acts as controller vs. processor for customer data.\\n- Identity verification via Didit, with explicit note that Assureworx does **not** receive or store ID document images, face photos, or biometric templates.\\n- Payment handling via Stripe; no card data stored by Assureworx.\\n- Cookie usage \u2014 strictly-necessary cookies only, documented in a styled table.\\n- Retention periods, documented in a table (accounts, compliance evidence, identity check outcomes, billing records, audit events, etc.).\\n- The account erasure flow: `Settings \u2192 Erase my personal details`.\\n\\nUses `styles.tableWrap` for tables that need horizontal scrolling on narrow viewports, and `next/link` for the cross-link to `/terms`.\\n\\n### `TermsPage` \u2014 `app/(legal)/terms/page.tsx`\\n\\nServer component with the same structure and export pattern as `PrivacyPage`. Renders the Terms of Use in 17 sections, including eligibility, roles on the network, identity verification consent, payment terms via Stripe, IP, confidentiality, liability caps, indemnity, and governing law. Cross-links to `/privacy`.\\n\\n### `LegalLinks` \u2014 `components/legal/LegalLinks.tsx`\\n\\nPresentational component used across the application. It has no hooks, so it renders correctly in both server and client component trees.\\n\\nProps:\\n\\n| Prop | Type | Default | Purpose |\\n| --- | --- | --- | --- |\\n| `className` | `string` | `undefined` | Extra class appended to the wrapping `\n` |\\n| `onNavigate` | `() =&gt; void` | `undefined` | Invoked when either link is clicked (e.g. to close an account sheet) |\\n| `tone` | `\\\"default\\\" \\\\| \\\"menu\\\" \\\\| \\\"muted\\\"` | `\\\"default\\\"` | Visual density for the surrounding context |\\n\\nRendered output:\\n\\n```tsx\\n\n\\n  Privacy&lt;\\/Link&gt;\\n   \u00b7 &lt;\\/span&gt;\\n  Terms&lt;\\/Link&gt;\\n&lt;\\/p&gt;\\n```\\n\\nStyle variants map to `LegalLinks.module.css` classes: `.default`, `.menu`, `.muted`.\\n\\n## Styling\\n\\nTwo CSS modules serve the module:\\n\\n- **`legal.module.css`** \u2014 page chrome (`.shell`, `.header`, `.nav`, `.footer`, `.main`) and document typography (`.doc`, `.title`, `.meta`, heading/paragraph/list/table styles, `.tableWrap`). Includes a responsive breakpoint at `max-width: 640px`.\\n- **`LegalLinks.module.css`** \u2014 the reusable link list, including focus-visible ring styling via `--focus-ring`.\\n\\nBoth modules consume the app's design tokens (`--color-canvas`, `--color-ash`, `--color-carbon`, `--color-pewter`, `--color-graphite`, `--color-pale`, `--color-cloud`, `--color-electric`, `--text-meta`, `--text-label`, `--text-body`, etc.).\\n\\n## Integration points\\n\\n`LegalLayout` is resolved automatically by Next.js for both child routes in the group. The route group has no outgoing data calls.\\n\\n`LegalLinks` has six known call sites:\\n\\n| Call site | Area |\\n| --- | --- |\\n| `AccountClient` | `settings/account/AccountClient.tsx` |\\n| `RegisterForm` | `auth/register/RegisterClient.tsx` |\\n| `LoginForm` | `auth/login/LoginClient.tsx` |\\n| `TopBar` | `components/shell/TopBar.tsx` |\\n| `SettingsClient` | `(app)/settings/SettingsClient.tsx` |\\n| `Sidebar` | `components/shell/Sidebar.tsx` |\\n\\nCallers may pass `tone` to match the surrounding UI density and `onNavigate` to close a sheet or overlay when the user leaves to read a legal document.\\n\\n## Updating legal content\\n\\nTo publish a new version of either document:\\n\\n1. Edit the JSX body in `privacy/page.tsx` or `terms/page.tsx`.\\n2. Bump the `EFFECTIVE` and `VERSION` constants at the top of the file \u2014 they are displayed in the document meta line and should change together.\\n3. Keep the heading hierarchy (`h2`/`h3`) intact; the `.doc` styles depend on it.\\n4. Wrap any new tables in `\n` so they scroll horizontally on small screens.\\n5. Use `next/link` for cross-links between `/privacy` and `/terms` so navigation stays client-side.\\n\\nThere is no CMS or data source behind these pages; the legal text lives directly in the component source.\",\"security-compliance-documentation-templates\":\"# Security &amp; Compliance Documentation \u2014 templates\\n\\n# Security &amp; Compliance Documentation \u2014 Templates Module\\n\\n## Overview\\n\\nThe **templates** module lives at `isms/templates/` and provides reusable record templates for the ISMS people controls. It covers the full personnel lifecycle \u2014 onboarding, policy acknowledgement, confidentiality obligations, and offboarding \u2014 and aggregates signed acknowledgements into a team-wide register.\\n\\nThe module is a static Markdown documentation tree. There are no executable functions, no internal calls, and no runtime execution flows. Its behaviour is expressed entirely through cross-linked documents: templates reference policies and plans, the index references all templates, and completed, dated records flow into the evidence register.\\n\\nA governing principle stated in `index.md`:\\n\\n&gt; A blank template is not control evidence; the completed, dated and retained record is.\\n\\nTemplates are therefore scaffolding for evidence, not evidence themselves.\\n\\n## Module Layout\\n\\n| File | ID | Type | Purpose |\\n| --- | --- | --- | --- |\\n| `index.md` | \u2014 | `index` | Entry point listing all templates and the evidence register |\\n| `confidentiality-and-non-disclosure-agreement.md` | ISMS TPL 001 | `control` | NDA for employees, contractors and advisers |\\n| `personnel-onboarding-checklist.md` | ISMS TPL 002 | `control` | Pre-access, first-day and first-week onboarding record |\\n| `personnel-offboarding-checklist.md` | ISMS TPL 003 | `control` | Access revocation, asset return and verification record |\\n| `policy-acknowledgement.md` | ISMS TPL 004 | `control` | Signed record of policy receipt and understanding |\\n| `policy-acknowledgement-register.md` | ISMS REG 004 | `register` | Multi-signer aggregation of policy acknowledgements |\\n\\nDocument IDs use a shared numeric sequence with a type prefix: `TPL` for templates and `REG` for registers. The register's ID (REG 004) indicates the numbering space is shared across the wider ISMS document set, not per-directory.\\n\\n## Key Components\\n\\n### Confidentiality and Non-Disclosure Agreement (ISMS TPL 001)\\n\\nThe NDA template is the only template marked **\\\"Legal review required before use.\\\"** Australian counsel must confirm enforceability, employment-law interaction, execution method, duration, remedies, and New South Wales governing-law wording before Assureworx signs or issues it.\\n\\nThe document is structured as a full agreement:\\n\\n- **Parties** \u2014 Assureworx Labs Pty Ltd (ACN 699 745 876) and the named recipient, with placeholders for registered address and recipient details.\\n- **Confidential Information** \u2014 a broad definition covering customer data, personal and sensitive information, source code, credentials, security architecture, compliance evidence, and derived materials, with standard carve-outs for public, independently developed, and lawfully received information.\\n- **Recipient obligations** \u2014 ten numbered obligations including least-privilege access, MFA use, immediate incident reporting, and return/deletion on request.\\n- **Permitted and required disclosure** \u2014 including a carve-out protecting lawful protected disclosures, regulator cooperation, and Fair Work Act 2009 (Cth) pay-secrecy rights.\\n- **Personal information handling** \u2014 explicit prohibition on placing sensitive information, identity documents, passwords, recovery codes, or production personal information into unapproved tools.\\n- **Duration** \u2014 a five-year post-engagement term, with longer survival for trade secrets, credentials, personal information, and third-party obligations.\\n\\nThe template includes a document history table (versions 0.1 and 0.2), signature blocks, and links to the Data Classification and Handling Policy and Plan 09: People.\\n\\n### Personnel Onboarding Checklist (ISMS TPL 002)\\n\\nCovers ISO/IEC 27001:2022 Annex A controls **A.5.15\u2013A.5.18, A.6.1\u2013A.6.3, A.6.6, A.6.7, and A.8.1** \u2014 identity/access management, screening, terms of employment, awareness training, NDAs, remote work, and endpoint devices.\\n\\nThe checklist is organised into three phases:\\n\\n1. **Before access is granted** \u2014 identity and right-to-work checks, signed employment agreement and NDA, least-privilege access profile, device baseline (full disk encryption, automatic lock, patching, endpoint protection).\\n2. **First day** \u2014 unique identity (no shared accounts), MFA enrolment, password manager provisioning, policy briefings, and confirmation that production data must not be copied to DEV, personal tools, or unapproved external services.\\n3. **First week** \u2014 security awareness and phishing training, incident-reporting demonstration, signed policy acknowledgement, access verification, and asset inventory update.\\n\\nThe completion block records open actions, the checklist owner, manager approval, and the person's acknowledgement.\\n\\n### Personnel Offboarding Checklist (ISMS TPL 003)\\n\\nCovers **A.5.11, A.5.15\u2013A.5.18, A.6.5, A.6.6, and A.7.14** \u2014 asset return, access rights, responsibilities after termination, NDAs, and secure disposal or reuse of equipment.\\n\\nThe checklist has four phases:\\n\\n1. **Plan** \u2014 confirm last working time and risk level (standard / elevated / involuntary), list all accounts and assets, transfer ownership, and obtain approval for immediate revocation on elevated or involuntary exits.\\n2. **Revoke and recover** \u2014 disable the primary identity at the approved time, revoke sessions and tokens, remove access across GitHub, Azure, databases, support, billing, and supplier consoles, rotate shared secrets, recover devices and security keys, and preserve business records before account deletion.\\n3. **Information and confirmation** \u2014 confirm secure deletion from unauthorised locations, remove auto-forwarding and integrations, remind the person in writing of continuing confidentiality obligations, and update the asset and access inventories.\\n4. **Verification after exit** \u2014 independent verification of access removal within one business day, escalation of residual access, retention/deletion per schedule, and corrective action for lessons or exceptions.\\n\\n### Policy Acknowledgement (ISMS TPL 004)\\n\\nCovers **A.5.36** (compliance with policies, rules and standards) and **A.6.3** (information security awareness, education and training).\\n\\nA single-signer record where the person acknowledges:\\n\\n- Receipt of and opportunity to ask questions about the listed documents;\\n- Understanding of security, privacy, confidentiality and acceptable-use responsibilities;\\n- Credential protection, MFA use, and restriction to approved accounts, devices, repositories and communication channels;\\n- Immediate reporting of suspected security events, privacy incidents, policy breaches, lost devices, exposed credentials, or unintended disclosures;\\n- That monitoring and proportionate corrective action may apply;\\n- That the acknowledgement does not remove lawful workplace rights, protected disclosure rights, or pay-discussion rights.\\n\\nThe document lists the policies to be acknowledged (Information Security Policy, Data Classification and Handling Policy, Acceptable Use Policy, Remote Working Policy, Incident Reporting Procedure) with blank `Version` and `Date provided` fields. It includes blocks for questions, exceptions and accommodations, signatures, and the evidence record location.\\n\\n### Policy Acknowledgement Register (ISMS REG 004)\\n\\nA multi-signer register that aggregates individual policy acknowledgements into a single team-wide record. It lists the policies acknowledged (with version and effective date columns), carries a seven-point acknowledgement statement, and provides a signature table pre-filled with the preparer's name. Record-handling instructions require storage in the restricted evidence repository and retention of the e-signature completion certificate when signatures are collected on different dates.\\n\\nThis register is the bridge between individual acknowledgement records and the evidence register.\\n\\n## Relationship Map\\n\\nTemplates do not call each other; they reference each other. The links form a people-lifecycle graph:\\n\\n```mermaid\\nflowchart LR\\n    IDX[index.md] --&gt; OB[onboarding checklist]\\n    IDX --&gt; OBX[offboarding checklist]\\n    IDX --&gt; PA[policy acknowledgement]\\n    IDX --&gt; REG[acknowledgement register]\\n    IDX --&gt; NDA[NDA]\\n    POL[policies] --&gt; PA\\n    PA --&gt; REG\\n    NDA --&gt; OB\\n    OB --&gt; OBX\\n    REG --&gt; EVID[evidence register]\\n    OBX --&gt; EVID\\n```\\n\\nThe relationships that matter for audit:\\n\\n- **Onboarding requires the NDA.** The NDA must be legally approved, signed and retained before access is granted (onboarding check, \\\"Legally approved NDA signed and retained\\\").\\n- **Onboarding requires the policy acknowledgement.** First-week checks include \\\"Policy acknowledgement signed and retained.\\\"\\n- **Offboarding reinforces the NDA.** The offboarding checklist reminds the person in writing that confidentiality, privacy and IP obligations continue after the engagement \u2014 the NDA's duration clause is the underlying obligation.\\n- **Individual acknowledgements aggregate into the register.** The register's signature table collects multiple signers; its record-handling note covers e-signature completion certificates.\\n- **All completed records flow to the evidence register.** Both checklists and the register reference the approved evidence repository. The index links to `../evidence-register.md` as the landing point for completed evidence.\\n\\n## Integration with the Wider ISMS\\n\\nThe module connects outward to three neighbouring areas:\\n\\n- **Policies** (`../policies/`) \u2014 the NDA and policy acknowledgement link to the Information Security Policy and the Data Classification and Handling Policy. These policies are the subject of the acknowledgement, and the NDA requires recipients to follow them.\\n- **People plan** (`../plans/09-people.md`) \u2014 the index, register, and NDA all reference Plan 09: People, which owns the personnel security controls these templates evidence.\\n- **Evidence register** (`../evidence-register.md`) \u2014 the index and register link here. The evidence register is where completed, dated templates are recorded as control evidence.\\n\\n## Conventions\\n\\n### Frontmatter\\n\\nEvery file carries YAML frontmatter with the same shape:\\n\\n| Field | Value |\\n| --- | --- |\\n| `type` | `control` (templates that become evidence), `register` (aggregation record), or `index` |\\n| `status` | Currently `draft` for all files |\\n| `updated` | ISO date of last edit |\\n| `owner` | `Assureworx directors` |\\n| `prepared_by` | `Farrel Alvis, Director and Lead Engineer` |\\n| `review` | Next review date \u2014 `2026-08-28` for most files; `2027-07-28` for the register |\\n\\n### Body conventions\\n\\n- Templates declare `Template ID`, `Version`, `Status`, `Prepared by`, and (where applicable) `Control coverage` at the top of the body.\\n- Control coverage uses ISO/IEC 27001:2022 Annex A identifiers (e.g., `A.5.15 to A.5.18`).\\n- Checklist items use Markdown task checkboxes (`- [ ]`) so a completed, checked file becomes the retained record.\\n- Blank fields for completion are explicit placeholders (`[DATE]`, `[RECIPIENT LEGAL NAME]`, underlined signature lines).\\n- Templates use `## Related` sections at the bottom for cross-links instead of a central dependency file.\\n\\n## Lifecycle Workflow\\n\\nThe templates map directly onto the personnel security lifecycle:\\n\\n1. **Pre-engagement** \u2014 the NDA is reviewed by Australian counsel, approved, and signed. Interview and screening evidence lives outside this module.\\n2. **Onboarding** \u2014 the onboarding checklist is executed across pre-access, first day, and first week. The NDA must already be signed; the policy acknowledgement is signed during the first week.\\n3. **Ongoing** \u2014 policy acknowledgements aggregate into the register as people join. Policy versions are recorded in the register's \\\"Policies acknowledged\\\" table.\\n4. **Offboarding** \u2014 the offboarding checklist is executed at the approved revocation time, including asset return and written reminder of continuing NDA obligations.\\n5. **Evidence** \u2014 completed checklists, signed acknowledgements, the register, and the NDA are stored in the restricted evidence repository and tracked in the evidence register.\\n\\n## Maintenance and Contribution\\n\\nTo add a new template to this module, follow the established pattern:\\n\\n1. Create a new Markdown file in `isms/templates/` with the standard frontmatter block (`type: control`, current date in `updated`, `owner`, `prepared_by`, and the next `review` date).\\n2. Assign the next document ID in the shared sequence \u2014 `ISMS TPL 005` for a template.\\n3. Add a `Control coverage` line with the applicable ISO/IEC 27001:2022 Annex A identifiers.\\n4. Use the checklist or agreement structure of the closest existing template \u2014 task checkboxes and explicit placeholders for completion fields.\\n5. Add a `## Related` section linking the relevant policies, plans, and `index.md`.\\n6. Register the new template in `index.md` with a one-line description.\\n\\nBefore changing the NDA, note the legal gate: substantive changes to the agreement wording require Australian legal review regardless of version number. Version bumps belong in the document history table, as the NDA's own history shows for versions 0.1 and 0.2.\",\"security-compliance-documentation\":\"# Security &amp; Compliance Documentation\\n\\n# Security &amp; Compliance Documentation\\n\\nThe Security &amp; Compliance Documentation module is the Assureworx platform's complete compliance surface. It combines two independent halves: the **public-facing legal documents** served to platform users, and the **ISMS vault** \u2014 a controlled Obsidian wiki (`isms/`) that substantiates ISO/IEC 27001:2022 certification and a SOC 2 Type II examination. The module is almost entirely document-driven. Apart from the PDF build script in `legal` and the React components in `src`, none of the sub-modules contain executable code; their behaviour is expressed through cross-referenced Markdown, JSON, and enforced document-control conventions.\\n\\n## Sub-modules\\n\\n### Public legal documents\\n| Module | Role |\\n|---|---|\\n| [legal](legal.md) | Canonical Privacy Statement and Terms of Use as HTML templates, standalone HTML mirrors, and a dependency-free Node script that renders A4 director-pack PDFs via a locally installed headless Chrome/Edge. |\\n| [src](src.md) | Next.js route group (`app/(legal)/`) that serves the two legal documents as static, server-rendered pages, with `LegalNav` active-link highlighting and a shared `LegalLinks` component. |\\n\\n### ISMS vault (`isms/`)\\n| Module | Role |\\n|---|---|\\n| [isms](isms.md) | Root of the ISMS wiki \u2014 the authoritative source for ISO 27001:2022 and SOC 2 Type II certification. Holds the `AGENTS.md` schema and conventions that keep the vault synchronised with code and the Azure estate. |\\n| [governance](governance.md) | Control layer of the vault. Defines the document identity and lifecycle contract (ISMS GOV 003), approval instruments for the first policy set, and the rule that no document may assert controls that do not exist. |\\n| [policies](policies.md) | Highest-level security governance: the two formal ISMS policies, the alert triage runbook, and the policy register. Maps documents to ISO/IEC 27001:2022 Annex A and SOC 2 criteria, and enforces the \\\"for review \u2260 operating control\\\" discipline. |\\n| [plans](plans.md) | Execution layer: ten workstream plans plus an index covering every gap to certification. Tasks are mapped to the ISO control, SOC 2 criterion, or ISO clause they satisfy; checkbox state is the single source of progress truth; completed tasks cite the proving artefact. |\\n| [assurance](assurance.md) | Factual backbone. A machine-readable control truth register (`control-truth-register.json`) whose claims cite the file and marker that prove them \u2014 or the policy that exposes the gap \u2014 keeping what is actually true distinct from what is intended or open. |\\n| [templates](templates.md) | Reusable record templates for the personnel lifecycle: onboarding, policy acknowledgement, confidentiality obligations, offboarding, and the team-wide acknowledgement register. |\\n| [evidence](evidence.md) | Storage boundary policy. Defines what may be committed to the standard repository, routes sensitive material to the restricted evidence repository, and records controlled access in a register. |\\n| [archive](archive.md) | Governance-controlled storage for superseded controlled Markdown sources, preserving the last approved version, the approval record, and a reference to the replacement. |\\n| [exports](exports.md) | Process specification for producing shareable Word releases of the Markdown vault for auditors, customers, and management. |\\n\\n## How the sub-modules fit together\\n\\nThe public legal layer is a simple pipeline: [legal](legal.md) maintains the canonical Privacy Statement and Terms of Use as embedded HTML templates, [src](src.md) serves those same documents as static Next.js pages, and `legal`'s build script regenerates them as board-ready PDFs.\\n\\nThe ISMS vault is organised in layers. [governance](governance.md) defines the rules every controlled document must follow, and acts on [policies](policies.md) and [plans](plans.md) \u2014 the intent layer of approved controls and remaining work. [assurance](assurance.md) verifies that layer by tying every material claim to a concrete artefact: source code, migrations, deployment configuration, policies, or known gaps. [templates](templates.md) generates people-control records governed by [evidence](evidence.md), which also polices the boundary between the standard repository and the restricted evidence repo. Superseded documents flow into [archive](archive.md) under the document-control procedure, and [exports](exports.md) turns the controlled vault state into auditor-ready releases. Together, `governance`, `policies`, and `assurance` form an integrity chain: nothing may be claimed as implemented unless an approved document or a code-level marker proves it.\\n\\n```mermaid\\nflowchart TB\\n    subgraph public[\\\"Public legal layer\\\"]\\n        L[legalCanonical Privacy &amp; Terms + PDF pipeline] --&gt; S[srcNext.js legal pages]\\n    end\\n\\n    subgraph vault[\\\"ISMS vault\\\"]\\n        G[governanceLifecycle &amp; approval rules] --&gt; POL[policiesApproved controls]\\n        G --&gt; ASS[assuranceControl truth register]\\n        G --&gt; ARC[archiveSuperseded documents]\\n        PL[plansGap workstreams] --&gt; ASS\\n        POL --&gt; ASS\\n        T[templatesPeople-control records] --&gt; EVI[evidenceStorage rules &amp; restricted repo]\\n        ASS --&gt; EVI\\n        ASS --&gt; EXP[exportsShareable releases]\\n    end\\n```\\n\\n## Key workflows\\n\\n1. **Public legal publishing** \u2014 edit the privacy/terms templates in `legal` \u2192 regenerate the standalone HTML and the `src` Next.js pages from the same embedded templates \u2192 run the build script to produce director-pack PDFs.\\n2. **Certification gap-closing loop** \u2014 `plans` track workstream progress against ISO 27001 and SOC 2 requirements \u2192 completed tasks cite the artefact that proves them \u2192 `assurance` encodes those claims in the control truth register with file-and-marker citations \u2192 `policies` and `templates` supply the required controlled documents and records.\\n3. **Record storage** \u2014 records from `templates` and citations from `plans`/`assurance` are governed by `evidence`'s storage rules; sensitive material is routed to the restricted repository and tracked in the access register.\\n4. **Document lifecycle** \u2014 the governance document-control procedure drives approval and versioning; superseded documents are deposited in `archive` with their prior approval record; `exports` regenerates shareable Word releases from the resulting controlled vault state.\",\"shared-frontend-utilities-data-access\":\"# Shared Frontend Utilities &amp; Data Access\\n\\n# Shared Frontend Utilities &amp; Data Access\\n\\nThe `frontend/src/lib` utilities form the shared infrastructure for all Assureworx portals: browser and server data access, client-side caching, page loading, formatting, user-facing copy, navigation, and small UI hooks. They sit between the Next.js App Router and the Express API and are used by every client and Server Component in the application.\\n\\n## Architecture\\n\\nAll data access goes through one of two paths:\\n\\n- Client Components talk to the API through `api.ts` using same-origin `fetch` or `XMLHttpRequest` for uploads.\\n- Server Components talk directly to the API through `server-api.ts`, forwarding the browser session cookie manually.\\n\\nClient data is cached, deduplicated, and optionally seeded from Server Component props via `use-api.ts`, so the first client render already has data and duplicate requests are avoided.\\n\\n```mermaid\\nflowchart LR\\n    A[Client Component] --&gt;|useApi| B[use-api.ts cache + dedupe]\\n    B --&gt;|api.get / api.upload| C[api.ts]\\n    C --&gt;|fetch / XHR /api/v1/*| D__Express[\\\"D((Express\\\"] API))\\n    E[Server Component] --&gt;|loadAppPage / loadAppPageOptional| F[load-app-page.ts]\\n    F --&gt;|serverGet| G[server-api.ts]\\n    G --&gt;|fetch API_ORIGIN /api/v1/*| D\\n    G --&gt;|initialData / seedApiCache| B\\n    F --&gt;|traceAppPage| H[withServerTrace AsyncLocalStorage]\\n    C -.-&gt;|dev network spans| I[perf store]\\n```\\n\\n## API conventions and error model\\n\\nThe Express API wraps every response in an `ApiEnvelope` from `@assureworx/shared-types`:\\n\\n```ts\\n{ success: true, data: T, meta?: ApiSuccess[\\\"meta\\\"] }\\n// or\\n{ success: false, error: { code: string, message: string, details?: unknown[] } }\\n```\\n\\nAll request helpers parse this envelope. A non-JSON response becomes a `BAD_RESPONSE` error, and `success: false` throws with the server's `code`, `message`, and `details`.\\n\\nTwo error classes carry the same shape:\\n\\n- `ApiClientError` \u2014 thrown by the browser client in `api.ts`\\n- `ServerApiError` \u2014 thrown by the server client in `server-api.ts`\\n\\nBoth expose `status`, `code`, `message`, and `details`. A `status` of `0` means the request never got an HTTP response: a network failure, an abort, or a cancelled upload.\\n\\n## Browser API client (`api.ts`)\\n\\n`api.ts` exports a small `api` object:\\n\\n```ts\\nexport const api = {\\n  get: (path: string, signal?: AbortSignal): Promise,\\n  getWithMeta: (path: string, signal?: AbortSignal): Promise&lt;{ data: T; meta?: Meta }&gt;,\\n  post: (path: string, body?: unknown): Promise,\\n  put: (path: string, body?: unknown): Promise,\\n  patch: (path: string, body?: unknown): Promise,\\n  del: (path: string): Promise,\\n  upload: (path: string, body: FormData, options?: UploadOptions): Promise,\\n};\\n```\\n\\nRequests go to `/api/v1${path}` with `credentials: \\\"same-origin\\\"`. JSON bodies are stringified and sent with `Content-Type: application/json`; `undefined` bodies omit the header entirely.\\n\\nCancellation is supported through `AbortSignal` on `get` and `getWithMeta`. When the caller cancels, the failure is converted to an `ApiClientError` with code `REQUEST_CANCELLED` so it is never counted as an error against the API.\\n\\n### Uploads\\n\\n`api.upload` uses `XMLHttpRequest` rather than `fetch` so it can report upload progress. It:\\n\\n- posts `FormData` to `/api/v1${path}` with credentials\\n- forwards cancellation from `options.signal` to `xhr.abort()`\\n- throttles `onProgress` callbacks to at most one per 100 ms per upload, ignoring duplicate percentages\\n- always delivers the final `100` percent in `onload`\\n- rejects with `UPLOAD_CANCELLED` on abort and `NETWORK_ERROR` on network failure\\n\\nThe throttling exists because every progress callback becomes a `setState` in the consuming form; unthrottled uploads from multiple crew members are the dominant source of frontend jank.\\n\\n## Server-side API access (`server-api.ts`)\\n\\n`server-api.ts` is marked `import \\\"server-only\\\"` and can only be used in Server Components and server code.\\n\\nIt reads the browser session cookie from `next/headers` and forwards it manually:\\n\\n```ts\\nconst jar = await cookies();\\nconst cookieHeader = jar.getAll().map((c) =&gt; `${c.name}=${c.value}`).join(\\\"; \\\");\\n```\\n\\nThe target origin comes from `API_ORIGIN`, defaulting to `http://localhost:4000`. Every request uses `cache: \\\"no-store\\\"` because session-scoped responses must never be shared across users or requests.\\n\\nExported surface:\\n\\n```ts\\nexport const serverApi = {\\n  get: (path: string): Promise,\\n  getWithMeta: (path: string): Promise&lt;{ data: T; meta?: Meta }&gt;,\\n  post: (path: string, body?: unknown): Promise,\\n};\\n\\nexport const serverGet = serverApi.get;\\n```\\n\\n### Server tracing\\n\\n`withServerTrace(fn)` groups every server API call made while loading one page into a single numbered sequence. It is dev-only.\\n\\nThe implementation uses `AsyncLocalStorage` because:\\n\\n- a module-level counter would be shared across concurrent users\\n- `React.cache()` returns a fresh value outside a render scope, so it cannot accumulate a per-request tally\\n\\n`traceServerRequest` logs each call with its duration and offset into the render. If a single render makes six or more server API calls, it logs a warning about a possible waterfall in the page loader or root layout.\\n\\n## Server page loaders (`load-app-page.ts`)\\n\\n`load-app-page.ts` is the Server Component entry point for authenticated page data.\\n\\n```ts\\nexport async function loadAppPage(path: string): Promise;\\nexport async function loadAppPageOptional(path: string): Promise;\\nexport function traceAppPage(fn: () =&gt; Promise): Promise;\\nexport { withServerTrace };\\n```\\n\\n- `loadAppPage` calls `serverGet` and redirects to `/auth/login` on a 401. All other failures rethrow so `error.tsx` can render.\\n- `loadAppPageOptional` is for optional secondary fetches. It returns `null` only on a clean 403 or 404, and redirects on 401. It deliberately does **not** swallow rate limits, auth failures, or server faults: returning `null` for those would render a silently empty page that is indistinguishable from \\\"you genuinely have no requests\\\".\\n- `traceAppPage` is a convenience wrapper around `withServerTrace` for page loaders that want to see their whole waterfall as one group.\\n\\n## Client data hook (`use-api.ts`)\\n\\n`use-api.ts` is the browser-side data hook used by almost every client screen. It is a small GET-on-mount hook with three layers on top of `api.get`:\\n\\n- **in-flight deduplication** \u2014 identical GETs for the same path share one promise\\n- **tab-lifetime cache** \u2014 the last response per path is available immediately on re-mount\\n- **server seeding** \u2014 data already fetched by a Server Component is reused by the first client mount\\n\\n### Cache mechanics\\n\\nThe module keeps several module-level structures:\\n\\n| Structure | Purpose |\\n| --- | --- |\\n| `inflight` | Dedupes identical GETs in flight |\\n| `cache` | Last known response per path, kept for the life of the tab |\\n| `cacheWrittenAt` | Wall-clock time for shell-path TTL decisions |\\n| `freshFromServer` | Paths seeded from a Server Component on the current navigation |\\n| `SHELL_PATHS` | `/notifications`, `/auth/entra/status` \u2014 shell chrome shared across routes |\\n| `SHELL_STALE_MS` | 60 seconds; shell paths revalidate at most once a minute on soft navigation |\\n\\n### Public functions\\n\\n```ts\\nexport function useApi(path: string | null, options?: { initialData?: T });\\nexport function seedApiCache(path: string, data: unknown): void;\\nexport function prefetchApi(path: string): void;\\nexport function invalidateApiCache(path?: string): void;\\n```\\n\\n- `useApi` returns `{ data, loading, error, refresh }`.\\n- `refresh()` refetches silently and writes through to the cache; use it after mutations.\\n- `seedApiCache` stores a Server Component payload and marks it as fresh for the current navigation. `useApi` calls this automatically when `initialData` is passed.\\n- `prefetchApi` starts a GET immediately, before the component that needs it mounts. Failures are swallowed because the real `useApi` call still surfaces an error.\\n- `invalidateApiCache` drops cache entries after a mutation. With no argument it clears the entire cache.\\n\\n### Lifecycle behaviour\\n\\nWhen `useApi` mounts:\\n\\n1. If `initialData` is provided, it seeds the cache and `freshFromServer`.\\n2. If a cached value exists, it is adopted synchronously so a route change shows data instead of a skeleton.\\n3. If the path came from the server on this navigation, the flag is consumed and the duplicate GET is skipped. Later remounts revalidate normally.\\n4. If the path is a shell path and the cache entry is younger than `SHELL_STALE_MS`, the remount trusts the cache.\\n5. Otherwise it calls the underlying `load`.\\n\\nBecause the cache is module-level, navigating away from `/dashboard` and back does not start from zero.\\n\\n## Formatting helpers (`format.ts`)\\n\\n`format.ts` provides display formatting in Australian English, following brand conventions.\\n\\n```ts\\nexport const EMPTY = \\\"\u2013\\\";\\nexport function formatDate(value: string | null | undefined): string;\\nexport function formatShortDate(value: string | null | undefined): string;\\nexport function formatDateTime(value: string | null | undefined): string;\\nexport function formatRelative(value: string): string;\\nexport function formatAbn(abn: string | null | undefined): string;\\nexport function daysUntil(value: string | null | undefined): number | null;\\nexport function plural(n: number, word: string, pluralWord?: string): string;\\nexport function formatMoney(minorUnits: number, currency?: string): string;\\n```\\n\\nNotable points:\\n\\n- Missing or invalid dates return `EMPTY` (`\u2013`), never a thrown error.\\n- `formatAbn` normalises an 11-digit ABN into `51 824 753 556` style grouping; non-11-digit input is returned unchanged.\\n- `formatMoney` expects integer minor units from the API and divides by 100 only at display time. Dividing earlier is how rounding errors reach a receipt.\\n- `formatRelative` returns relative text like `5 min ago` or `3 days ago`, falling back to a short date after 30 days.\\n\\n## Copy helpers (`copy.ts`)\\n\\n`copy.ts` centralises user-facing copy and enforces the brand rule: never join clauses with em dashes or `--`.\\n\\n- `withPipes(text)` normalises legacy em dashes, en dashes, double hyphens, and middle dots to `\\\" | \\\"`.\\n- `evidenceFileAction(status)` returns `\\\"Upload\\\"` when status is `missing`, otherwise `\\\"Replace\\\"`.\\n- `EVIDENCE_COPY` contains the fixed upload copy strings and the dynamic `panelTitle(documentName)`.\\n\\nThe status-banner `\\\"Fix\u2026\\\"` CTAs are deliberately not file actions, and `\\\"Verify\\\"` is reserved for ABN/identity checks rather than file pickers.\\n\\n## Navigation (`nav.ts`)\\n\\n`nav.ts` defines the sidebar/product navigation for each portal and role.\\n\\n```ts\\nexport type NavItem = {\\n  href: string;\\n  label: string;\\n  icon: \\\"dashboard\\\" | \\\"trades\\\" | \\\"projects\\\" | \\\"jobs\\\" | \\\"requests\\\" | \\\"training\\\"\\n       | \\\"reports\\\" | \\\"settings\\\" | \\\"profile\\\" | \\\"team\\\" | \\\"documents\\\"\\n       | \\\"billing\\\" | \\\"support\\\";\\n};\\n```\\n\\nAvailable navigation lists:\\n\\n- `principalNav` \u2014 Principal Contractor workspace\\n- `platformNav` \u2014 Platform Admin ops console\\n- `tradeNav` \u2014 Trade company workspace\\n- `employeeNav` \u2014 salaried employees verifying themselves, not running the company workspace\\n\\n```ts\\nexport function navForRole(role: PortalRole, appRole?: AppRole): NavItem[];\\nexport function homeForRole(role: PortalRole, appRole?: AppRole): string;\\n```\\n\\n`appRole` wins when present: `platform_admin` gets the platform console. Otherwise `principal` gets the principal nav, `trade_individual` gets the employee nav, and every other trade role gets the trade nav. `homeForRole` follows the same precedence: `/admin`, `/dashboard`, or `/profile`.\\n\\n## UI hooks\\n\\nThe remaining client hooks are tiny and deliberate.\\n\\n### `useAppHistory`\\n\\nMaintains an in-app navigation stack in `sessionStorage` under `aw-app-history`. It mirrors path changes and drives `window.history.back()` / `forward()` from page-body controls.\\n\\nReturns `{ canBack, canForward, back, forward }`.\\n\\nThe stack supports browser back/forward traversal and truncates the forward stack when a new route is visited.\\n\\n### `useBeforeUnload`\\n\\n```ts\\nexport function useBeforeUnload(shouldWarn: boolean): void;\\n```\\n\\nInstalls the browser's native `beforeunload` confirmation when `shouldWarn` is true. Used for volatile work like an in-progress File upload that browsers will not restore.\\n\\n### `useMediaQuery`\\n\\n```ts\\nexport function useMediaQuery(query: string): boolean;\\n```\\n\\nTracks a CSS media query with `useSyncExternalStore`. The `MediaQueryList` is cached per query string because `getSnapshot` is called on every render and must return a stable value. The server snapshot is `false`, so SSR always renders the desktop/default branch and the first client render already reflects the real viewport.\\n\\n### `usePresence`\\n\\n```ts\\nexport function usePresence(open: boolean, exitMs?: number): { present: boolean; shown: boolean };\\n```\\n\\nKeeps a surface mounted through its exit animation. `present` is true while mounted; `shown` toggles the CSS open class. When opening, `shown` is delayed by 16 ms so the enter transition can run. When closing, `present` stays true for `exitMs` (default 180 ms) and then becomes false.\\n\\n## Integration with the rest of the app\\n\\nA typical page flow is:\\n\\n1. A Server Component calls `loadAppPage` or `loadAppPageOptional` to fetch page data.\\n2. It wraps parallel fetches in `traceAppPage` during development to expose waterfalls.\\n3. The fetched data is passed to a client component as `initialData`.\\n4. `useApi` seeds that data into the tab cache and skips the duplicate GET on first mount.\\n5. After a mutation, the component calls `invalidateApiCache` if unrelated paths changed, then `refresh()` to re-read the affected path.\\n\\nAll network activity is routed through `api.ts` on the client and `server-api.ts` on the server, so error handling, cookie forwarding, and dev performance tracing stay consistent across every screen.\",\"support-reporting-admin-backend-src\":\"# Support, Reporting &amp; Admin \u2014 backend-src\\n\\n# Support, Reporting &amp; Admin \u2014 Backend\\n\\n## Overview\\n\\nThis module contains the platform's back-office surface: the platform-admin console, tenant reporting dashboards, the support ticketing system, and the background assurance sweep that keeps verification statuses current as time-based evidence expires.\\n\\nThe code lives in four main areas:\\n\\n| Area | Files | Responsibility |\\n|---|---|---|\\n| Admin | `modules/admin/index.ts` | Platform-admin-only endpoints: org provisioning, actor management, platform metrics, live schema ERD |\\n| Reporting | `modules/reporting/index.ts` | Tenant dashboards, audit timeline + export, manual status recompute |\\n| Support | `modules/support/index.ts`, `modules/support/sla.ts` | Support tickets, notes, SLA evaluation, breach stamping |\\n| Assurance | `modules/assurance/scheduler.ts`, `modules/assurance/sweep.ts` | Background job that finds and recomputes stale verification statuses |\\n| Schema ERD | `shared/services/schema-erd.ts` | Builds the live ERD model served by the admin schema endpoint |\\n\\nThe three Express routers (`adminRouter`, `reportingRouter`, `supportRouter`) are mounted by `server.ts` and share the `requireAuth` \u2192 `resolveTenantContext` \u2192 `requireRole` middleware chain, the error-bound async handler `h`, and the `audit` service.\\n\\n## Architecture\\n\\n```mermaid\\ngraph TB\\n  Admin[adminRouter \u2014 platform admin console]\\n  Reporting[reportingRouter \u2014 dashboards + audit]\\n  Support[supportRouter \u2014 tickets + SLA]\\n  Timer[assurance scheduler] --&gt; Sweep[runAssuranceSweep]\\n  Sweep --&gt; Status[evaluateAndPersistStatus]\\n  Reporting --&gt; Status\\n  Admin --&gt; Schema[buildSchemaErd / provisioning services]\\n  Support --&gt; Sla[evaluateSla / slaDeadlines]\\n  DB[(PostgreSQL \u2014 core schema, RLS)]\\n  Admin --&gt; DB\\n  Reporting --&gt; DB\\n  Support --&gt; DB\\n  Sweep --&gt; DB\\n```\\n\\nThe diagram highlights the key shared dependency: `evaluateAndPersistStatus` is the single status-recomputation engine, invoked both by the reporting module's manual sweep endpoint and by the automated assurance sweep. The admin and support modules delegate to dedicated services (`buildSchemaErd`, membership/closure helpers, SLA evaluation) rather than duplicating that logic.\\n\\n## Data Access Modes\\n\\nAll database access in this module goes through one of four pool helpers, and choosing the right one is the most important correctness decision in each handler:\\n\\n| Helper | Effect | Used here |\\n|---|---|---|\\n| `withBypass(tx =&gt; ...)` | Runs with RLS bypassed. Does **not** grant access to revoked schemas (see admin `/schema` below). | All admin routes (platform-only anyway); platform-admin support routes; the assurance sweep; global audit queries |\\n| `withActor(actorId, tx =&gt; ...)` | Sets the RLS session to the given actor. RLS then restricts rows to that actor's visibility. | Support: requester-scoped ticket access |\\n| `withTenant(actorId, scopeOrgId, tx =&gt; ...)` | Sets actor + tenant org scope. | Reporting dashboard and manual sweep |\\n| `withScope(scopePartyId, tx =&gt; ...)` | Sets a scope party without an actor context. | Reporting audit timeline/export for tenant scopes |\\n\\n## Route Reference\\n\\n### Admin router \u2014 global `platform_admin` gate\\n\\n```ts\\nadminRouter.use(requireAuth, resolveTenantContext, requireRole(\\\"platform_admin\\\"));\\n```\\n\\nEverything except `GET /health` requires the `platform_admin` role.\\n\\n| Method | Path | Purpose |\\n|---|---|---|\\n| GET | `/health` | Liveness probe |\\n| GET | `/schema` | Live schema ERD over `information_schema` + `pg_constraint`, overlaid with logical `meta.map_dict` names |\\n| POST | `/principal-contractors` | Provision a Principal Contractor org plus its first admin (ADR-0010) |\\n| GET | `/overview` | Platform aggregate counts + 20 most recent audit events |\\n| GET | `/principal-contractors` | List up to 200 PCs with ABN, trade count, seat count, admin email |\\n| GET | `/organisations` | Search orgs by name/ABN with `kind` filter |\\n| GET | `/actors` | List actors with JSON-aggregated memberships |\\n| PATCH | `/actors/:id` | Enable or disable an actor |\\n| POST | `/principal-contractors/:orgId/resend-setup` | Invalidate pending seats and resend the set-up invite |\\n| POST | `/parties/:partyId/closure/cancel` | Cancel a person-account closure request within its grace window |\\n\\n### Reporting router \u2014 per-route roles\\n\\n| Method | Path | Required roles | Purpose |\\n|---|---|---|---|\\n| GET | `/health` | \u2014 | Liveness probe |\\n| GET | `/dashboard` | `tier1_admin`, `tier1_user` | Tenant dashboard aggregation |\\n| GET | `/events` | `tier1_admin`, `tier1_user`, `platform_admin` | Tenant audit timeline (keyset paginated) |\\n| GET | `/events/export` | `tier1_admin`, `platform_admin` | Full audit export (JSON or CSV, capped) |\\n| POST | `/assurance/sweep` | `tier1_admin`, `platform_admin` | Manual, tenant-scoped status recompute |\\n\\n### Support router \u2014 per-route ownership/role logic\\n\\nThe support router applies `requireAuth` + `resolveTenantContext` globally but no role gate; each route decides access itself.\\n\\n| Method | Path | Access | Purpose |\\n|---|---|---|---|\\n| GET | `/health` | \u2014 | Liveness probe |\\n| GET | `/monitor` | `platform_admin` only | Global SLA breach/due-soon counts |\\n| GET | `/` | Owner or `platform_admin` | List tickets; `status` filter, `breached=1` for admins |\\n| POST | `/` | Any authenticated user | Create ticket |\\n| GET | `/:id` | Owner or `platform_admin` | Ticket detail + notes (internal notes admin-only) |\\n| POST | `/:id/notes` | Owner or `platform_admin` | Add note; internal notes only by admins |\\n| PATCH | `/:id` | `platform_admin` only | Update status/priority, recompute SLA deadlines |\\n\\n## Admin Module\\n\\n### Principal Contractor provisioning (`POST /principal-contractors`)\\n\\nThis is the platform's org bootstrap flow. It runs entirely inside a `withBypass` transaction, in this order:\\n\\n1. **Validate** \u2014 Zod-parsed body; ABN (optional) is stripped to digits and checksum-checked via `isValidAbn`. Invalid ABN \u2192 `badRequest(\\\"INVALID_ABN\\\", ...)`.\\n2. **Conflict checks** \u2014 an existing actor with the same lowercased email, or an existing `D01` party ident with the same ABN, raises `conflict`.\\n3. **Create org party** \u2014 `kind_cd = 'K01'` (Principal Contractor), `class_cd = 'E01'`, `status_cd = 'S01'`; the ABN is stored as a `D01` party ident.\\n4. **Create person + actor** \u2014 a person party (`K03`/`M02`), a `D03` email ident, and an actor row using `actorSubjectForEmail(email)`.\\n5. **Grant role** \u2014 the actor gets a `R02` grant on the org, i.e. the org's first tier-1 admin. `invalidateMembershipCache(actorId)` is called so membership is immediately visible.\\n6. **Start a complimentary subscription** \u2014 a `t_ledger` row with `plan_cd = 'PL2'`, status `LS1`, valid for one year from today.\\n7. **Create a setup seat** \u2014 `createSetupSeat` returns a token + seat ID used for the emailed invitation.\\n8. **Audit** \u2014 three events: `pc.provisioned`, `role.granted`, `subscription.started`.\\n9. **Email** \u2014 `emailSeat` with `kind: \\\"pc_setup\\\"`; the response includes `inviteUrl` from `seatInviteUrl(result.seatToken)`.\\n\\nNote the hardcoded codes (`K01`, `R02`, `PL2`, `D01`, `D03`) \u2014 if the code tables change, these literals must be updated in sync.\\n\\n### Resending setup (`POST /principal-contractors/:orgId/resend-setup`)\\n\\nLooks up the org and its first `R02` grant holder, soft-deletes any still-pending `U01` setup seats (marking them `U04` + `deleted_at`), creates a fresh seat, and re-emails the invitation. This gives admins a way to recover from lost or expired invites without manual seat surgery.\\n\\n### Actor enable/disable (`PATCH /actors/:id`)\\n\\n- Guards against an admin disabling their own account (`SELF_DISABLE`).\\n- Disabling sets `status_cd = 'RS2'` and **bumps `session_ver`**, which invalidates all of that actor's existing sessions.\\n- Enabling restores `RS1` without touching `session_ver`.\\n- Both transitions are audited (`actor.disabled` / `actor.enabled`) with the actor's email in the delta.\\n\\n### Schema ERD (`GET /schema`)\\n\\n`buildSchemaErd` queries `information_schema.tables`, `information_schema.columns`, primary keys, and `pg_constraint` foreign keys (with `json_agg` so FK column arrays arrive as real JSON arrays). It then overlays logical names and meanings from `meta.map_dict`.\\n\\nA subtlety documented in the service: `withBypass` lifts RLS but does **not** grant `USAGE` on the `meta` schema, which is revoked from `app_rt`. The service therefore reads meta through a SECURITY DEFINER projection, `core.fn_map_dict()`. Migration versions come from `core.fn_schema_migrations()`. Tables without a meta row fall back to `DOMAIN_BY_TABLE` for domain classification and `FALLBACK_MEANING` for logical naming (e.g. `t_pass_dl`, `t_pass_device`). The `asStringArray` helper normalizes PG's `\\\"{a,b}\\\"` text-array form into JS arrays.\\n\\n## Reporting Module\\n\\n### Dashboard (`GET /dashboard`)\\n\\nThe dashboard is assembled in a single `withTenant(actorId, scope, ...)` transaction from six query groups:\\n\\n- **Network** \u2014 `getNetworkParties(tx, scope)` supplies the party list. Companies (`K02`) and individuals (`K03`) are counted and bucketed by verified status: `verified`, plus a split of `awaiting` (pending) and `outstanding` (`action_required` + `suspended`) via `bumpSplit`. `byStatus` is a simple status \u2192 count list.\\n- **Trade types** \u2014 `t_party_tag` joined to party status, grouped by tag code, mapped through `TAG_NAMES`, sorted by count desc, top 8.\\n- **Evidence attention** \u2014 `getEvidenceForParties` rows are bucketed into `expiring`, `missing`, and `failed` (expired or rejected). Buckets are sorted by severity rank (failed first, then missing, then expiring), then by `daysLeft` ascending. The list is sliced to 24 items; `expiringSoon` is the top 8 with a non-null artifact ID.\\n- **Portfolio** \u2014 project/site counts from `t_node` (`N01`/`N02`) and link readiness counts from `t_link`, mapped through `LINK_STATE` (`J01`/`J04` \u2192 pending, `J02` \u2192 ready, `J03` \u2192 blocked).\\n- **Activity** \u2014 the 30 most recent `t_event` rows scoped to the tenant, summarized via `summariseVerb`.\\n- **Requests &amp; top companies** \u2014 open `U01` requests; company readiness aggregates from `t_link`, top 6 by total assignments.\\n\\n### Audit timeline (`GET /events`)\\n\\nKeyset-paginated by `before` (an ISO timestamp) with `limit` (default/max 200). The response is an `AuditEventPage`; `nextBefore` is the last item's `at` value, or `null` when the page is exhausted.\\n\\nScope handling is role-dependent:\\n\\n| Caller | `?scope=` supplied | Access mode |\\n|---|---|---|\\n| Tenant user (`tier1_admin`/`tier1_user`) | ignored | `withScope(ctx.scopeOrgId)` \u2014 locked to own org |\\n| Platform admin | no | `withBypass` \u2014 global |\\n| Platform admin | valid UUID | `withScope(scope)` |\\n\\n### Audit export (`GET /events/export`)\\n\\nAdmins only, guarded by `exportLimiter`. Supports `from`/`to` filters and `format=json|csv` (default csv). The export is capped at 10,000 rows: JSON responses include `truncated: rows.length &gt;= 10_000`; CSV responses stream an `assureworx-audit-.csv` attachment via `eventsToCsv`. Choose JSON when you need the raw rows programmatically, CSV for spreadsheets.\\n\\n### Manual assurance sweep (`POST /assurance/sweep`)\\n\\nBounded, admin-only, guarded by `sweepLimiter`. Runs inside `withTenant` and calls `evaluateAndPersistStatus(tx, partyId, ctx.actorId)` for every party in the tenant network. Passing the actor ID (unlike the background job, which passes `null`) means any status transitions and emitted events are attributed to the acting admin. The response reports `{ evaluated, parties }`.\\n\\n## Support Module\\n\\n### Ticket model\\n\\nTickets are stored in `t_sr` with a human-readable reference generated from a sequence (`AWX-SR-`). Statuses map to `T01` (open), `T02` (in_progress), `T03` (waiting), `T04` (resolved), `T05` (closed). Kinds and priorities map through `SR_KIND_CD` / `SR_PRIORITY_CD`.\\n\\nCreating a ticket (`POST /`) inserts the `t_sr` row with SLA deadlines precomputed from `slaDeadlines(priority)`, audits `support.created`, and inserts a `t_msg` row (`kind_cd = 'support.opened'`, channel `Z01`, status `ZS2`) so the ticket surfaces in an inbox.\\n\\nThere is no formal state-machine enforcement on `PATCH /:id` \u2014 any status can transition to any other. What the patch does enforce:\\n\\n- The first admin to touch a ticket becomes the `assignee_actor_id` (via `COALESCE`).\\n- Leaving `T01` sets `first_response_at` if not already set.\\n- Entering `T04`/`T05` stamps `resolved_at`; `T05` additionally stamps `closed_at`.\\n- Changing priority recomputes both SLA deadlines.\\n\\n### Ownership and authorization\\n\\n- A regular user can only see tickets where `requester_actor_id` matches their own actor ID. This is enforced twice: by RLS (queries run under `withActor`) and by app-layer asserts in `loadDetail` / `addNote` (defence in depth).\\n- Platform admins bypass RLS (`withBypass`) and see everything, including internal notes.\\n- Only platform admins can add internal notes (`internal: true`) or update status/priority.\\n- In `loadDetail`, the ownership check happens **before** `stampBreaches` \u2014 the code deliberately avoids touching a row the caller is not entitled to see, even under a mis-applied RLS session.\\n\\n### First response and notes\\n\\n`addNote` handles the response-clock logic: the first **non-internal** note by anyone other than the requester stamps `first_response_at` and advances `T01` \u2192 `T02`. Any other note just bumps `updated_at`. Every note is audited (`support.note_added`) with the `internal` flag in the delta.\\n\\n### SLA engine (`support/sla.ts`)\\n\\nSLAs are calendar-hour based (Phase 1; business-hours calendars are a later replacement):\\n\\n| Priority | Response | Resolve |\\n|---|---|---|\\n| critical | 1h | 4h |\\n| high | 4h | 24h |\\n| normal | 8h | 72h |\\n| low | 24h | 120h |\\n\\n`evaluateSla` returns per-dimension states: `met`, `breached`, `due_soon`, or `ok`. A dimension is `due_soon` when it is within the 2-hour `DUE_SOON_MS` window of its deadline. Response SLA is `met` when `first_response_at &lt;= sla_response_due_at`; resolve SLA is `met` when the ticket was resolved/closed by `sla_resolve_due_at`.\\n\\n`stampBreaches` is the persistence counterpart. It runs on every ticket read/list and on every mutation, and idempotently records `response_breached_at` / `resolve_breached_at`:\\n\\n- Response breach: no first response and now past the response deadline, **or** first response arrived late.\\n- Resolve breach: ticket still open (`T01`\u2013`T03`) past the resolve deadline, **or** resolved late.\\n\\n`COALESCE` ensures a breach timestamp is only ever written once, preserving the original breach time.\\n\\n### Monitor (`GET /monitor`)\\n\\nPlatform-admin-only aggregated view over all non-deleted tickets: counts of open/in-progress/waiting, currently breached (by deadline or already-stamped breach), due soon (within 2 hours of the resolve deadline, no breach yet), and resolved today.\\n\\n## Assurance Sweep\\n\\n### Why it exists\\n\\nVerification status is normally recomputed eagerly by event paths (`evaluateAndPersistStatus` on evidence/identity/payment changes). Calendar expiry has no event: a white card or ID that passes its `expires_on` overnight would leave party status and link readiness stale until the next human action. The sweep finds exactly those parties and recomputes.\\n\\n### Candidate selection (`listAssuranceCandidates`)\\n\\nOne query with three `UNION` branches, all joined to non-deleted parties:\\n\\n1. **Expired evidence** \u2014 `t_artifact` rows in `F02`/`F04` status with a non-null `expires_on` before today.\\n2. **Expired identity document** \u2014 the most recent `C02` identity check (status `H02`, not superseded) whose `doc_expires_on` is before today.\\n3. **Withdrawn or lapsed consent** \u2014 the most recent `C02` check whose latest `N01` consent (by `seq_n` desc) is not `granted` (`CONSENT_ACTION.granted`) or has an `expires_at` in the past.\\n\\nResults are deduplicated, ordered by party ID, and capped by `batchLimit` (config default, clamped to 1\u20131000). Each candidate is then passed to `evaluateAndPersistStatus(tx, partyId, null)` \u2014 note the null actor: background recomputes are unattributed.\\n\\n### Scheduling (`assurance/scheduler.ts`)\\n\\nThe scheduler follows the same shape as the identity/retention timers:\\n\\n- Jittered interval (0.85\u20131.15\u00d7) via `jitter`, `unref()`'d so it never holds the process open.\\n- Skip-if-running guard so overlapping sweeps cannot occur.\\n- A first pass roughly 2 minutes (jittered) after boot, so overnight expiries don't wait a full interval.\\n- Disabled with `ASSURANCE_SWEEP_INTERVAL_MIN=0`.\\n- `startAssuranceSweepTimer()` is called at boot from `server.ts`; `stopAssuranceSweepTimer()` is called on shutdown.\\n\\n## Shared Services &amp; Integration Points\\n\\n| Service | Used by | Role |\\n|---|---|---|\\n| `evaluateAndPersistStatus` (verified-status) | Reporting manual sweep, assurance sweep | Single source of truth for status recomputation |\\n| `getNetworkParties` (network) | Reporting dashboard + manual sweep | Resolves the tenant's linked parties |\\n| `getEvidenceForParties` (evidence) | Reporting dashboard | Evidence status bucketing |\\n| `listAuditEvents` / `exportAuditEvents` / `eventsToCsv` / `summariseVerb` (audit-query) | Reporting events + export | Audit retrieval and CSV formatting |\\n| `createSetupSeat` / `emailSeat` / `seatInviteUrl` (memberships) | Admin provisioning + resend | Setup invitation lifecycle |\\n| `cancelAccountClosure` (retention/closure) | Admin closure cancellation | Grace-window closure reversal |\\n| `audit` (audit) | All three routers | Event persistence with delta |\\n| `isValidAbn`, `actorSubjectForEmail`, `invalidateMembershipCache` | Admin | ABN checksum, Entra subject generation, membership cache invalidation |\\n| `config.assurance` | Assurance | `intervalMinutes` and `batchLimit` knobs |\\n\\n## Configuration\\n\\n| Setting | Effect |\\n|---|---|\\n| `ASSURANCE_SWEEP_INTERVAL_MIN` (`config.assurance.intervalMinutes`) | Sweep interval in minutes; `0` disables the timer entirely |\\n| `config.assurance.batchLimit` | Maximum candidates per sweep run (clamped to 1000) |\\n\\nRate limits are applied per-endpoint via the shared middleware: `supportLimiter` on ticket creation, `exportLimiter` on audit export, `sweepLimiter` on the manual assurance sweep.\",\"support-reporting-admin-frontend-src\":\"# Support, Reporting &amp; Admin \u2014 frontend-src\\n\\n# Support, Reporting &amp; Admin \u2014 Frontend\\n\\n## Overview\\n\\nThis module groups three operator-facing surfaces behind role-gated routes:\\n\\n- **Platform console** (`/admin`) \u2014 an 8-tab operations dashboard for `platform_admin` users. Covers organisations, accounts, document review, payments operations, schema introspection, principal-contractor provisioning, and Azure resource lineage.\\n- **Support mailbox** (`/support`) \u2014 a two-pane ticket system. End users raise and track tickets; platform admins get a full mailbox with SLA monitoring, status/priority controls, and internal notes.\\n- **Reports** (`/reports`) \u2014 a placeholder page gated to `principal` users, reserved for async compliance/expiry/training/incident reports.\\n\\nAll three routes follow the same server-first pattern: the server component checks the session, optionally pre-fetches initial data, and the client component hydrates the interactive UI.\\n\\n## Route structure and access control\\n\\n| Route | Server component | Client component | Required portal |\\n|---|---|---|---|\\n| `/admin` | `AdminPage` | `AdminClient` | `platform_admin` |\\n| `/support` | `SupportPage` | `SupportClient` | any authenticated |\\n| `/reports` | `ReportsPage` | \u2014 | `principal` |\\n\\nServer-side gating uses `requirePortal(...)` in each `page.tsx`. `AdminClient` additionally re-checks `me.active?.role` in a `useEffect` and calls `router.replace(homeFor(me))` for non-admins \u2014 this closes the gap where the admin page is reached via client-side navigation after auth state changes, and returns `null` from render until the redirect lands.\\n\\nInitial data is fetched server-side with `loadAppPageOptional(path)` and passed as a prop so the client can skip its first network round-trip:\\n\\n- `AdminPage` passes `initialOverview` from `GET /admin/overview`.\\n- `SupportPage` passes `initialTickets` from `GET /support`.\\n\\n```mermaid\\ngraph TD\\n    AP[admin/page.tsxserver] --&gt; AC[AdminClient]\\n    SP[support/page.tsxserver] --&gt; SC[SupportClient]\\n    RP[reports/page.tsxserver] --&gt; CP[ComingSoonPage]\\n    AC --&gt; PC[PaymentsConsoledynamic import]\\n    AC --&gt; SE[SchemaErdStudiodynamic import]\\n    AC --&gt; AL[AzureLineageStudiodynamic import]\\n    AC --&gt;|GET/POST/PATCH| AAPI[/admin &amp; artifact endpoints/]\\n    SC --&gt;|GET/POST/PATCH| SAP[/support endpoints/]\\n```\\n\\n## Admin console (`AdminClient`)\\n\\n`AdminClient` is a single client component rendering a tabbed console. The tabs are defined in the `TABS` constant: `overview`, `orgs`, `accounts`, `review`, `payments`, `erd`, `provision`, `azure`.\\n\\nHeavy tab content is code-split with `next/dynamic` so it stays out of the default chunk:\\n\\n- `SchemaErdStudio` \u2014 ERD canvas (SSR disabled, `SkeletonRows` fallback)\\n- `AzureLineageStudio` \u2014 Azure resource board (same pattern)\\n- `PaymentsConsole` \u2014 payment operations (same pattern, and it fetches its own data on mount)\\n\\n### Data loading per tab\\n\\nA single `useEffect` watches `tab` and dispatches to the appropriate loader. Each loader sets `loading`/`error` and stores its result in component state. `overview` is only fetched if not already populated; the other tabs refetch each time they are visited.\\n\\n| Tab | Loader | Endpoint | State |\\n|---|---|---|---|\\n| overview | `loadOverview` | `GET /admin/overview` | `overview` |\\n| orgs | `loadPcs`, `loadOrgs` | `GET /admin/principal-contractors`, `GET /admin/organisations?q=&amp;kind=` | `pcs`, `orgs` |\\n| accounts | `loadActors` | `GET /admin/actors?q=` | `actors` |\\n| review | `loadReviews` | `GET /artifacts/review-queue` | `reviews` |\\n| erd | `loadSchema` | `GET /admin/schema` | `schema` |\\n| payments | \u2014 | (fetched inside `PaymentsConsole`) | \u2014 |\\n| azure | \u2014 | (static data inside `AzureLineageStudio`) | \u2014 |\\n| provision | \u2014 | (form POST, see below) | \u2014 |\\n\\n### Overview tab\\n\\nRenders metric cards from `Overview`: principal contractors, trade companies, accounts, pending reviews, and open invites. The pending-reviews card is a button that jumps to the review queue tab and gets an alert style when non-zero.\\n\\nBelow the metrics is a **Recent audit** panel listing `overview.recentEvents` \u2014 each event shows a timestamp (`formatShortDate`), verb, and a meta line combining actor email, object type, and a truncated `objId`.\\n\\nThe tab also surfaces an operational note: password reset and MFA unlock are not wired in this environment \u2014 they require Entra External ID (Microsoft Graph). The available account controls are disable/enable and resend set-up.\\n\\n### Organisations tab\\n\\nTwo panels:\\n\\n1. **Principal contractors** \u2014 table from `GET /admin/principal-contractors` (`PcOrg[]`). Each row shows the org name, ABN/status, admin email, trade count, seat count, and creation date. The **Resend set-up** button (`resendSetup`) POSTs to `/admin/principal-contractors/{orgId}/resend-setup`; on success the returned `inviteUrl` is written to the clipboard.\\n2. **Search organisations** \u2014 `loadOrgs` queries with `q` (name or ABN) and `kind` (`all` / `principal_contractor` / `trade_company`), returning read-only `OrgRow[]`.\\n\\n`busyId` tracks which row is mid-action so the correct button shows a loading state.\\n\\n### Accounts tab\\n\\n`loadActors` searches `GET /admin/actors?q=` by email or name. Each `ActorRow` shows name/email, memberships (`orgName (role)` joined), a status chip (Active/Disabled via `StatusChip`), and last-seen date.\\n\\nRow actions:\\n\\n- **Disable / Enable** \u2014 `setActorStatus` PATCHes `/admin/actors/{actorId}` with `{ status }`. Disabling your own account is blocked (`disabled={actor.actorId === me.userId}`).\\n- **Reset password** \u2014 permanently disabled with the tooltip \\\"Requires Entra Graph | not wired yet\\\".\\n\\n### Review queue tab\\n\\n`loadReviews` fetches `GET /artifacts/review-queue`, returning `ReviewItem[]`. Each row shows the party, document name, class code, authority/reference, upload metadata, and file size (`formatBytes`). When `item.extraction` is present, a verdict chip appears using `VERDICT_LABEL`:\\n\\n- `hold` \u2192 \\\"Hold | read disagrees\\\"\\n- `check` \u2192 \\\"Check | read flagged something\\\"\\n- `clean` \u2192 \\\"Read cleanly\\\"\\n\\nVerdict colour comes from `VERDICT_CLASS`. Expansion rows render `DocumentReadSummary` beneath the main row when extraction data exists.\\n\\nActions per row:\\n\\n- **View evidence** \u2014 link to `/api/v1/artifacts/{artifactId}/content` in a new tab.\\n- **Reject reason** \u2014 free-text input stored in `rejectReasons` keyed by `artifactId`.\\n- **Approve / Reject** \u2014 `reviewDoc` POSTs to `/artifacts/{artifactId}/review` with `{ decision, reason }`. Rejecting without a reason is blocked with a toast. After a decision, both `loadReviews` and `loadOverview` re-run so the pending-review count stays accurate.\\n\\n### Provision tab\\n\\nA five-field form (`orgName`, `abn`, `adminEmail`, `adminFirstName`, `adminLastName`) POSTs to `/admin/principal-contractors` via `submitProvision`. The ABN is stripped to digits and sent as `undefined` when empty.\\n\\nThe response is a `ProvisionResult` with `orgId`, `orgName`, `adminEmail`, `emailSent`, and `inviteUrl`. The form clears and a **Last provision** panel appears with a **Copy set-up link** button (`copyLink`). The PC list and overview are invalidated (`setPcs(null)`, `setOverview(null)`) so the next visit to those tabs refetches.\\n\\n## Support mailbox (`SupportClient`)\\n\\n`SupportClient` is a two-pane mailbox: a ticket list on the left, a reading/compose pane on the right.\\n\\n### Initial load and seeding\\n\\n`SupportPage` server-fetches `GET /support` and passes the result as `initialTickets`. `SupportClient` uses a `skipInitialLoad` ref to avoid double-fetching the list on mount. When the list was server-seeded and the user is an admin, the client still warms `GET /support/monitor` because that payload is not part of the server fetch.\\n\\n`loadList` fetches:\\n\\n- `GET /support` (with `?breached=1` when the admin filter is \\\"SLA breached\\\")\\n- `GET /support/monitor` (admin only)\\n\\nThe monitor drives six stat cards: open, in progress, waiting, SLA breached, due soon, resolved today.\\n\\n### Ticket list\\n\\nThe list is filtered client-side by `statusFilter` through the `rows` memo. The filter dropdown includes \\\"SLA breached\\\" only for admins. Selection defaults to the first row and resets when the list empties.\\n\\nEach row shows:\\n\\n- ticket ref and relative creation time (`formatRelative`)\\n- subject\\n- priority, kind, and an SLA pill via `slaClass`/`slaLabel`\\n- requester name/email for admins\\n- status chip via `statusChipKind` (resolved \u2192 verified, waiting \u2192 expiring, closed \u2192 neutral, in_progress \u2192 info, otherwise pending)\\n\\n### Reading pane and detail loading\\n\\nSelecting a ticket fetches `GET /support/{id}` into `detail`. The fetch is cancel-guarded with a `cancelled` flag so stale responses are dropped when the selection changes mid-flight. While loading, the pane shows `SkeletonRows`.\\n\\nThe detail view renders requester, organisation, opened date, category, status, priority, both SLA facts (response and resolve, each with state + due date), the original message, and the note thread.\\n\\nFocus management is deliberately careful: a `focusedTicketKey` ref tracks which ticket (or `\\\"compose\\\"`) currently owns focus. The reading pane is focused only when that key changes \u2014 never on keystrokes, and never when the user is typing in a textarea/input/select inside the pane. A code comment notes that an earlier callback-ref approach was stealing the caret from the reply textarea.\\n\\n### Creating tickets\\n\\nCompose mode (`creating` state) shows category and priority selects, a subject input, and a details textarea (min 10 chars). `submit` POSTs to `/support`, then:\\n\\n- shows a toast with the ticket ref\\n- resets the form\\n- exits compose mode\\n- selects the created ticket\\n- refreshes the list\\n\\n### Notes and updates\\n\\n- **`sendNote`** POSTs to `/support/{id}/notes` with `{ body, internal }`. `internal` is only ever true for admins (the checkbox is hidden otherwise). The response is the full updated `SupportTicketDetail`, which replaces `detail` locally. Internal notes are visually distinct and labelled \\\"internal\\\" in the thread.\\n- **`updateTicket`** PATCHes `/support/{id}` with `{ status, priority }`. It backs the \\\"Take ticket\\\" button (open \u2192 in_progress), the \\\"Resolve\\\" button, and both admin selects for status and priority.\\n\\n## Payments console (`PaymentsConsole`)\\n\\n`PaymentsConsole` is lazy-loaded from the admin \\\"payments\\\" tab. Its module comment states the design constraint explicitly: **there is no button that marks an order paid or edits an allocation**. Money moves through the provider and settles via verified events; an operator override would create a second, untested path to the same state. The console therefore exposes only the recovery set: configuration visibility, order listing, exceptions, refund decisions, delivery replay, reconciliation, and hold sweeping.\\n\\n### Data fetching\\n\\n`PaymentsConsole` fetches its own data on mount. `loadRest` uses `Promise.allSettled` so one broken list cannot leave other panels stuck on skeletons under a single generic error.\\n\\n| Panel | Endpoint | State |\\n|---|---|---|\\n| Payment configuration | `GET /payments/status` | `status: PaymentsStatusView` |\\n| Exceptions | `GET /payments/admin/exceptions` | `exceptions: AdminOrderView[]` |\\n| Failed provider deliveries | `GET /payments/admin/events/failed` | `events: PaymentInboxProblem[]` |\\n| Orders | `GET /payments/admin/orders?status=&amp;q=` | `orders: AdminOrderView[]` |\\n| Refunds | `GET /payments/admin/refunds?status=` | `refunds: AdminRefundView[]` |\\n\\nOrders and refunds have their own loaders (`loadOrders`, `loadRefunds`) with independent filters. The orders effect is deliberately keyed on `filter` only \u2014 depending on `query` too would refetch on every keystroke (suppressed with an eslint-disable and a comment).\\n\\n### Operational actions\\n\\n| Action | Endpoint | Notes |\\n|---|---|---|\\n| Replay delivery | `POST /payments/admin/events/{id}/replay` | For dead-lettered provider events; refreshes all panels afterwards |\\n| Refund decision | `POST /payments/admin/refunds/{id}/decision` | `{ decision: \\\"approve\\\" \\\\| \\\"decline\\\", note }`. Decline requires a note. Approve sends the refund to the provider |\\n| Reconcile | `POST /payments/admin/reconcile` | Returns a `Record` summary shown in a toast |\\n| Sweep | `POST /payments/admin/sweep` | Releases abandoned holds; returns `PaymentSweepResult` with expired orders, released reservations, and a `paidAfterExpiry` count left for reconciliation |\\n\\n### Refund workflow\\n\\n`REFUND_FILTERS` covers the lifecycle: requested \u2192 under_review \u2192 approved \u2192 submitted \u2192 succeeded, plus failed/declined/withdrawn.\\n\\n- `canDecide(status)` gates the decision controls: `requested`, `under_review`, `approved`, and `failed` are actionable (`failed` re-labels the button \\\"Retry send\\\").\\n- `canDecline(status)` is narrower: only `requested` and `under_review`.\\n- The amount shown is either the provider-confirmed `refund.amount` or, for requested/under_review, the computed `refundableAmount` (labelled \\\"refundable now\\\").\\n- A refund stuck in `submitted` with `attempts &gt;= STUCK_ATTEMPTS` (3) is highlighted in the row and counted in the \\\"Stuck refunds\\\" metric card.\\n\\n`OrderTable` renders provider references (`intentRef`/`sessionRef`), retry attempts, GST breakdown, and per-item allocation chips (`allocationChip`).\\n\\n## Schema ERD studio (`SchemaErdStudio`)\\n\\nLazy-loaded on the \\\"erd\\\" tab. `AdminClient` fetches `GET /admin/schema` and passes a `SchemaErdModel` prop.\\n\\n### Layout\\n\\nThe layout is FK-driven (`layoutByFk`): tables are ranked by depth in the foreign-key graph \u2014 left column is the referenced/PK side, right is the referencing/FK side. Tables are arranged in vertical columns by rank and sorted alphabetically within a rank. Tables outside the current domain filter that connect to the selected table are added as \\\"linked extras\\\" in a rightmost column.\\n\\nPositions are memoised behind a `layoutKey` (domains + linked extras). Positions are only re-seeded when that key changes \u2014 re-seeding on every render would wipe an in-progress drag. `nodeHeight` caps column display at `COL_VISIBLE` (8) with an overflow row.\\n\\n### Interaction\\n\\n- **Pan** \u2014 drag on empty canvas using pointer capture; a click (movement under 4px) clears selection.\\n- **Node drag** \u2014 dragging a table card moves it; a click selects it.\\n- **Zoom** \u2014 wheel (non-passive listener so `preventDefault` works), toolbar buttons, `fit()`, and `Reset`. `fit()` bounds scale to `[0.55, 1.15]` and centres content.\\n- **Selection** \u2014 clicking a table highlights incident edges in the SVG; the nav list shows physical name, domain label, and degree (`degreeByTable`).\\n- **Domain chips** \u2014 single click toggles a domain in the active set, double-click solos it, \\\"All\\\" resets.\\n- **Search** \u2014 filters the nav list by physical name, logical name, or meaning.\\n\\n`fit()` runs on domain changes and model regeneration (`model.generatedAt`), not during pan/zoom/select.\\n\\n## Azure lineage studio (`AzureLineageStudio`)\\n\\nLazy-loaded on the \\\"azure\\\" tab. Unlike the other admin panels it has no network dependency: it renders the static `AZURE_LINEAGE` tree imported from `./azureLineageData`.\\n\\nThe component deliberately renders a **grouped board, not a graph**. The module comment explains why: the estate is 34 nodes, six levels deep, but the shape that matters is flat \u2014 one subscription, one environment, six domains, and a handful of services per domain. A scaled-down tree produced unreadable 7px labels; the board groups services by domain and renders every label at full size.\\n\\nStructure:\\n\\n- **Ownership chain** \u2014 an ordered list rendered as a sentence: subscription \u2192 tenant \u2192 environment. Sibling non-product resource groups are shown in a separate \\\"excluded\\\" strip.\\n- **Domain sections** \u2014 each domain heading shows a name, role/meta, and a descendant resource count (`descendants` does a depth-first count). `ServiceCard`s list services; a service expands to sub-items (app registrations, alert rules).\\n- **Detail pane** \u2014 `Detail` shows the plain-English explanation first, then technical detail (type, region, resource name, `why`, facts). This ordering is intentional: the panel must work for a reader who has never opened the Azure portal.\\n\\nSelection is stored as `selectedId`; `findAzureNode(selectedId)` resolves the node from the static tree. Summary totals count resources and pending items via `descendants(AZURE_LINEAGE)`. Status chips map `live`/`pending`/`ops` to `StatusChip` kinds via `STATUS_META`.\\n\\n## Reports placeholder\\n\\n`ReportsPage` is a stub. It enforces `requirePortal(\\\"principal\\\")` and renders `ComingSoonPage` with the description: \\\"Async compliance, expiry, training, and incident reports | scoped to the requesting organisation.\\\" There is no client component yet; the route is ready to be replaced with a real report surface without structural changes.\\n\\n## Shared infrastructure\\n\\nAll client components use the same building blocks:\\n\\n- **`api` client** (`@/lib/api`) \u2014 `api.get`, `api.post`, `api.patch`; errors normalised as `ApiClientError` and surfaced via toasts built from `err.message`.\\n- **`useToast`** \u2014 success/error feedback. The admin and support clients both follow the pattern: show a success toast, then refresh dependent data with `Promise.all([...])`.\\n- **`useSession` / `me`** \u2014 role checks (`platform_admin`), the self-disable guard, and route redirects.\\n- **`StatusChip`** \u2014 used across accounts, review, support, payments, and Azure lineage with semantic `StatusKind` mappings (`verified`, `pending`, `suspended`, `neutral`, `expiring`, `info`).\\n- **`SkeletonRows`** \u2014 loading fallback for lazy imports and first-fetch states.\\n- **Formatting utilities** (`@/lib/format`) \u2014 `EMPTY`, `formatShortDate`, `formatRelative`, `formatDateTime`, `formatMoney`.\\n\\n### Error-handling pattern\\n\\nLoaders set a single `error` string rendered in an `errorStrip` with `role=\\\"alert\\\"`. `PaymentsConsole` deviates slightly: `loadRest` uses `Promise.allSettled` and only sets the error if at least one of the three independent fetches rejected, so one broken endpoint does not blank the whole panel.\\n\\n### Concurrency and stale-response guards\\n\\n- `SupportClient` uses a `cancelled` flag to drop detail responses that arrive after the selection changed.\\n- `AdminClient` uses `busyId` to scope loading spinners to specific rows/buttons.\\n- `PaymentsConsole` uses a single `busy` string, including the sentinel values `\\\"reconcile\\\"` and `\\\"sweep\\\"` for the two global operations.\\n- `SchemaErdStudio` guards drag state with a `dragRef` and only re-seeds positions on `layoutKey` changes.\",\"support-reporting-admin\":\"# Support, Reporting &amp; Admin\\n\\n# Support, Reporting &amp; Admin\\n\\nThis module is the platform's back-office surface: the platform-admin console, tenant reporting dashboards, the support mailbox, and the background assurance sweep that keeps actor verification current as time-based evidence expires. Two sub-modules deliver it \u2014 [Backend services](backend-src.md) (endpoint and job logic) and [Frontend surfaces](frontend-src.md) (role-gated routes).\\n\\n| Sub-module | Contents |\\n|---|---|\\n| [Backend services](backend-src.md) | Admin endpoints (org provisioning, actor management, platform metrics), reporting (tenant dashboards, audit timeline/export, manual status recompute), support tickets with SLA evaluation, the assurance sweep, and the shared schema-ERD service |\\n| [Frontend surfaces](frontend-src.md) | `/admin` 8-tab platform console (`AdminClient`, `PaymentsConsole`, `SchemaErdStudio`, `AzureLineageStudio`), `/support` two-pane ticket mailbox (`SupportClient`), `/reports` placeholder for principal users, plus dev performance tooling under `lib/perf` |\\n\\n## How they fit together\\n\\nThe three frontend routes map one-to-one onto backend endpoint groups. `AdminClient` drives org/actor management through `modules/admin`, renders the live schema ERD via `buildSchemaErd` (shared service) in `SchemaErdStudio`, and \u2014 from `PaymentsConsole` \u2014 triggers the assurance sweep (`sweep \u2192 message`) to re-verify actors whose evidence has expired, alongside `reconcile`/`replay` flows that restore order state via `loadOrders`/`loadRest`.\\n\\nThe support mailbox is a thin client over `modules/support`: `SupportClient` submits and lists tickets, while the backend classifies each row through `evaluateSla` from `modules/support/sla.ts`, with a frontend `slaClass` mirror for display. Reporting endpoints feed tenant dashboards and audit data that the `/reports` surface will consume once its async compliance/expiry/training/incident reports are built.\\n\\nOne cross-cutting piece: the `lib/perf` dev instrumentation bundled with this module \u2014 `trackNetworkInstant`, `trackRender`, `trackClick`, analysed in `findings.ts` \u2014 is also reached from pages outside it. `ProjectsPage`, `AccountSettingsPage`, and `SiteDetailPage` all route network timing through `useApi \u2192 trackNetworkInstant` into the same `lib/perf/store.ts`.\\n\\n```mermaid\\nflowchart LR\\n    subgraph FE[\\\"Frontend surfaces (role-gated)\\\"]\\n        Admin[\\\"/admin \u00b7 AdminClientPaymentsConsole \u00b7 SchemaErdStudio\\\"]\\n        Support[\\\"/support \u00b7 SupportClient\\\"]\\n        Reports[\\\"/reports \u00b7 placeholder\\\"]\\n    end\\n    subgraph BE[\\\"Backend services\\\"]\\n        AdminAPI[\\\"modules/adminorgs \u00b7 actors \u00b7 metrics\\\"]\\n        ReportAPI[\\\"modules/reportingdashboards \u00b7 audit \u00b7 recompute\\\"]\\n        SupportAPI[\\\"modules/supporttickets \u00b7 notes\\\"]\\n        Sla[\\\"support/sla.tsevaluateSla\\\"]\\n        ErdB[\\\"shared/services/schema-erd.tsbuildSchemaErd\\\"]\\n        Sweep[\\\"assurance sweepexpiry re-verification\\\"]\\n    end\\n    Admin --&gt; AdminAPI\\n    Admin --&gt; ErdB\\n    Admin --&gt; Sweep\\n    Support --&gt; SupportAPI\\n    SupportAPI --&gt; Sla\\n    Reports --&gt; ReportAPI\\n    Perf[\\\"lib/perf store(also used by Projects/Settings/Site pages)\\\"] -.-&gt; Admin\\n    Perf -.-&gt; Support\\n```\\n\\n## Key workflows\\n\\n- **Ticket triage with SLA monitoring** \u2014 end users raise tickets via `SupportClient.submit`; admins work them in the two-pane mailbox with status/priority controls and internal notes; SLA class and breach status are computed by `listTickets \u2192 mapRow \u2192 evaluateSla`.\\n- **Payments reconciliation and assurance sweep** \u2014 `PaymentsConsole` loads orders/refunds, `reconcile`/`replay` restore state, and `sweep` messages the backend to force re-verification of expired verification evidence.\\n- **Org provisioning and schema introspection** \u2014 platform admins provision organisations and actors, manage accounts and document reviews, and explore the live ERD rendered by `SchemaErdStudio` from `buildSchemaErd`.\\n- **Audit and reporting** \u2014 the reporting module exposes tenant dashboards, audit timeline + export, and manual status recompute; `/reports` is the reserved route for future async report types.\",\"trades-company-directory-backend-src\":\"# Trades &amp; Company Directory \u2014 backend-src\\n\\n# Trades &amp; Company Directory \u2014 backend-src\\n\\n## Overview\\n\\nThis module covers the parts of Assureworx that deal with **who is in whose network** and **how a trade account manages its own compliance dossier**.\\n\\nTwo API surfaces sit on top of shared services and the Postgres schema:\\n\\n- **`directoryRouter`** \u2014 read-only network visibility for Tier 1 (principal) organisations. Lists the trade companies and individuals in the Tier 1's network, resolves whether an unknown trade is already on Assureworx, renders the full introducer lineage tree, and serves company/person detail profiles.\\n- **`partiesRouter`** \u2014 self-service for trade accounts. Reads/writes the signed-in trade's profile, photos, location, availability, team roster, site assignments, and engagements, and handles account-closure requests.\\n\\nThree shared services support these routers and are also consumed elsewhere in the codebase:\\n\\n- `shared/services/network.ts` \u2014 network membership queries (`getNetworkParties`, `isInNetwork`).\\n- `shared/services/org-admin.ts` \u2014 trade-org admin detection used by payments and verified-status logic.\\n- `shared/services/abn-verifier.ts` \u2014 ABN checksum validation plus live/mock ABR lookup.\\n\\n## Module map\\n\\n| File | Responsibility |\\n|---|---|\\n| `modules/directory/index.ts` | Tier 1 directory router: list, lookup, lineage, company/individual detail |\\n| `modules/parties/index.ts` | Trade self-service router: profile, photo, team, engagements, closure |\\n| `modules/links/index.ts` | Stub router for engagements/readiness (`core.t_link`, ADR-0005); only a health probe today |\\n| `shared/services/network.ts` | `getNetworkParties`, `isInNetwork` |\\n| `shared/services/org-admin.ts` | `administeredTradeOrgId`, `employerPaysForPerson` |\\n| `shared/services/abn-verifier.ts` | `isValidAbn`, `normaliseAbn`, `mapAbrEntityType`, `parseAbrJsonp`, `mapAbrPayload`, `MockAbrVerifier`, `LiveAbrVerifier`, `createAbnVerifier`, singleton `abnVerifier` |\\n\\n## Domain model in five minutes\\n\\nThe directory and parties modules are built on a small set of codes:\\n\\n**Party kinds** (`t_party.kind_cd`)\\n\\n- `K02` \u2014 trade company / organisation\\n- `K03` \u2014 individual person\\n\\n**Relationship kinds** (`t_rel.kind_cd`)\\n\\n| Code | Edge | Meaning |\\n|---|---|---|\\n| `L01` | person \u2192 company | Employment (person is supplied by company) |\\n| `L02` | company \u2192 Tier 1 | Trade company in the Tier 1's network |\\n| `L03` | person \u2192 Tier 1 | Individual directly in the Tier 1's network |\\n| `L04` | person \u2192 company | Subcontractor under a network company |\\n\\n**Party statuses** (`t_party.status_cd`) \u2014 surfaced in API responses as `pending`, `verified`, `action_required`, `suspended` via `PARTY_STATUS`. The `suspended` bucket also covers parties that withdrew their information (holder erasure / closure requested).\\n\\n**Requests** (`t_request`) \u2014 invitations and introductions. `U01` is pending, `U03` is accepted. Accepted requests where `origin_party_id !== scope` become \\\"introduced by\\\" edges; the request chain, not `t_rel`, is the source of truth for company\u2192company nesting in the lineage view.\\n\\n**Withdrawal flag** \u2014 a party or edge \\\"withdrew\\\" when any of these hold: relationship `attrs ? 'holder_erasure'`, party `attrs ? 'holder_erasure'`, or `t_party.closure_requested_at IS NOT NULL`. Withdrawn parties are shown as `suspended` with the display name \\\"Withdrew their information\\\".\\n\\n## Directory API (`directoryRouter`)\\n\\nAll routes require `requireAuth`, `resolveTenantContext`, and one of the roles `tier1_admin` / `tier1_user`. The tenant context supplies `scopeOrgId` \u2014 the Tier 1 org whose network is being read.\\n\\n| Route | Purpose |\\n|---|---|\\n| `GET /health` | Liveness probe |\\n| `GET /` | Paged, filterable network directory |\\n| `GET /lookup` | Pre-invite recognition: \\\"is this trade already on Assureworx?\\\" |\\n| `GET /lineage` | Full network tree: root \u2192 companies \u2192 individuals + pending invites (rate-limited by `heavyReadLimiter`) |\\n| `GET /companies/:id` | Company detail profile |\\n| `GET /individuals/:id` | Individual detail profile |\\n\\n### `GET /` \u2014 network directory list\\n\\nQuery parameters (validated by `listQuerySchema`):\\n\\n- `q` \u2014 free text, min 3 chars; matches display name, email, ABN, `public_ref`, and the literal word \\\"withdrew\\\"\\n- `status` \u2014 `pending` \\\\| `verified` \\\\| `action_required` \\\\| `suspended`\\n- `classification` \u2014 trade-type name or raw tag code; resolved via `TAG_NAMES`\\n- `page`, `pageSize` \u2014 `pageSize` capped at 100\\n\\nThe list is computed in a single tenant-scoped query with four CTEs:\\n\\n1. `direct` \u2014 L02/L03 edges onto the Tier 1 (active `RS1`, or edges carrying `holder_erasure`)\\n2. `employed` \u2014 L01 employees of those L02 companies, excluding company admins via `fn_identity_admin_party_ids`\\n3. `network` \u2014 one row per party (`DISTINCT ON (party_id)` ordered by earliest `member_since`), joined to `t_party`, plus the computed `withdrew_information` flag\\n4. `filtered` \u2014 applies status/text/classification filters; `COUNT(*) OVER()` gives the total for pagination\\n\\nNotable behaviours:\\n\\n- A `status=suspended` filter also matches any party with `withdrew_information`.\\n- Withdrawn parties render with `name: \\\"Withdrew their information\\\"`, no ABN/email/photo/classifications, and `actionReason: \\\"Withdrew their information\\\"`.\\n- `trades` (people counts) is populated only for non-withdrawn `K02` companies via `loadPeopleCounts`.\\n- Evidence summaries come from `getEvidenceForParties` + `summariseDocuments`; `primaryActionReason` derives the action-reason string from evidence rows and status.\\n- The early `loadPartyDetails` call loads parties, ABNs (`partyIdent` kind `D01`), and tags in three parallel queries into maps keyed by `party_id`.\\n\\n### `GET /lookup` \u2014 pre-invite recognition\\n\\nDeliberately thin (ADR-0012). A caller who has no relationship yet asks one question: does this trade exist?\\n\\n- `term` is classified by `classifyLookupTerm`; `resolveIdentity` runs **under `withBypass`** because the caller has no tenant relationship with the target party.\\n- If a match is found, two checks run in the tenant scope: `inNetwork` (existing L02/L03 `RS1` edge) and `requestPending` (a `U01` request from this Tier 1 to the matched party).\\n- The response carries **existence and portable status only** \u2014 no ABN, contact details, or evidence. Further disclosure is gated on consent (Q25).\\n\\n### `GET /lineage` \u2014 network introducer tree\\n\\nThis is the heaviest endpoint (hence `heavyReadLimiter`). It returns a `NetworkLineage`:\\n\\n- **Root** \u2014 the Tier 1 org itself.\\n- **Companies** \u2014 trade companies, nested under whoever introduced them. Nesting comes from the `t_request` chain (`U03` accepted, `origin_party_id !== scope`), not from `t_rel`, because L02 is a star onto the Tier 1 and cannot express the introducer relationship.\\n- **Individuals** \u2014 per company: employees (`L01`, excluding admins via `fn_identity_admin_party_ids`) and subcontractors (`L04`). A subcontracted *company* is not flattened into people \u2014 it becomes a child company tier. Direct `L03` individuals appear under `directIndividuals` unless already shown under a company.\\n- **Pending invites** \u2014 `U01` requests grouped by origin company. The invite `token` field is only populated when the caller is a `tier1_admin` of the origin org \u2014 never for `tier1_user` or for other orgs' invites.\\n\\nConstruction details:\\n\\n- `loadPartyDetails` + `getEvidenceForParties` run once for the whole tree (`allIds`).\\n- `buildCompany` recurses with a `visited` set to guard against cycles; companies introduced by another network company are skipped at top level, then a safety pass surfaces any unvisited company (dangling introducer / cycle) at top level.\\n- `toIndividual` maps an employee with `class_cd = \\\"M02\\\"` to `relation: \\\"sole_trader\\\"`.\\n- The `totals` block counts companies, individuals, verified, attention, and pending invites by walking the tree.\\n\\n### `GET /companies/:id` and `GET /individuals/:id`\\n\\nBoth validate the UUID, load the full network via `getNetworkParties`, and 404 if the party is outside the scope org's network (`\\\"That company is not in your network.\\\"` / `\\\"That person is not in your network.\\\"`).\\n\\nCompany detail additionally:\\n\\n- Loads party details, ABN, evidence, PC reviews (`loadPcReviews` scoped to the Tier 1), and a compliance checklist (`buildChecklist`).\\n- Lists employed crew via `L01` edges **excluding company admins** (`fn_identity_admin_party_ids`) \u2014 company-invite onboarding creates an L01 for the founding admin, but they are not staff on the roster.\\n- Maps `class_cd === \\\"M02\\\"` individuals to role `\\\"Sole trader\\\"`, otherwise `\\\"Employee\\\"`.\\n- Returns `memberSince` from the network edge, not party creation.\\n\\nIndividual detail mirrors this: profile, classifications, checklist, evidence with PC reviews, and `companyName` resolved from `entry.employerPartyId`.\\n\\n## Parties API (`partiesRouter`)\\n\\nAll routes require `requireAuth` and `resolveTenantContext`; trade-only role checks are applied per-route. The router distinguishes two \\\"self\\\" identities:\\n\\n- `myTradeOrgId(req)` \u2014 the org context (`ctx.orgId`) for `trade_company_admin` / `trade_individual`; throws `forbidden` for other roles.\\n- `myTradeDossierPartyId(req)` \u2014 the compliance dossier target: for `trade_individual`, the person party (`ctx.personPartyId`); for `trade_company_admin`, the org.\\n\\n| Route | Roles | Purpose |\\n|---|---|---|\\n| `GET /health` | any authed | Liveness probe |\\n| `GET /me/profile` | trade | Full profile dossier + checklist + networks + workforce stats |\\n| `PUT /me/photo` | any authed with person | Replace person profile photo (`uploadLimiter`) |\\n| `DELETE /me/photo` | any authed with person | Remove person profile photo |\\n| `PUT /me/org-photo` | `trade_company_admin` | Replace company logo (`uploadLimiter`) |\\n| `GET /me/team` | `trade_company_admin` | Staff roster with documents, projects, funding status |\\n| `GET /me/assignable-sites` | `trade_company_admin` | Sites this org can place people on |\\n| `POST /me/team/:partyId/assign` | `trade_company_admin` | Place an employee on a site |\\n| `DELETE /me/team/:partyId` | `trade_company_admin` | De-register employee, release their site placements |\\n| `PUT /me/location` | any authed | Persist suburb/postcode/region on the person party |\\n| `PUT /me/availability` | `trade_company_admin` | Set `openToNewBusiness` + AU regions on company `attrs` |\\n| `GET /me/engagements` | trade | Active + completed jobs, derived from link/node state (ADR-0016) |\\n| `GET /me/closure` | any authed with person | Read closure status (ADR-0027) |\\n| `POST /me/closure` | any authed with person | Request account closure with `{ confirm: true }` |\\n\\n### `GET /me/profile`\\n\\nReturns a `MyProfile` with:\\n\\n- **Party** \u2014 identity fields, ABN (`D01`), contact, `statusSince`, suburb/postcode/region.\\n- **Checklist** \u2014 from `buildChecklist(tx, partyId)`; 404 if missing.\\n- **Networks** \u2014 principals this trade is connected to (outgoing L02/L03 edges), with `since` from the edge creation time.\\n- **Trade types** \u2014 `tradeTypeCounts` runs over the `MEMBERS_CTE` (the org plus its L01 employees), grouping `t_partyTag` codes by status bucket.\\n- **Workforce** \u2014 for companies only: L01 employee counts by status.\\n- **Availability** \u2014 for companies only: `attrs.openToNewBusiness` + `attrs.availableRegions` filtered against `AU_REGIONS`.\\n\\n`MEMBERS_CTE` is the shared \\\"compliance picture\\\" definition: the party itself plus live L01 employees.\\n\\n### Photos\\n\\n`PUT /me/photo` and `PUT /me/org-photo` share `photoSchema` (data-URL regex for JPEG/PNG/WebP, max 700k chars) and `boundImageDataUrl` (max 500 KB decoded, max edge 1024 px). The person photo writes `t_party.image_uri` for `ctx.personPartyId`; the org photo writes it for the trade company. Both audit with `party.photo_updated` / `party.org_photo_updated`.\\n\\n### Team, sites, and de-registration\\n\\n`GET /me/team` is the staff roster. The employee query includes withdrawn members (RS1 *or* any holder-erasure/closure flag) and excludes anyone with an `R04` grant on the org (company admins manage Team but are not listed as staff). For each member it returns:\\n\\n- document summary (from `getEvidenceForParties` on active IDs),\\n- active project/site assignments (via `t_link` \u2192 `t_node`, with parent project),\\n- verification funding state from `workforceFundingFor` \u2014 read **under `withBypass`** because `t_cycle` / `t_order_item` are written by the payments service outside tenant scope (migration 023).\\n\\n`POST /me/team/:partyId/assign` delegates to `placeEmployeeOnSite` under `withBypass` and responds `201`.\\n\\n`DELETE /me/team/:partyId` retires the L01 edge (sets `RS2` + `deleted_at`), then soft-deletes live site placements **only where the placement owner is a principal networked to this trade via L02** \u2014 never links owned by unrelated principals. Returns `{ partyId, name, sitesReleased }`.\\n\\n### Engagements\\n\\n`GET /me/engagements` runs the `MEMBERS_CTE` against `t_link` joined to site/project nodes, limited to 200 most recent. Active vs completed is **derived**, not stored:\\n\\n- a placement is active while the link is not soft-deleted,\\n- the project status is `RS1` and not past `ends_on`,\\n- the site status is `RS1`.\\n\\n`readiness` is the raw `LINK_STATE[state_cd]`; `endedOn` falls back to project end date. The response splits into `{ active, completed }`.\\n\\n### Closure\\n\\n`GET /me/closure` and `POST /me/closure` implement ADR-0027 (90-day grace, then a retention sweeper). Both require `req.auth.personPartyId`. The POST requires an explicit `{ confirm: true }` literal; delegation is to `requestAccountClosure` from `modules/retention/closure.ts`, which writes the closure status and returns it. Reading closure status uses `withBypass` since retention state is managed outside the tenant scope.\\n\\n## Shared services\\n\\n### `shared/services/network.ts`\\n\\n- `getNetworkParties(db, scopeOrgId)` \u2014 returns every party reachable from a Tier 1 org: L02 companies, L03 individuals, and L01 employees of those companies. `memberSince` is the earliest relevant edge `created_at`; results are deduplicated by `party_id`. This is the membership check used by `directory /companies/:id`, `/individuals/:id`, and `modules/reporting`.\\n- `isInNetwork(db, scopeOrgId, partyId)` \u2014 boolean form; also true when the party is an L01 employee of a networked L02 company. Consumed by `modules/nodes`.\\n\\n### `shared/services/org-admin.ts`\\n\\nAnswers two payment/eligibility questions:\\n\\n- `administeredTradeOrgId(db, personPartyId)` \u2014 the K02 org this person administers via an `R04` grant, if any. Company-invite onboarding stores evidence/ABN on the *organisation* while identity stays on the human admin, so payment and verified-status logic must read the org dossier for admins.\\n- `employerPaysForPerson(db, personPartyId)` \u2014 true when the person has a live L01 employment edge **and** does not administer a trade org (admins fund their own cycles even if a mistaken L01 exists).\\n\\nConsumers: `verified-status` (`computeStatus`, `evaluateAndPersistStatus`), `payments/eligibility`, `payments/views`, `payments/checkout`, `modules/memberships`.\\n\\n### `shared/services/abn-verifier.ts`\\n\\n- `isValidAbn(abn)` \u2014 standard ABN checksum (subtract 1 from first digit, weighted sum mod 89 === 0).\\n- `normaliseAbn(raw)` \u2014 strips whitespace.\\n- `mapAbrEntityType(code, name)` \u2014 ABR entity type \u2192 `OrgClass` (sole trader, partnership, trust, company).\\n- `parseAbrJsonp(body)` \u2014 strips the JSONP wrapper and parses the ABR payload.\\n- `mapAbrPayload(abn, payload)` \u2014 maps an ABR response to `AbnVerifyResult`. A found-but-cancelled ABN is still `valid: true` (registerable) but `active: false`; compliance gates on `active`.\\n- `MockAbrVerifier` (`providerCd: \\\"abr-mock\\\"`) \u2014 dev/local only; performs the checksum, then tries to reuse an existing party name for the entity name.\\n- `LiveAbrVerifier` (`providerCd: \\\"abr\\\"`) \u2014 calls `https://abr.business.gov.au/json/AbnDetails.aspx` with an 8-second `AbortController` timeout. The GUID is passed via URL param and must never be logged.\\n- `createAbnVerifier()` \u2014 selects live vs mock; throws in hardened environments (`isHardenedEnv`) when `ABR_GUID` is missing.\\n\\nThe singleton `abnVerifier` is consumed by `modules/onboarding`, `modules/verification`, `modules/requests`, and `modules/admin` \u2014 not by the directory/parties routers themselves.\\n\\n## Architecture\\n\\n```mermaid\\ngraph TD\\n    subgraph HTTP[\\\"HTTP layer\\\"]\\n        DIR[\\\"directoryRoutertier1_admin \u00b7 tier1_user\\\"]\\n        PAR[\\\"partiesRoutertrade_company_admin \u00b7 trade_individual\\\"]\\n    end\\n\\n    subgraph SVC[\\\"Shared services\\\"]\\n        NS[\\\"network.tsgetNetworkParties \u00b7 isInNetwork\\\"]\\n        OA[\\\"org-admin.tsadministeredTradeOrgId \u00b7 employerPaysForPerson\\\"]\\n        AB[\\\"abn-verifier.tsLiveAbrVerifier \u00b7 MockAbrVerifier\\\"]\\n        EV[\\\"evidence \u00b7 checklist \u00b7 identityemployee-placement \u00b7 audit\\\"]\\n    end\\n\\n    subgraph XMOD[\\\"Cross-module helpers\\\"]\\n        WF[\\\"payments/views \u2014 workforceFundingFor\\\"]\\n        RC[\\\"retention/closure \u2014 closure status\\\"]\\n    end\\n\\n    DIR --&gt; NS\\n    DIR --&gt; EV\\n    PAR --&gt; EV\\n    PAR --&gt; WF\\n    PAR --&gt; RC\\n    NS --&gt; DB[(\\\"Postgres\\\")]\\n    OA --&gt; DB\\n    AB --&gt; ABR[\\\"ABR JSON endpointabr.business.gov.au\\\"]\\n    AB --&gt; DB\\n    EV --&gt; DB\\n```\\n\\n## Security &amp; tenant-scoping notes\\n\\n- **Directory routes** are read-only and restricted to `tier1_admin` / `tier1_user`. All network reads happen inside `withTenant(actorId, scopeOrgId, ...)`, which applies RLS.\\n- **`withBypass` is used deliberately in three places**: identity resolution in `/lookup` (no tenant relationship exists yet), workforce funding reads in `/me/team` (payments tables are written outside tenant scope), and site placement (`placeEmployeeOnSite`). Each is read-only or narrowly scoped to parties the caller already owns/employs.\\n- **Admin exclusion** in employee rosters relies on `core.fn_identity_admin_party_ids` (SECURITY DEFINER, migration 040) because a tenant cannot `SELECT` another org's `t_grant` rows.\\n- **Invite tokens** in `/lineage` are exposed only when the caller is a `tier1_admin` of the invite's origin org \u2014 never to `tier1_user`.\\n- **`/lookup` is intentionally thin**: no ABN, contact details, or evidence is disclosed to a caller with no relationship to the matched party (ADR-0012 / Q25 consent).\\n- **Photo uploads** are re-validated and resized server-side (`boundImageDataUrl`) even though the client sends a data URL; both upload endpoints sit behind `uploadLimiter`.\\n\\n## Cross-module connections\\n\\n- `modules/reporting` consumes `getNetworkParties`; `modules/nodes` consumes `isInNetwork`.\\n- `payments/*` (eligibility, views, checkout) and `verified-status` consume `org-admin.ts` to decide who pays for verification and whose dossier drives status.\\n- `abn-verifier.ts` is shared by onboarding, verification, requests, and admin flows.\\n- `partiesRouter` pulls `workforceFundingFor` from `payments/views` and closure logic from `retention/closure`, coupling the self-service surface to payment and retention state that lives in other modules.\\n- `modules/links/index.ts` is a stub today; it is the intended home for engagement/readiness endpoints over `core.t_link` (ADR-0005/ADR-0016), which `partiesRouter` currently reads directly in `/me/engagements`.\",\"trades-company-directory-frontend-src\":\"# Trades &amp; Company Directory \u2014 frontend-src\\n\\n# Trades &amp; Company Directory \u2014 Frontend\\n\\n## Module Overview\\n\\nThe Trades &amp; Company Directory gives principal contractors a compliance-focused view of their trade network. It answers three questions:\\n\\n1. **Who is in my network?** \u2014 a sortable/filterable directory of trade companies and individual workers.\\n2. **How compliant are they?** \u2014 per-party profile pages showing core checks, evidence documents, and people.\\n3. **How is the network structured?** \u2014 a lineage view showing the chain from principal contractor down through sub-contractors, workers, and pending invites.\\n\\nThe module lives under `frontend/src/app/(app)/trades/` with supporting domain components in `frontend/src/components/domain/`.\\n\\n```\\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\\n\u2502                         Route Hierarchy                           \u2502\\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\\n\u2502  /trades                                            TradesPage    \u2502\\n\u2502    \u2514\u2500 TradesClient          (list | cards | lineage views)        \u2502\\n\u2502         \u251c\u2500 LineageTree      (tree layout)                         \u2502\\n\u2502         \u2502    \u2514\u2500 LineageSummary (map layout)                       \u2502\\n\u2502         \u2514\u2500 TradeTypeCell    (inline trade-type icons)             \u2502\\n\u2502                                                                   \u2502\\n\u2502  /trades/[id]                                  CompanyProfilePage \u2502\\n\u2502    \u2514\u2500 CompanyProfileClient (checks | evidence | people tabs)      \u2502\\n\u2502                                                                   \u2502\\n\u2502  /trades/individuals/[id]                     IndividualProfilePage\u2502\\n\u2502    \u2514\u2500 IndividualProfileClient (checks | evidence tabs)            \u2502\\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\\n```\\n\\n---\\n\\n## Route Architecture\\n\\nAll three routes follow the same server/client split:\\n\\n1. **Server page** (`page.tsx`) \u2014 guards the route with `requirePortal(\\\"principal\\\")`, prefetches initial data, and renders the client component with that data as props.\\n2. **Client component** (`*Client.tsx`) \u2014 hydrates from the server-prefetched data, then manages its own client-side state, refetching, and user interaction.\\n\\n### Server pages\\n\\n| Route | Server page | Data loading |\\n|---|---|---|\\n| `/trades` | `TradesPage` | `traceAppPage` \u2192 `serverApi.getWithMeta(\\\"/directory?pageSize=50\\\")` |\\n| `/trades/[id]` | `CompanyProfilePage` | `loadAppPage(\\\"/directory/companies/[id]\\\")` |\\n| `/trades/individuals/[id]` | `IndividualProfilePage` | `loadAppPage(\\\"/directory/individuals/[id]\\\")` |\\n\\nAll three call `await requirePortal(\\\"principal\\\")` before loading data, so unauthenticated or non-principal sessions are redirected before any fetch happens. `TradesPage` additionally handles a `401` from the directory fetch by redirecting to `/auth/login`.\\n\\nThe initial fetch serves two purposes: it makes first paint immediate, and it lets the client components skip their first identical fetch (see [Data fetching](#data-fetching)).\\n\\n---\\n\\n## Directory Page (`TradesClient`)\\n\\n`TradesClient` is the main interactive surface. It receives `initialRows: DirectoryRow[]` and `initialTotal: number` from the server page.\\n\\n### View modes\\n\\nThree mutually exclusive views, toggled by the segmented control in the toolbar:\\n\\n| Mode | Renders | Purpose |\\n|---|---|---|\\n| `list` | `\n` | Dense compliance summary \u2014 name, ABN, status, trade types, evidence split, member-since |\\n| `cards` | `` grid | Identity-focused browsing with contact info and evidence meter |\\n| `lineage` | `LineageTree` or `LineageSummary` | Network structure visualization |\\n\\nThe active view is:\\n\\n1. Taken from the `?view=` URL param if present (`list`, `cards`, `lineage`; legacy `summary` maps to `lineage`).\\n2. Otherwise restored from `localStorage` under the key `awx.trades.view`.\\n3. Otherwise defaults to `\\\"list\\\"`.\\n\\n`setViewMode` persists the choice to `localStorage` so the preference survives navigation.\\n\\n### Filtering and sorting\\n\\n**Filters** \u2014 a status dropdown (`All statuses`, `verified`, `action_required`, `pending`, `suspended`) and a classification filter, both synced to URL search params (`?status=` and `?classification=`). The `useEffect` watching `params` keeps local state in sync with browser navigation (deep links from projects/dashboard).\\n\\n**Sorting** \u2014 sortable columns for name, status, and member since. Clicking a column header toggles asc/desc; status uses a fixed severity ordering:\\n\\n```\\nsuspended(0) \u2192 action_required(1) \u2192 pending(2) \u2192 verified(3)\\n```\\n\\n### Data fetching\\n\\nThe directory fetch has several deliberate performance behaviors:\\n\\n```ts\\nconst query = useMemo(() =&gt; {\\n  const sp = new URLSearchParams();\\n  if (status) sp.set(\\\"status\\\", status);\\n  if (classification) sp.set(\\\"classification\\\", classification);\\n  sp.set(\\\"pageSize\\\", \\\"50\\\");\\n  return sp.toString();\\n}, [status, classification]);\\n```\\n\\n- **Seeded fetch skip** \u2014 when the server already prefetched the default query (`pageSize=50`, no filters), the first client-side effect run is skipped entirely via `skipSeededDefaultFetch`. Filter changes still trigger a fetch.\\n- **Debounce** \u2014 after the initial ready state, filter changes are debounced by 250ms so typing in the classification filter doesn't fire a request per keystroke.\\n- **AbortController** \u2014 superseded requests are aborted rather than left to run and have their results discarded. This frees the connection and keeps `/directory` off the API's chatty-endpoint list.\\n- **Loading state ownership** \u2014 a cancelled request does not touch loading state; the in-flight (newer) search owns it.\\n\\n### Withdrawn parties\\n\\nRows where `row.withdrewInformation` is truthy are rendered in a masked state:\\n\\n- Name shown but not linked; ABN replaced with `\u2013`; avatar photo suppressed.\\n- Sub-label reads *\\\"Personal details removed at their request\\\"*.\\n- The row/card is not clickable; the status chip shows the withdrew chip.\\n\\nThis keeps the party in the directory (so the principal knows they existed) while honoring the privacy withdrawal.\\n\\n### Empty and error states\\n\\n- **Error** \u2014 rendered as an `errorStrip` above the panel with `role=\\\"alert\\\"`.\\n- **Loading** \u2014 `SkeletonRows` placeholder.\\n- **Empty network** \u2014 `EmptyState` with an invitation CTA if `canSendRequests(me)` is true (i.e., the current user is allowed to send onboarding requests).\\n- **No matches** \u2014 `EmptyState` explaining the filter matched nothing.\\n\\n### Invite entry point\\n\\nThe toolbar shows **\\\"Invite a trade\\\"** (`Button href=\\\"/requests?new=1\\\"`) only when `canSendRequests(me)` passes. The same check gates the empty-state CTA.\\n\\n---\\n\\n## Profile Pages\\n\\n`CompanyProfileClient` and `IndividualProfileClient` share the same architecture: server-prefetched `initialData`, client-side refresh via `useApi`, tabbed `ProfileSheet` layout.\\n\\n### Data refresh\\n\\nBoth use:\\n\\n```ts\\nconst { data, error, refresh } = useApi(path, { initialData });\\nconst profile = data ?? initialData;\\n```\\n\\n`useApi` handles the fetch lifecycle and performance tracking (`trackNetworkInstant` \u2192 `push` \u2192 `emit`). The `error` from a failed refresh renders as an error strip; the last-known good data stays on screen.\\n\\n### Tab structure\\n\\nTabs are built dynamically in a `useMemo`:\\n\\n- **Company**: `Checks` (if any core checks are not `not_required`), `Evidence`, `People`.\\n- **Individual**: `Checks` (same condition), `Evidence`.\\n\\nDefault active tab is the first tab, falling back to `\\\"evidence\\\"`. If the stored `activeSection` no longer matches a tab (e.g., checks became irrelevant after a refresh), it snaps back to the first available tab.\\n\\n### Status reason derivation\\n\\nBoth clients share the same `statusReasonFromProfile` logic:\\n\\n1. If verified \u2192 `null`.\\n2. Find a core check in `failed` or `pending` state \u2192 `\\\" failed\\\"` or `\\\" needs attention\\\"`.\\n3. Find evidence with status `expired`, `rejected`, `missing`, or `expiring_soon` \u2192 `\\\" Expired\\\"`, the rejection reason (or `\\\" Rejected\\\"`), `\\\" Missing\\\"`, or `\\\" Expiring\\\"`.\\n4. Fall back to `\\\"Awaiting info\\\"` (pending) or `\\\"Action required\\\"`.\\n\\nThis reason is passed to `ProfileHeader`'s `statusReason` prop and displayed as an explanatory chip under the status.\\n\\n### People tab (company only)\\n\\nLists `profile.individuals` in a table with name (via `PartyIdentity`), role, and status. Rows navigate to `/trades/individuals/{partyId}`. An `EvidenceMeter` in the tab actions shows the people counts:\\n\\n- `total` / `current` (verified)\\n- `pending`\\n- `attention` (`action_required` + `suspended`)\\n\\n### PC review action\\n\\n`onPcReview` is a `useCallback` shared by both profile clients:\\n\\n```ts\\nawait api.post(`/artifacts/${artifactId}/pc-ack`, { decision, reason });\\nawait refresh();\\n```\\n\\nThe `decision` is one of `\\\"accept\\\" | \\\"decline\\\" | \\\"clear\\\"`. The `busyId` state disables the specific evidence row's buttons while the request is in flight, preventing double-submission. Errors are rethrown and surface via `EvidenceBoard`'s error handling.\\n\\n---\\n\\n## Lineage Views\\n\\nThe lineage view is loaded lazily \u2014 the `/directory/lineage` fetch only fires when the user switches to the `lineage` view and no data is cached yet. The view has two layouts toggled from the toolbar: **Tree** and **Map**.\\n\\n### Tree layout (`LineageTree`)\\n\\nA CSS rail-and-guide tree: vertical spine, elbows, and expandable company nodes. Key behaviors:\\n\\n- **Expand/collapse** \u2014 each company node has a disclose button (chevron) when it has children (sub-companies, people, or pending invites). Nodes without children show an empty dot placeholder.\\n- **Expand/collapse all** \u2014 toolbar button appears when there are companies; toggles between all-expanded and all-collapsed using `allIds` from `collectCompanyIds`.\\n- **Document metric** \u2014 each company/person row shows `current/total` documents with a warning tone when `attention &gt; 0` or `current &lt; total`.\\n- **Pending invites** \u2014 `InviteRow` renders a dashed-border card with the invite name/email, expiry date, and a **Copy link** button when `invite.token` is present. The copied URL is `${window.location.origin}/onboarding/invite/${token}`; a toast confirms success or reports clipboard failure.\\n\\nThe tree is left-anchored with a legend explaining the reading direction.\\n\\n### Map layout (`LineageSummary`)\\n\\nA purview-style pan/zoom-free map: SVG bezier links connecting absolutely positioned node cards on a scrollable stage.\\n\\n**Tree building** \u2014 `buildTree` converts `NetworkLineage` into a `TreeNode` structure:\\n\\n- Root \u2192 `kind: \\\"root\\\"`\\n- Companies \u2192 `kind: \\\"company\\\"` with recursive children\\n- Direct individuals \u2192 `kind: \\\"person\\\"`\\n- Pending invites \u2192 `kind: \\\"invite\\\"`\\n\\nEach node estimates its card width from name/meta length (`estimateCardWidth`), accounting for avatar, gaps, status chip, and the reserved chevron column so chips align down the tree.\\n\\n**Layout algorithm**:\\n\\n1. Prune the tree to the set of expanded nodes (`prune`).\\n2. Count leaves to derive vertical leaf gap \u2014 fills the viewport height but clamps to `MIN_LEAF_GAP` for dense trees.\\n3. Compute column X positions from max card width per depth, stretching cards and gutters so the graph always fills the viewport width.\\n4. Recursively position leaves top-to-bottom; internal nodes center vertically over their children (`layout`).\\n\\n**Panning** \u2014 the stage is a scroll container with hidden scrollbars (`scrollbar-width: none`). Drag-to-pan works with mouse (`onMouseDown`/`onMouseMove`) and touch (`onTouchStart`/`onTouchMove`), using a `dragStart` ref that records the initial cursor position and scroll offset. Drags that start on a link or button are ignored so clicks/navigation still work.\\n\\n**Expand/collapse** \u2014 nodes with children get a chevron toggle; leaf nodes get a spacer so status chips stay aligned. The expanded set is initialized to all companies plus the root and reset when a new `lineage` object arrives.\\n\\n**Resize handling** \u2014 a `ResizeObserver` on the wrapper measures viewport width/height; the layout recomputes whenever dimensions change.\\n\\n---\\n\\n## Trade Type Iconography\\n\\n### `trade-pack.ts` \u2014 code mapping\\n\\nCentral mapping for platform trade classification codes:\\n\\n- `TRADE_PACK` \u2014 maps G-codes (`G01`\u2013`G17`) to sliced tradiepack asset slugs. Where an exact asset doesn't exist, a visually closest asset is used (e.g., `G03` gasfitter \u2192 `plumber`, `G07` asbestos \u2192 `demolition`).\\n- `NAME_TO_CODE` \u2014 maps display names (e.g., `\\\"Electrician\\\"`) to G-codes, used when the API only returned labels in `classifications` without `classificationCodes`.\\n- `tradeCodeFromName(name)` \u2014 resolves a display name to a G-code, or `null`.\\n- `tradePackSrc(code)` \u2014 returns the public URL (e.g., `/tradiepack/electrician.png`) or `null` when no asset maps.\\n- `primaryTradeCode(types)` \u2014 sorts `TradeTypeCount[]` by count desc (then code asc) and returns the top code.\\n- `GENERIC_TRADE_PACK_SRC` \u2014 `/tradiepack/builder.png`, the default silhouette.\\n\\n### `TradeTypeIcon`\\n\\nResolution order:\\n\\n1. `tradePackSrc(code)` \u2192 tradiepack PNG if the code maps.\\n2. Otherwise `GENERIC_TRADE_PACK_SRC` when `preferPackGeneric` is true (default) or when `code` is absent.\\n3. Otherwise greyscale `MetaIcon` via `tradeIcon(code)`.\\n\\nThe `preferPackGeneric={false}` variant is used in dense list rows (`TradeTypeList`) where a greyscale icon reads better than pack art; the directory's `TradeTypeCell` opts for pack art with `preferPackGeneric` defaulting to true.\\n\\n### `TradeTypeList`\\n\\nRenders `TradeTypeCount[]` in either:\\n\\n- **`stack`** \u2014 rows with icon, name, and count (used in profile summaries), or\\n- **`chips`** \u2014 compact pill chips for dense boards.\\n\\n---\\n\\n## Styling\\n\\nThe module uses CSS Modules:\\n\\n| File | Scope |\\n|---|---|\\n| `trades.module.css` | Directory toolbar, view toggle, table cells, card grid, trade-type rows |\\n| `LineageTree.module.css` | Rail tree structure, elbows, disclose buttons, company/person/invite rows |\\n| `LineageSummary.module.css` | Map stage, SVG links, node cards, pan hint |\\n| `TradeTypeList.module.css` | Stack/chip list layouts |\\n\\nLayout relies on design tokens from the global theme (`--border-default`, `--radius-md`, `--surface-muted`, `--color-carbon`, etc.), and shared structural styles from `page.module.css` (`pageStyles.table`, `cellMuted`, `sortTh`, `thLabel`, `errorStrip`, `toolSelect`, `rowLink`).\\n\\nNotable responsive behavior in `trades.module.css`: view buttons grow to 44px min-height on coarse pointers or screens under 1024px. `LineageTree.module.css` collapses company metrics at 720px.\\n\\n---\\n\\n## Integration Points\\n\\n### Inbound dependencies\\n\\n| Dependency | Used for |\\n|---|---|\\n| `@/lib/api` (`api`, `ApiClientError`) | Client-side GET/POST with `getWithMeta` and AbortController support |\\n| `@/lib/use-api` (`useApi`) | Profile refresh lifecycle with performance tracking |\\n| `@/lib/server-api` (`serverApi`, `ServerApiError`) | Server-side prefetch in page.tsx |\\n| `@/lib/load-app-page` (`loadAppPage`, `traceAppPage`) | Page-level data loading with tracing |\\n| `@/lib/server-session` (`requirePortal`) | Route guarding |\\n| `@/lib/session` (`useSession`, `canSendRequests`) | Invite permission checks |\\n| `@/lib/format` (`formatAbn`, `formatShortDate`) | ABN and date formatting |\\n| `@/components/ui/*` | `Button`, `EmptyState`, `MetaIcon`, `PageHeader`, `Panel`, `PartyAvatar`, `SkeletonRows`, `StatusChip` (`verifiedChip`, `withdrewChip`), `Toast` |\\n| `@/components/domain/*` | `CoreChecks`, `EvidenceBoard`, `EvidenceMeter`, `ProfileHeader`, `ProfileSheet`, `VerifiedSplit` |\\n\\n### Outbound integration points\\n\\n- **API endpoints consumed**: `/directory`, `/directory/lineage`, `/directory/companies/{id}`, `/directory/individuals/{id}`, `/artifacts/{artifactId}/pc-ack`.\\n- **Navigation targets**: `/trades/{partyId}`, `/trades/individuals/{partyId}`, `/requests?new=1`, `/onboarding/invite/{token}`.\\n- **Performance monitoring**: profile fetches flow through `useApi` \u2192 `trackNetworkInstant` \u2192 `push` \u2192 `emit`, so profile page load times are recorded by the perf store.\\n- **Shared consumers of trade-pack utilities**: `TradeTypeIcon` and `primaryTradeCode` are also used by `DashboardClient`, `ProfileClient`, and `WorkforceMetricRow` outside this module.\",\"trades-company-directory\":\"# Trades &amp; Company Directory\\n\\n# Trades &amp; Company Directory\\n\\nThe Trades &amp; Company Directory is the part of Assureworx that deals with **who is in whose network** and **how a trade account manages its own compliance dossier**. It is split across two sub-modules \u2014 a backend API layer and a principal-facing frontend \u2014 that share a common Postgres schema and a set of shared services.\\n\\n## Sub-modules\\n\\n- [Trades &amp; Company Directory \u2014 backend-src](backend-src.md) \u2014 Two API surfaces over shared services: `directoryRouter` (read-only network visibility for Tier 1 principals) and `partiesRouter` (self-service read/write for trade accounts).\\n- [Trades &amp; Company Directory \u2014 frontend-src](frontend-src.md) \u2014 The `(app)/trades/` UI that gives principal contractors a sortable/filterable directory, per-party compliance profiles, and a lineage view of the network.\\n\\n## How the sub-modules fit together\\n\\nThe frontend is the principal's window into the backend. `TradesDirectory`/`TradesClient` fetches the network from `directoryRouter` (via `getNetworkParties` in `shared/services/network.ts`) and renders it as a directory list; the lineage components `LineageSummary` and `LineageTree` consume the same read-only API to draw the chain from principal down through sub-contractors, workers, and pending invites (`CompanyNode`, `InviteRow`, `groundCount`). Selecting a company or individual drives `CompanyProfilePage`/`IndividualProfilePage` through their clients to the directory module's `loadPartyDetails`, `buildCompany`, `docSummary`, and `toIndividual` builders, which assemble the compliance profile.\\n\\nOn the other side, the backend's `partiesRouter` serves the signed-in trade's own profile editing \u2014 photos, location, availability, team roster, site assignments \u2014 backed by the `parties` module's `myTradePartyId` and the org-admin helpers `administeredTradeOrgId` and `employerPaysForPerson`.\\n\\nBoth surfaces lean on the same shared services: `network.ts` for party retrieval, `org-admin.ts` for admin/employer rules, `abn-verifier.ts` (`createAbnVerifier` switching between `LiveAbrVerifier` and `MockAbrVerifier`) for resolving unknown trades, and the perf-store pipeline (`trackNetworkInstant` \u2192 `push` \u2192 `emit`/`isRecording`) that traces every directory and profile API call.\\n\\n```mermaid\\nflowchart TD\\n    subgraph Frontend[\\\"frontend-src \u2014 (app)/trades\\\"]\\n        Directory[\\\"TradesDirectory / TradesClient\\\"]\\n        Profiles[\\\"Company &amp; Individual Profile Clients\\\"]\\n        Lineage[\\\"LineageSummary / LineageTree\\\"]\\n        SelfService[\\\"Trade self-service UI\\\"]\\n    end\\n\\n    subgraph Backend[\\\"backend-src\\\"]\\n        DirAPI[\\\"directoryRouter (read-only)\\\"]\\n        PartiesAPI[\\\"partiesRouter (self-service)\\\"]\\n        Services[\\\"Shared services:network.ts \u00b7 org-admin.ts \u00b7 abn-verifier.ts\\\"]\\n        DB[(\\\"Postgres schema\\\")]\\n    end\\n\\n    Directory --&gt; DirAPI\\n    Lineage --&gt; DirAPI\\n    Profiles --&gt; DirAPI\\n    SelfService --&gt; PartiesAPI\\n    DirAPI --&gt; Services\\n    PartiesAPI --&gt; Services\\n    Services --&gt; DB\\n```\\n\\n## Key workflows that span the module\\n\\n- **Directory browsing** \u2014 A principal filters and sorts their network in `TradesDirectory`; trade types are normalised via `tradeCodeFromName`/`TradeTypeIcon`/`TradeTypeList`, and compliance state is surfaced inline.\\n- **Compliance drill-down** \u2014 Opening a company or individual renders their full dossier (core checks, evidence documents, people) from the directory module's `buildCompany`/`toIndividual` builders.\\n- **Lineage exploration** \u2014 `LineageSummary` and `LineageTree` show the introducer chain end-to-end, including invites not yet accepted, using `prune`, `flatten`, `collectCompanyIds`, and `estimateCardWidth`.\\n- **Trade self-service** \u2014 A signed-in trade edits its own dossier through `partiesRouter`, with `myTradePartyId` identifying the party being edited and org-admin logic deciding employer/payer relationships.\\n- **Unknown-trade resolution** \u2014 When a principal encounters a trade not yet on the platform, `abn-verifier.ts` verifies the ABN and maps the payload (`mapAbrPayload`) to decide whether the party already exists.\",\"ui-components-app-shell\":\"# UI Components &amp; App Shell\\n\\n# UI Components &amp; App Shell\\n\\n## Module Overview\\n\\nThe **UI Components &amp; App Shell** module is the authenticated application frame for the Assureworx frontend. It provides:\\n\\n- The server-rendered session gate and error recovery for all authenticated routes\\n- The persistent application chrome: `Sidebar`, `TopBar`, and the workspace container that pages render into\\n- The **Dashboard**, the principal landing page, which combines KPI metrics, compliance status bars, an evidence attention queue, a trade network rail, and a site map preview under a single cross-filtering model\\n- The `ActivityRail` \u2014 a sliding activity feed that docks to the right edge of the viewport\\n\\nThe module sits at the boundary between Next.js server components (data loading, session resolution, redirects) and client components (interactive state, portals, media queries). It is deliberately architected so that the first paint already knows the user's identity and dashboard data; the client only refetches to stay fresh.\\n\\n```mermaid\\ngraph TD\\n    Root[\\\"app/layout.tsx \u2014 ToastProvider \u00b7 PerfRecorderHost\\\"]\\n    Root --&gt; Home[\\\"app/page.tsx \u2014 role-aware redirect via homeFor()\\\"]\\n    Root --&gt; App[\\\"(app)/layout.tsx \u2014 AppLayout: getServerMe + SessionProvider\\\"]\\n    App --&gt; Shell[\\\"components/shell/AppShell.tsx\\\"]\\n    Shell --&gt; Sidebar[\\\"Sidebar\\\"]\\n    Shell --&gt; TopBar[\\\"TopBar \u2014 title slot, history, notifications\\\"]\\n    Shell --&gt; WS[\\\"main#main-content workspace\\\"]\\n    WS --&gt; DashPage[\\\"dashboard/page.tsx \u2014 requirePortal('principal') \u00b7 3\u00d7 loadAppPage\\\"]\\n    DashPage --&gt; Dash[\\\"DashboardClient \u2014 KPIs, filters, panels\\\"]\\n    Dash --&gt; AR[\\\"ActivityRail \u2014 sliding activity door\\\"]\\n    Dash --&gt; API[\\\"useApi \u2192 /reporting/dashboard (seeded with initialData)\\\"]\\n```\\n\\n## App Shell\\n\\n### AppLayout \u2014 server-side session resolution\\n\\n`app/(app)/layout.tsx` wraps every authenticated page. It resolves the session once on the server via `getServerMe()` (a React `cache`d call), so the layout's own session fetch and the page's `requirePortal()` share a **single** `/auth/me` request per navigation. The comment in the source is explicit about why: fetching directly there doubled auth traffic and pushed the shared dev rate-limit bucket into 429s.\\n\\nError handling in the layout distinguishes three cases:\\n\\n- **401** \u2014 `getServerMe` raises/redirects; the layout calls `unstable_rethrow(err)` so redirect/notFound signals are not swallowed by the error card.\\n- **429 / `RATE_LIMITED`** \u2014 renders a \\\"Slow down\\\" recovery card. The user is still signed in; a refresh is the recovery path.\\n- **5xx / unreachable API** \u2014 renders a \\\"Can't reach Assureworx\\\" card with a link to `/profile`, which the app shell then renders in its normal (recoverable) mode.\\n\\nOn success it renders:\\n\\n```tsx\\n\\n  \\n  {children}&lt;\\/AppShell&gt;\\n&lt;\\/SessionProvider&gt;\\n```\\n\\n### AppShell \u2014 client shell composition\\n\\n`AppShell` (client component) owns the persistent layout and the top bar title slot:\\n\\n- **Navigation state** \u2014 `navOpen` drives the mobile drawer. `drawerMode` comes from `useMediaQuery(\\\"(max-width: 1023px)\\\")`. When the viewport leaves drawer mode, `navOpen` is force-reset.\\n- **Title slot** \u2014 pages can port their heading into the top bar. `AppShell` holds `titleSlot` state, passes a `titleSlotRef` setter to `TopBar`, and exposes `TopBarTitleSlotContext.Provider` to the page tree. `DashboardClient` consumes it via `useTopBarTitleSlot()`.\\n- **Role gates are server-side only.** The source contains an explicit note not to `router.replace()` on the client based on role \u2014 a client/server redirect fight (trade user with `next=/dashboard`) caused a dashboard\u2194profile request storm and a fake \\\"signed out\\\" 429 loop.\\n- **Document scroll lock** \u2014 every app route locks `html`/`body` overflow so the shell is a viewport-filling app, not a scrolling document. The cleanup restores previous inline styles.\\n- **`inert` handling** \u2014 when the mobile drawer is open or a top bar sheet is present, the main content column gets the `inert` attribute so it is unreachable by pointer or keyboard.\\n- **Workspace modes** \u2014 three classes are applied to `\n`:\\n  - Default: content fills the viewport height, no page scroll.\\n  - `dashboardWorkspace` (pathname `/dashboard` + `role === \\\"principal\\\"`): dashboard owns its internal scroll regions; the KPI donuts must not be squashed.\\n  - `scrollWorkspace` (`/admin`, `/support`): taller-than-viewport content scrolls inside the workspace while the sidebar/top bar stay fixed.\\n\\n`app/(app)/error.tsx` (`AppError`) is the client error boundary for the shell. It detects rate-limit errors by message regex or `status === 429` and offers a \\\"Try again\\\" `Button` that calls `reset()`.\\n\\n`app/(app)/loading.tsx` renders `SkeletonRows` immediately during route transitions so navigation feels instant while the next chunk mounts.\\n\\n### TopBar\\n\\n`AppShell` passes the `TopBar` these props (all internal to the shell):\\n\\n- `onMenuClick` / `menuExpanded` / `showMenuButton` / `menuButtonRef` \u2014 mobile drawer toggle with focus return on close\\n- `sidebarId` \u2014 ARIA wiring between the menu button and the sidebar\\n- `titleSlotRef` \u2014 receives the DOM node the page title should port into\\n- `onSheetPresentChange` \u2014 lets the shell know a top bar sheet is open so the sidebar brand can be raised above the veil\\n\\nThe top bar also hosts:\\n\\n- `HistoryNav` \u2014 in-app back/forward backed by `useAppHistory()`, which tracks history independent of the browser's (in-app buttons, disabled when the stack is at a boundary)\\n- Notifications (opens a sheet, with `hrefForNotification` / `toneForNotification` resolving targets and tones from shared libs)\\n- Session controls including `startEntraSignIn` and legal links\\n\\n### Sidebar\\n\\n`Sidebar` is a fixed left rail (`--sidebar-width`) with:\\n\\n- Brand block with close button (drawer mode only)\\n- Navigation items from `NavItem[]`, each rendered with `NavIcon`\\n- An org identity strip at the bottom: org name, role pill, and `LegalLinks`\\n\\n`NavIcon` maps nav names to raster PNGs in `/public` (greyscale alpha images), with inline SVG fallbacks for `training`, `reports`, and `team` \u2014 the only icons without PNG assets. All nav icons are decorative (`aria-hidden`); the label text sits beside them.\\n\\nThe `brandRaised` prop lifts the sidebar's z-index above top bar sheets. On desktop (`.brandRaised:not(.open)`), the aside itself ignores pointer events so it cannot steal clicks from sheet veils \u2014 only the brand/logo keeps pointer events.\\n\\n## Dashboard\\n\\nThe dashboard is the principal-role landing page. It is a two-layer structure:\\n\\n### Server page \u2014 `dashboard/page.tsx`\\n\\n`DashboardPage` is an async server component that:\\n\\n1. Calls `requirePortal(\\\"principal\\\")` \u2014 rejects non-principal roles before any data loads\\n2. Loads three resources in parallel:\\n   - `loadAppPage(\\\"/reporting/dashboard\\\")` \u2014 **required**\\n   - `loadAppPageOptional(\\\"/nodes/projects\\\")` \u2014 **optional**; returns `null` when absent\\n   - `loadAppPageOptional(\\\"/nodes/sites\\\")` \u2014 **optional**\\n3. Passes all three to `DashboardClient`\\n\\nThe `Optional` variants exist because projects/sites may not be present for a fresh network; `DashboardClient` accepts `null` and simply skips seeding the client cache for those endpoints.\\n\\n### Client page \u2014 `DashboardClient`\\n\\n`DashboardClient` is the interactive surface. It receives `initialData`, `initialProjects`, and `initialSites` and seeds three `useApi` hooks:\\n\\n```ts\\nconst { data, loading, error } = useApi(\\\"/reporting/dashboard\\\", { initialData });\\nconst { data: projects } = useApi(\\\"/nodes/projects\\\",\\n  initialProjects !== null ? { initialData: initialProjects } : undefined);\\nconst { data: sites } = useApi(\\\"/nodes/sites\\\",\\n  initialSites !== null ? { initialData: initialSites } : undefined);\\n```\\n\\nSo the server payload paints immediately, and `useApi` refetches in the background. The rest of the component derives all panel data from these three hooks plus the local filter state.\\n\\nLayout responsibilities:\\n\\n- Renders the greeting into the top bar title slot via `createPortal` (with an `srOnly` fallback if the slot isn't there yet)\\n- Renders a KPI strip (project/site counts, two contractor/tradesperson gauges, three evidence KPI buttons, quick actions)\\n- Renders a filter chip when a filter is active\\n- Renders the workspace: project/site status panel, evidence attention panel, trade network rail, site map, contractor status\\n- Wraps itself in `PerfProfiler id=\\\"DashboardClient\\\"` for performance instrumentation\\n\\n### Unified filter model\\n\\nThe dashboard's most distinctive design is a single `DashFilter` union that every panel reacts to:\\n\\n```ts\\ntype DashFilter =\\n  | { kind: \\\"partyStatus\\\"; status: PartyStatusKey }        // verified | awaiting | outstanding\\n  | { kind: \\\"evidence\\\"; bucket: EvidenceBucket }           // expiring | missing | failed\\n  | { kind: \\\"tradeType\\\"; code: string; status?: PartyStatusKey }\\n  | { kind: \\\"readiness\\\"; state: ReadinessKey };            // ready | pending | blocked | action_required\\n```\\n\\nFilters come from many UI surfaces \u2014 an evidence row click, a gauge segment, a trade rail row \u2014 and all collapse into this one type. `toggleFilter(current, next)` returns `null` when the exact same filter is re-applied, so every control behaves as a toggle.\\n\\nFrom the raw filter, three derived axes drive every panel:\\n\\n- `activePartyStatus` \u2014 maps readiness states (`ready\u2192verified`, `pending\u2192awaiting`, `blocked|action_required\u2192outstanding`) and evidence buckets (`expiring|missing\u2192awaiting`, `failed\u2192outstanding`)\\n- `activeReadiness` \u2014 maps back from party status via `readinessFromPartyStatus`\\n- `activeEvidence` \u2014 the evidence bucket, used to highlight the KPI buttons and filter the attention list\\n\\n`evidenceBucketsForFilter` computes which evidence cohorts should show under any filter (e.g. `awaiting` shows missing + expiring; `outstanding` shows missing + failed; `verified` shows nothing).\\n\\nEvery list derives from the same axes:\\n\\n| List | Source | Filter behavior |\\n|---|---|---|\\n| `projectRows` | `projects` | Filtered to rows where `readinessMetric` &gt; 0 for the active readiness; sorted by that metric desc, then name; top 8 |\\n| `companyRows` | `data.topCompanies` | Same pattern; top 6 |\\n| `tradeTypes` | `data.tradeTypes` | Filtered by trade code if a trade filter is set; filtered/sorted by active party status metric; top 8 |\\n| `filteredEvidence` | `data.evidence.items` (falls back to `data.expiringSoon`) | Bucket-filtered; top 8 |\\n| `mapPlaces` | `sites` | Filtered by `siteMatchesReadiness` when readiness is active |\\n\\nThe `maxTotal` values (`projectMaxTotal`, `companyMaxTotal`, `tradeMaxTotal`) are computed per-list so every bar's width is relative to the strongest row \u2014 this keeps bar lengths comparable within a panel.\\n\\n### Visualization components\\n\\nAll are local to `DashboardClient.tsx` and share a consistent semantics: three statuses drawn in the Assureworx palette.\\n\\n**`StatusGauge`** \u2014 a wide, shallow SVG half-circle (76\u00d742, stroke 8) showing `verified` / `awaiting` / `outstanding` split for contractors and tradespeople. The total sits centered under the arc. Hover/focus reveals a tooltip (`role=\\\"tooltip\\\"`) with per-segment buttons that set the party-status filter; inactive segments dim to 35% opacity when a filter is active.\\n\\n**`VolumeBar`** \u2014 a segmented horizontal track used for project/site/company readiness. Three segments: `ready`, `pending`, `action_required` (the separate `blocked` count rolls into `action_required` \u2014 the code constant comments this). Each segment is a `` that toggles the readiness filter. When a readiness filter is active, the bar collapses to a single full-width segment for that state and shows only that segment's count. Optionally renders counts inside segments when \u2265 3 (`showCounts`). The track width is proportional to `displayTotal / maxTotal`, with an 8% floor so a positive row is never invisible.\\n\\n**`StatusBar`** \u2014 the party-status analogue of `VolumeBar`, used in the trade rail. Renders `verified` / `awaiting` / `outstanding` segments with `STATUS_COLORS`.\\n\\n**`Legend`** \u2014 a color legend that doubles as the filter control. When `onSelect` is provided, each item is a button with `aria-pressed`; where there's no handler (rare in this module) it's a plain span.\\n\\n**`KpiButton`** \u2014 a metric tile for the evidence KPIs (Expiring / Missing / Failed-expired). Active state highlights the tile and sets `aria-pressed`.\\n\\n### Helper functions worth knowing\\n\\n- `greetingFor(name)` \u2014 time-of-day greeting (\\\"Good morning/afternoon/evening, FirstName\\\")\\n- `dayParts(iso)` \u2014 parses an ISO date into `{ day, month }` using a **fixed 3-letter month table** (`MONTH_SHORT`). The comment explains why: Node ICU and browsers disagree on `en-AU month: \\\"short\\\"` formatting, so the table guarantees identical server/client output\\n- `evidenceStatusLabel(item)` \u2014 human label per evidence bucket (\\\"Missing\\\", \\\"Expired\\\", \\\"Failed\\\", \\\"N days left\\\")\\n- `readinessMetric(r, state)` \u2014 collapses `blocked` + `action_required` into one metric so third/action rows behave identically everywhere\\n- `tradeSplit(type)` \u2014 maps `TradeTypeCount` into a `StatusSplit`, folding `actionRequired + suspended` into `outstanding`\\n\\n### Evidence attention panel\\n\\nRows render a black identity-mark date block (day + month, or a \\\"\u2013 / GAP\\\" block for missing evidence) plus the party name and document info. Each row is a button that sets the evidence filter to that row's bucket. Cohort styling is strictly three-state: `expiring` = electric blue, `missing` = amber (`rag-attention`), `failed` = red (`rag-blocked`). The default empty text is \\\"Nothing needing attention | network evidence is clear.\\\"\\n\\n### Map preview\\n\\nThe sites panel passes `mapPlaces` \u2014 derived from `sites` via `geocodeQueryForSite` / `formatSiteAddress` fallbacks \u2014 to `SiteMapBoardLazy` in `preview`/`embedded` mode, with `mode=\\\"preview\\\"` and an `onOpenMap` callback that routes to `/projects?view=map`. Selection state (`selectedSiteId`) lives in `DashboardClient` and is passed down. The map lazy-loads, hence the `SitesLoading` skeleton text (\\\"Loading map\u2026\\\") while it mounts.\\n\\n### Quick actions\\n\\nThe `+` button opens a pill menu of role-derived actions:\\n\\n- `tier1_admin` \u2192 \\\"Create project\\\" (`/projects?new=1`)\\n- Everyone with the app \u2192 \\\"Manage projects\\\" (`/projects`)\\n- `canSendRequests(me)` \u2192 \\\"Send request\\\" (`/requests?new=1`)\\n- Always \u2192 \\\"Manage trades\\\" (`/trades`)\\n\\nThe menu uses `usePresence(actionsOpen)` for mount/unmount animation, closes on outside `mousedown` or `Escape`, and renders a blurred backdrop via `createPortal` with `role=\\\"menu\\\"` / `menuitem` semantics. Navigation uses `router.push`.\\n\\n## ActivityRail\\n\\n`ActivityRail` is a fixed-position \\\"sliding door\\\" on the right edge:\\n\\n- Closed, it shows a 40px `seam` strip with a pulse dot and an item count (capped at \\\"99+\\\").\\n- Open, the 300px door slides out; the seam remains as the leftmost strip of the door.\\n- **Two interaction modes:**\\n  - **Pinned mode** (default): the door stays open until explicitly collapsed. `DashboardClient` controls this and reserves layout space \u2014 the board gets `margin-right: 300px` when pinned.\\n  - **Overlay mode** (`pinDisabled`, viewport \u2264 1279px via `useMediaQuery`): clicking the seam opens an overlay that closes on Escape or outside click. Pinning is unavailable because there isn't enough horizontal room.\\n- Activity data is **owned by `DashboardClient`** \u2014 `activity` comes from the dashboard summary prop; the rail never issues its own request. The comment states this explicitly: \\\"no second GET.\\\"\\n- Rows render `summary` + `formatRelative(row.at)` timestamps; up to 30 rows, then `EmptyState` (\\\"No activity yet\\\").\\n\\nKeyboard handling on the rail: when open, Escape closes it (collapsing pin or overlay depending on mode).\\n\\n## Styling &amp; Design Tokens\\n\\nAll styles are CSS Modules importing shared design tokens from `styles/tokens.css` (via `globals.css`). Key patterns:\\n\\n- **Dashboard palette** \u2014 `.board` defines scoped custom properties:\\n  - `--dash-verified`: `color-mix(in srgb, var(--color-electric-blue) 72%, white)` (light blue)\\n  - `--dash-awaiting`: `color-mix(in srgb, var(--color-violet) 70%, white)` (light purple)\\n  - `--dash-outstanding` / `--dash-blocked`: `var(--color-deep-violet)`\\n  - Evidence cohorts: `--dash-expiring` (electric blue), `--dash-missing` (`rag-attention`), `--dash-failed` (`rag-blocked`)\\n- **Layout grid** \u2014 the workspace is a 2\u00d72 grid: `minmax(0, 1fr) 220px` columns, `1fr 1.15fr` rows. The trade rail spans `grid-row: 1 / -1` in the right column; `.mid` and `.bottom` sections occupy the left column rows. At \u2264 1279px the rail column narrows to 190px; at \u2264 1023px everything stacks to a single scrollable column and the trade rail becomes a 2-column (3 on \u2264 720px) tile grid.\\n- **Scope discipline** \u2014 the activity rail is hidden entirely \u2264 1023px; the stats row wraps \u2264 1023px; KPI pipes are removed \u2264 720px.\\n- **Accessibility** \u2014 `prefers-reduced-motion` disables door/panel/gauge transitions and the seam pulse; `:focus-visible` rings are styled on interactive gauges/legend/buttons; every visual segment carries an `aria-label` describing its counts.\\n- **Perf instrumentation** \u2014 `PerfProfiler` wraps `AppShell`, the workspace, `DashboardClient`, and `ActivityRail`; `PerfRecorderHost` in the root layout captures the traces.\\n\\n## Integration Points\\n\\n| Concern | Mechanism |\\n|---|---|\\n| Session | `AppLayout` \u2192 `getServerMe()` \u2192 `SessionProvider`; pages re-check via `requirePortal()`; `useSession()` gives client components `me` / `role` |\\n| Data loading | Server: `loadAppPage` / `loadAppPageOptional`; client: `useApi` seeded with `initialData`. The dashboard also demonstrates the fallback pattern \u2014 optional resources arrive as `null` and skip cache seeding |\\n| Routing | `HomePage` redirects via `homeFor(me)` (never hardcodes `/dashboard`). The dashboard's actions use `router.push`; map \\\"Open map\\\" routes to `/projects?view=map` |\\n| Cross-component communication | `TopBarTitleSlotContext` (page \u2192 top bar heading); `SessionProvider` (identity everywhere); `usePresence` + portals for overlays |\\n| Shared UI | `Button`, `SkeletonRows`, `EmptyState`, `TradeTypeIcon`, `SiteMapBoardLazy`, `PartyIdentity`/`PartyAvatar` |\\n| Shared libs | `useApi`, `useMediaQuery`, `usePresence`, `useAppHistory`, `formatRelative`/`plural`, `geo` helpers, `session-core` (`canSendRequests`, `homeFor`) |\\n| Performance | `PerfProfiler` / `PerfRecorderHost` wrap the shell and dashboard at every level |\\n| Notifications | Top bar sheets resolve targets via `hrefForNotification` / `toneForNotification` from `@/lib/notification-href` |\\n\\n## Key Architectural Decisions\\n\\n1. **Server-first session and data** \u2014 the shell knows who the user is before hydration (`getServerMe` cached per navigation, shared with `requirePortal`), and the dashboard paints from server-fetched `initialData` before `useApi` refetches. This avoids both a client splash and doubled auth traffic.\\n2. **No client-side role redirects** \u2014 role gates live exclusively in server `requirePortal`. The source documents the historical failure mode (client + server redirects fighting \u2192 request storm \u2192 fake signed-out 429 loop) to prevent regression.\\n3. **One filter, all panels** \u2014 the `DashFilter` union with derived `activePartyStatus` / `activeReadiness` / `activeEvidence` axes means any click anywhere on the dashboard (gauge segment, evidence row, trade row, legend) filters every panel consistently, with toggle semantics.\\n4. **Blocked rolls into action_required** \u2014 the readiness model intentionally merges `blocked` into `action_required` for display and filtering so the UI presents exactly three behavioral states, never four.\\n5. **A single source of activity** \u2014 `ActivityRail` consumes the dashboard summary's activity array rather than fetching its own feed, keeping the right-edge rail free of network traffic.\\n6. **Consistent date rendering** \u2014 a hardcoded month table avoids Node ICU vs. browser locale disagreements in SSR'd date labels.\",\"user-profile-account-settings\":\"# User Profile &amp; Account Settings\\n\\n# User Profile &amp; Account Settings\\n\\n## Overview\\n\\nThis module provides the authenticated user's self-service surfaces: the **work profile** page, the **organisation settings** page, and the **personal account** page. It also contains shared profile display components (`ProfileHeader`, `ProfileSheet`) used by the public-facing company and individual profile routes.\\n\\nThree routes are covered:\\n\\n| Route | Server component | Client component | Purpose |\\n|---|---|---|---|\\n| `/profile` | `ProfilePage` | `ProfileClient` | Work status, compliance checks, evidence upload, photo, engagements, contact/trade details |\\n| `/settings` | `SettingsPage` | `SettingsClient` | Organisation settings \u2014 seat invites (PC admins), security, legal links |\\n| `/settings/account` | `AccountSettingsPage` | `AccountClient` | Personal account \u2014 photo, login email change, security, erasure request |\\n\\nAll three routes follow the same architecture: a **server component** performs the authenticated data load (via `loadAppPage` / `loadAppPageOptional`) and seeds the initial data into a **client component**, which then takes over fetching and mutation using `useApi` and `useSession`.\\n\\n```mermaid\\ngraph TD\\n    P[\\\"/profile\\\"] --&gt; PP[ProfilePage] --&gt; PC[ProfileClient]\\n    S[\\\"/settings\\\"] --&gt; SP[SettingsPage] --&gt; SC[SettingsClient]\\n    A[\\\"/settings/account\\\"] --&gt; AP[AccountSettingsPage] --&gt; AC[AccountClient]\\n\\n    subgraph \\\"Shared domain components\\\"\\n        H[ProfileHeader]\\n        T[ProfileSheet / Tabs / Sections]\\n    end\\n```\\n\\n## Data loading pattern\\n\\nEach page uses the same two-step pattern:\\n\\n1. **Server seed** \u2014 the page component runs in parallel loads and passes the results as props:\\n   - `loadAppPage(path)` \u2014 required; throws and triggers an error boundary on failure.\\n   - `loadAppPageOptional(path)` \u2014 soft-fails to `null`; the client hook then fetches the resource itself.\\n2. **Client hydration** \u2014 the client component calls `useApi(path, { initialData })`. When `initialData` is provided, the first render uses it and skips the initial fetch. Subsequent `refresh()` calls revalidate from the network.\\n\\nThis avoids a flash-of-loading on first paint while still getting live data after any mutation.\\n\\n---\\n\\n## Profile page (`/profile`)\\n\\n### Entry point\\n\\n`ProfilePage` gates the route with `requirePortal(\\\"trade\\\")`, then loads in parallel:\\n\\n- `/parties/me/profile` (required) \u2192 `initialProfile`\\n- `/parties/me/engagements` (optional) \u2192 `initialJobs`\\n\\n`initialJobs` is `MyEngagements | null`. A `null` value means the server-side engagements fetch soft-failed, so `ProfileClient`'s `useApi` hook loads it client-side instead.\\n\\n### Client structure\\n\\n`ProfileClient` maintains three tabbed sections \u2014 **Status**, **Work**, **Details** \u2014 swapped via `activeSection` state. Inactive sections are unmounted, and a scroll-to-top effect runs on section change. The component is wrapped in `PerfProfiler`; each section is wrapped in `PerfSection` for performance instrumentation.\\n\\nData sources:\\n\\n| Hook | Endpoint | Notes |\\n|---|---|---|\\n| `useApi` | `/parties/me/profile` | Seeded from `initialProfile` |\\n| `useApi` | `/parties/me/engagements` | Seeded only if `initialJobs` is non-null |\\n| `useApi` | `/payments/me/funding` | Only when `me.personPartyId` is set |\\n\\n`PassOverlay` and `PassPhotoPanel` are dynamically imported with `ssr: false` \u2014 the pass stack (QR code + camera capture) stays out of the default profile bundle.\\n\\n### Work status model\\n\\nThe most important logic in this module is the derivation of the user's displayed work status. It lives entirely in `ProfileClient` and combines four inputs:\\n\\n**1. Server verification state** \u2014 `data.party.status` (`verified`, `pending`, `action_required`, `suspended`).\\n\\n**2. Core checks** \u2014 `data.checklist.coreChecks`. A check is \\\"needed\\\" when its state is `pending` or `failed` (`needs` map). The screen is \\\"done\\\" when all required checks have state `passed` and none have state `failed`:\\n\\n```ts\\nconst requiredChecks = checks.filter((c) =&gt; c.state !== \\\"not_required\\\").length;\\nconst passedChecks = checks.filter((c) =&gt; c.state === \\\"passed\\\").length;\\nconst checksIncomplete = requiredChecks &gt; 0 &amp;&amp; passedChecks &lt; requiredChecks;\\nconst anyCheckFailed = checks.some((c) =&gt; c.state === \\\"failed\\\");\\nconst checksDone = !checksIncomplete &amp;&amp; !anyCheckFailed;\\n```\\n\\n**3. Payment status** \u2014 derived from the funding view label:\\n\\n- `paymentSettled` \u2014 label is `paid_by_you`, `paid_by_your_organisation`, or `paid_by_sponsor` (or the user has no `personPartyId`, meaning they are a company-only account with no personal payment obligation).\\n- `awaitingPayment` \u2014 label is `payment_required`, `payment_failed`, `payment_processing`, `billing_review_required`, or `awaiting_employer_payment`.\\n\\n**4. Pass photo** \u2014 for trade-role users (`trade_company_admin` or `trade_individual`), `hasPassPhoto` requires a real photograph: `me.photoUrl` must exist and not be a generated monogram URL (checked via `isGeneratedMonogramUrl`). Company accounts display the org logo in the hero instead, so the photo requirement only applies when `showPassPhoto` is true.\\n\\nThe final derivation:\\n\\n```ts\\nconst workReady =\\n  data?.party.status === \\\"verified\\\" &amp;&amp;\\n  checksDone &amp;&amp;\\n  paymentSettled &amp;&amp;\\n  !awaitingPayment &amp;&amp;\\n  (!showPassPhoto || hasPassPhoto);\\n\\nconst displayStatus = workReady\\n  ? \\\"verified\\\"\\n  : anyCheckFailed\\n    ? \\\"suspended\\\"\\n    : awaitingPayment || data.party.status === \\\"verified\\\" || data.party.status === \\\"action_required\\\"\\n      ? \\\"action_required\\\"\\n      : data.party.status;\\n```\\n\\nSo the status shown to the user is **not** simply `party.status` \u2014 it is a computed value. A user whose server status is `verified` but who has an unpaid invoice will see `action_required` (\\\"Pay to get Verified\\\"), not a verified badge.\\n\\nThe status copy has three special cases that override the generic per-status text:\\n\\n- `awaitingEmployerPayment &amp;&amp; checksDone` \u2192 *\\\"Waiting for your employer to pay\\\"* \u2014 checks are green but the employer has not settled.\\n- `awaitingPayment &amp;&amp; checksDone` \u2192 *\\\"Pay to get Verified\\\"* \u2014 checks are green but payment is owed.\\n- `showPassPhoto &amp;&amp; !hasPassPhoto &amp;&amp; checksDone &amp;&amp; paymentSettled &amp;&amp; verified` \u2192 *\\\"Add your pass photo\\\"* \u2014 everything is done except the gate-facing face photo.\\n\\nThe seal (\u2713/\u00d7) next to the avatar only renders for terminal states \u2014 `verified` or `suspended`. While checks are still in progress the UI deliberately hides it.\\n\\n### Status section\\n\\nThe Status section (`profile-status`) is the default landing tab. It contains:\\n\\n- **`FundingCallout`** \u2014 rendered only when `me.personPartyId` is set; surfaces payment state.\\n- **Status hero panel** \u2014 headline, status chip, avatar, verification seal, and key metrics (core checks passed, current jobs, connected builders, status-since date).\\n- **Avatar / photo control** \u2014 for trade roles it's a button that opens the `PassPhotoPanel` capture flow; otherwise it's a file-input label that uploads via `onPhotoChange`. Clicking either triggers client-side compression through `squareJpegFromFile` (from `@/components/pass/pass-view`) before upload, keeping camera-roll images under the 500 KB API cap.\\n- **`WorkforceMetricRow`** \u2014 only for company profiles (`data.party.kind === \\\"trade_company\\\"`); shows the workforce breakdown and lets the user filter by trade type.\\n- **`WorkChecksBoard`** \u2014 renders the core checks with expandable drawers. `expandedKey` state controls which drawer is open; `openCheck(key)` navigates to the Status tab and expands the given check.\\n\\n### Check board and evidence drawers\\n\\n`WorkChecksBoard` receives pre-built drawer content from the `drawers` memo in `ProfileClient`. Each drawer is built conditionally based on the `needs` map (checks that are `pending` or `failed`).\\n\\n**Check-to-class-code mapping** (`matchesCheck`):\\n\\n| Check key | Class codes |\\n|---|---|\\n| `abn` | `A003` |\\n| `identity` | `A002` |\\n| `insurance` | `A004`, `A005`, `A110` |\\n| `qualifications` | `A112`, `A113`, `A114`, `A116`, `A001` |\\n| `licence` | `/^A1(0[1-9]|1[0-9])$/` excluding `A110`, `A112\u2013A116` |\\n\\n**EvidenceDrawer** renders the evidence rows for a check, each with a status chip (`evidenceChip`) and an action button when inline upload is available. The upload target is chosen by `pickUploadTarget`, which ranks rows by status priority:\\n\\n1. `missing` / `expired` / `rejected` (highest)\\n2. `expiring_soon`\\n3. `pending_verification`\\n4. anything else\\n\\nWhen the user picks a row, the drawer swaps to an inline `EvidenceUploadForm` (compact mode). After upload it fires `onUploaded`, which calls `refresh()` on the profile. The identity drawer is different \u2014 its primary action is a button linking to `/verify` (the Didit identity check flow), and its copy notes that Assureworx keeps only the result and expiry date, not the ID images.\\n\\n**ABN drawer** \u2014 a form that validates the ABN is 11 digits, then posts to `/onboarding/abn`. The response (`AbnVerifyResult`) distinguishes:\\n\\n- `valid &amp;&amp; active` \u2014 confirmed; drawer closes with a toast.\\n- `valid &amp;&amp; !active` \u2014 registered but ABR status is not *Active*; compliance requires Active, so the profile remains incomplete.\\n\\nThe ABN field is also used as the source of truth for `data.party.abn` on load: a `useEffect` seeds the input from the party's ABN the first time the data arrives.\\n\\n### Work section\\n\\nThe Work section (`profile-work`) renders current and historical engagements:\\n\\n- **Current work** \u2014 `jobs.active`, rendered as `EngagementList` with `variant=\\\"active\\\"`. Empty state copy differs for companies (\\\"when a builder places your company or one of your people\\\") vs individuals.\\n- **Work history** \u2014 `jobs.completed`, rendered with `variant=\\\"completed\\\"`.\\n\\nWhile `jobsLoading` is true, both blocks show `SkeletonRows`.\\n\\n### Details section\\n\\nThe Details section (`profile-details`) is a two-column layout:\\n\\n**Left column:**\\n\\n- **Personal &amp; trade details** \u2014 a definition list of ABN (formatted via `formatAbn`), email, phone, location, and trade types (`TradeTypeList`). Individual profiles get an \\\"Edit details\\\" toggle that reveals the **location form**. `saveLocation` validates that at least suburb or postcode is present (postcode must be 4 digits), then PUTs to `/parties/me/location` with `suburb`, `postcode`, and `region` (nullable). State options come from `AU_REGIONS` in `@assureworx/shared-types`.\\n- **Open to new business** \u2014 company-only. A checkbox toggles `data.availability.openToNewBusiness`; when enabled, a region chip grid appears. Every toggle persists immediately via `saveAvailability`, which PUTs the full `TradeAvailability` shape to `/parties/me/availability` and refreshes the profile. If a company enables availability with no regions selected, it defaults to `[\\\"NSW\\\"]`.\\n\\n**Right column:**\\n\\n- **Connected builders** \u2014 `data.networks`, rendered as `PartyIdentity` rows with \\\"Connected since\\\" dates. Empty state prompts the user to accept a request.\\n- **Identity** \u2014 `IdentityPanel` in compact inline mode. The panel refreshes the profile on change. Below it, a privacy note explains that identity evidence is not shared with builders.\\n\\n### Photo endpoint selection\\n\\n`onPhotoChange` picks the upload endpoint based on the party kind:\\n\\n- Trade-role users (`showPassPhoto`) or non-company parties \u2192 `/parties/me/photo` (person face)\\n- Company accounts \u2192 `/parties/me/org-photo` (org logo)\\n\\nThe comment in the code is explicit: company admins still set a *person* face for the header and pass; the org logo is a separate field.\\n\\n---\\n\\n## Settings page (`/settings`)\\n\\n`SettingsPage` loads `/memberships/seats` and `/auth/entra/status` as optional seeds, then renders `SettingsClient`.\\n\\n### Role gating\\n\\n`isPcAdmin` is derived from `me.active?.role === \\\"tier1_admin\\\"`. Non-admins see:\\n\\n- Security panel\\n- A placeholder \\\"Organisation settings\\\" panel (\\\"Billing and preferences \u2026 will land here\\\")\\n- Privacy and terms panel\\n\\nPC admins additionally see the **Users** panel.\\n\\n### Security panel\\n\\nWhen `entra?.enabled` is true, the panel explains that passwords live in Microsoft Entra External ID and provides a button that calls `startEntraSignIn({ loginHint: me.email, returnTo: \\\"/settings\\\", prompt: \\\"login\\\" })` \u2014 re-authenticating with the identity provider so the user can change/reset their password.\\n\\nWhen Entra is not configured (dev environments), an `EmptyState` explains that development sign-in does not use passwords.\\n\\n### Users panel (seat invites)\\n\\nThe panel contains:\\n\\n**Invite form** \u2014 email (required), optional name, and role (`tier1_user` viewer or `tier1_admin` admin). Submitting POSTs to `/memberships/seats`. The response is a `SeatInviteRow`; a toast reflects whether the invite was emailed (`row.emailSent`) or whether a copy-link action is needed. The form resets and the seat list refreshes.\\n\\n**Seat list** \u2014 a table of existing seats with email, role (underscores replaced with spaces), a status chip via `seatStatusChip`:\\n\\n| Status | Chip |\\n|---|---|\\n| `pending` | Pending |\\n| `accepted` | Accepted (verified kind) |\\n| `expired` | Expired |\\n\\nPending invites with an `inviteUrl` get a \\\"Copy link\\\" action button, which uses `navigator.clipboard.writeText` and toasts the result.\\n\\n---\\n\\n## Account settings (`/settings/account`)\\n\\n`AccountSettingsPage` loads `/auth/email-change` (required) and `/auth/entra/status` (optional), passing them as `initialChange` and `initialEntra`.\\n\\n### Profile panel\\n\\nShows the current user's photo (or monogram fallback via `me.initials`) and name/organisation facts. Photo actions:\\n\\n- **Add / Change** \u2014 hidden file input opened via a ref; the same `squareJpegFromFile` compression path as the profile page, then PUT `/parties/me/photo`.\\n- **Remove** \u2014 DELETE `/parties/me/photo`.\\n\\n`onPhoto` and `removePhoto` both call `refresh()` on the session afterward so the header and other surfaces update immediately.\\n\\n### Login email panel\\n\\nThe email change flow is a small state machine:\\n\\n**Starting a change** \u2014 POST `/auth/email-change` with `{ newEmail }`. The response is an `EmailChangeRequest` with a `status`:\\n\\n- `pending` \u2014 confirmation must come from the **old** email (`confirm from {oldEmail}`)\\n- `old_ok` \u2014 the old address confirmed; confirmation must now come from the **new** email (`confirm from {newEmail}`)\\n\\nIf `row.emailSent` is false, the UI shows a \\\"Copy confirmation link\\\" button (`openChange.confirmUrl`).\\n\\n**While a change is pending**, the form is replaced by a pending-state block with:\\n\\n- **Resend** \u2014 POST `/auth/email-change/resend`\\n- **Cancel** \u2014 DELETE `/auth/email-change`\\n- Copy link (when `confirmUrl` is present)\\n\\nAll three call `refreshChange()` to re-fetch the current `EmailChangeRequest`. The GET endpoint can return `null`, which means no change is in flight and the start-change form is shown.\\n\\n### Security panel\\n\\nIdentical to the Settings page security panel except `returnTo: \\\"/settings/account\\\"`.\\n\\n### Erasure panel\\n\\n\\\"Erase my personal details\\\" is a two-step destructive flow:\\n\\n1. **Request erasure** \u2014 reveals the confirmation block. The copy is explicit about what gets removed immediately (photo, phone, home locality, identity outcome, compliance documents, trade tags, network placements), what happens after 90 days (name and email cleared), and what is kept (invoices, audit trail \u2014 legally required).\\n2. **Confirm erasure** \u2014 POST `/parties/me/closure` with `{ confirm: true }`. On success, shows a toast and redirects to `/auth/login`. On failure, the error is toasted and the button returns to idle.\\n\\nThe confirmation state is local (`closureConfirm`); navigating away discards it, which is the intended safety behaviour.\\n\\n---\\n\\n## Shared components\\n\\n### `ProfileHeader`\\n\\nUsed by the public company/individual profile pages (`CompanyProfileClient`, `IndividualProfileClient`). It renders the identity header for any party: avatar (photo or initials), name, kind label, status chip (`verifiedChip`), status-since date, optional status reason, and a facts row (Assureworx ID, ABN, email, phone).\\n\\nKey behaviours:\\n\\n- `headingLevel` controls whether the name renders as `h1` (standalone page) or `h2` (page already has a `PageHeader`).\\n- `onPhotoChange` turns the avatar into a change-photo control with a hidden file input; `photoBusy` shows progress.\\n- `embedded` removes the card chrome for use inside a `ProfileSheet`.\\n- The verified seal (\u2713) only renders when `status === \\\"verified\\\"`.\\n\\n### `ProfileSheet`\\n\\nA set of presentational primitives for the public profile pages:\\n\\n- **`ProfileSheet`** \u2014 a single bordered article surface; sections stack with hairline dividers, never nested cards.\\n- **`ProfileSheetTabs`** \u2014 a ribbon navigation (`role=\\\"tablist\\\"`) with icon + label tabs.\\n- **`ProfileSheetTabPanel`** \u2014 a tab panel with optional header actions.\\n- **`ProfileSheetSection`** \u2014 a collapsible section (`collapsible` prop) with a chevron toggle and optional action slot.\\n\\nThese components deliberately have no data logic \u2014 they are the layout vocabulary for profile display.\\n\\n### Initials utilities (`src/lib/initials.ts`)\\n\\n- `initialsFromName(name)` \u2014 two-letter initials (First Last \u2192 FL); single names take the first two characters.\\n- `isGeneratedMonogramUrl(url)` \u2014 detects seed/demo monogram SVGs (matching `/avatars/*.svg`). These are artwork, not photographs, so every surface renders them as a monogram fallback rather than a real photo. This distinction matters for `hasPassPhoto` in `ProfileClient` and the avatar logic in `ProfileHeader`, `PartyAvatar`, `TopBar`, and `EngagementList`.\\n\\n---\\n\\n## API reference\\n\\nEndpoints used by this module:\\n\\n| Method | Endpoint | Used by | Purpose |\\n|---|---|---|---|\\n| GET | `/parties/me/profile` | ProfilePage / ProfileClient | Profile, checks, evidence, networks, trade types |\\n| GET | `/parties/me/engagements` | ProfilePage / ProfileClient | Active and completed placements |\\n| GET | `/payments/me/funding` | ProfileClient | Payment state for work-ready logic |\\n| PUT | `/parties/me/availability` | ProfileClient | Trade availability + regions (companies) |\\n| PUT | `/parties/me/location` | ProfileClient | Suburb / postcode / region |\\n| PUT | `/parties/me/photo` | ProfileClient, AccountClient | Person face photo |\\n| DELETE | `/parties/me/photo` | AccountClient | Remove person photo |\\n| PUT | `/parties/me/org-photo` | ProfileClient | Company logo |\\n| POST | `/onboarding/abn` | ProfileClient | ABN verification against the ABR |\\n| POST | `/parties/me/closure` | AccountClient | Erasure request |\\n| GET | `/memberships/seats` | SettingsPage / SettingsClient | Seat invite list (PC admins) |\\n| POST | `/memberships/seats` | SettingsClient | Create seat invite |\\n| GET | `/auth/entra/status` | SettingsPage, AccountSettingsPage | Entra External ID feature flag |\\n| GET | `/auth/email-change` | AccountSettingsPage / AccountClient | Current email change request (nullable) |\\n| POST | `/auth/email-change` | AccountClient | Start email change |\\n| POST | `/auth/email-change/resend` | AccountClient | Resend confirmation |\\n| DELETE | `/auth/email-change` | AccountClient | Cancel email change |\\n\\n---\\n\\n## Key invariants\\n\\n1. **`workReady` is never true without payment.** Even with every check passed and `party.status === \\\"verified\\\"`, an unpaid funding label keeps the user out of the verified state. The code comment is explicit: \\\"never treat unpaid as Verified / ready to work.\\\"\\n2. **Pass photos and ID-check selfies are different.** The pass photo is a gate-facing face photo stored on the party, separate from the Didit identity-check images, which are not retained by Assureworx at all.\\n3. **Company admins have two photos.** `onPhotoChange` targets `/parties/me/photo` for the person face (used by the header and pass) and `/parties/me/org-photo` only for the org logo.\\n4. **Erasure is immediate for sign-in but gradual for data.** The closure call stops sign-in and starts removal immediately; name/email are cleared after the 90-day cancellation window.\",\"wallet-digital-passes-backend-src\":\"# Wallet &amp; Digital Passes \u2014 backend-src\\n\\n# Wallet &amp; Digital Passes \u2014 backend-src\\n\\n## Overview\\n\\nThe wallet module issues and serves digital passes for verified trade professionals on two platforms:\\n\\n- **Apple Wallet** \u2014 signs and serves `.pkpass` files, plus a subset of the PassKit web service for device registration and pass refreshes.\\n- **Google Wallet** \u2014 produces an \\\"Add to Wallet\\\" save URL via a signed JWT.\\n\\nThe module lives in `backend/src/modules/wallet/` and is mounted at `/api/v1/wallet`.\\n\\nTwo design decisions shape the whole module:\\n\\n1. **The QR code is a pointer, not a credential.** The barcode payload (`awx.verify.v1|passId|abn|region|passVer`) is a lookup key. A gate scanner always calls `resolveGatePass` to ask Assureworx whether the holder is currently Verified and, when a site is supplied, whether their placement on that site is ready. Site readiness is never embedded in the pass (ADR-0018).\\n2. **Pass IDs are deterministic.** `passIdFor(partyId)` is an HMAC-SHA256 digest of the party ID, so re-issuing replaces the same Apple/Google pass instead of stacking duplicates, and a printed ID keeps resolving forever.\\n\\n## Architecture\\n\\n```mermaid\\ngraph TD\\n    subgraph \\\"HTTP layer\\\"\\n        API[\\\"walletRouter (index.ts)/api/v1/wallet\\\"]\\n        AWS[\\\"appleWebServiceRouter (apple-webservice.ts)/api/v1/wallet/apple\\\"]\\n    end\\n    subgraph \\\"Credential core\\\"\\n        SUBJ[\\\"subject.tsreadPassSubject / loadPassSubject\\\"]\\n        GATE[\\\"gate.tsresolveGatePass\\\"]\\n        PID[\\\"pass-id.tspassIdFor / appleAuthTokenFor\\\"]\\n    end\\n    subgraph \\\"Issuers\\\"\\n        AP[\\\"apple.tsbuildApplePkpass\\\"]\\n        GP[\\\"google.tsbuildGoogleSaveUrl\\\"]\\n    end\\n    subgraph \\\"Support\\\"\\n        PS[\\\"pass-store.tsreserve / put / consume\\\"]\\n        CFG[\\\"config.tsloadWalletConfig\\\"]\\n    end\\n    API --&gt; SUBJ\\n    API --&gt; GATE\\n    API --&gt; AP\\n    API --&gt; GP\\n    API --&gt; PS\\n    API --&gt; CFG\\n    AWS --&gt; SUBJ\\n    AWS --&gt; PID\\n    SUBJ --&gt; PID\\n    AP --&gt; CFG\\n    GP --&gt; CFG\\n```\\n\\n## Core concepts\\n\\n### Pass identity\\n\\n`passIdFor(partyId)` produces `AWX-XXXX-XXXX` \u2014 8 characters drawn from a Crockford-style alphabet (`ABCDEFGHJKMNPQRSTVWXYZ23456789`, excluding `I/L/O/U/0/1`) so IDs survive being read aloud at a gate. The HMAC secret comes from `WALLET_PASS_ID_SECRET`, falling back to `SESSION_SECRET`; production should set a dedicated secret so session rotation does not rewrite every QR and serial number. The party's `pass_ver` column participates in QR payloads and barcode messages.\\n\\n### QR payload semantics\\n\\nBoth issuers encode the same message:\\n\\n```\\nawx.verify.v1||||\\n```\\n\\n`region` is a presentation hint only \u2014 the gate endpoint ignores it, and identity fields are never trusted from the client.\\n\\n### Portable validity vs site readiness\\n\\nA pass can be in two distinct states:\\n\\n- **Portable validity** (`PassSubject.validity`): derived only from the holder's profile. `current` means the party status is `verified` AND the party has an 11-digit ABN. This is the gate for issuing a pass at all.\\n- **Site readiness** (`GateResolveResult.siteReadiness`): resolved live per site from the party's latest `t_link` state. Values are `ready`, `pending`, `blocked`, or `action_required`. A verified person can be \\\"Verified profile | not placed on this site\\\" \u2014 the pass alone never guarantees entry anywhere.\\n\\n## HTTP API\\n\\n### Wallet endpoints (`walletRouter` in `index.ts`)\\n\\n| Method | Path | Auth | Purpose |\\n|---|---|---|---|\\n| GET | `/api/v1/wallet/status` | `requireAuth` | Reports whether Apple/Google issuers are configured (`WalletStatus`). |\\n| GET | `/api/v1/wallet/gate/resolve` | public, `abrLookupLimiter` | Live gate lookup: `?passId=&amp;abn=&amp;siteId=` (`siteId` is an optional UUID). Returns `WalletGateResolve`. |\\n| GET | `/api/v1/wallet/pass` | `requireAuth` + `resolveTenantContext`, `walletLimiter` | Returns the credential as the app renders it (`WalletPass`). Returned even when invalid so the UI can show a greyed/expired face. |\\n| POST | `/api/v1/wallet/passes` | `requireAuth` + `resolveTenantContext`, `walletIssueLimiter` | Issues a pass. Enforces `assertPassIssuable`, builds Apple and/or Google artifacts, returns `{ appleDownloadPath, googleSaveUrl }`. |\\n| GET | `/api/v1/wallet/passes/:token/pkpass` | `walletLimiter` | Downloads the signed `.pkpass` buffer via a short-lived bearer token. |\\n\\nRequest/response shapes come from `@assureworx/shared-types` (`WalletPass`, `WalletPassIssueBody`, `WalletPassIssueResult`, `WalletStatus`, `WalletGateResolve`).\\n\\n### Apple PassKit web service (`appleWebServiceRouter`)\\n\\nMounted at `/api/v1/wallet/apple`; full paths are `/api/v1/wallet/apple/v1/...`. Apple devices call these when a pass is added or opened.\\n\\n| Method | Path | Purpose |\\n|---|---|---|\\n| POST | `/v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier/:serialNumber` | Register a device for a serial. Body: `{ pushToken }`. Returns 201 (Apple accepts 200 or 201). |\\n| DELETE | same path | Unregister a device. |\\n| GET | `/v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier` | List serials for a device. Returns 204 when empty, else `{ lastUpdated, serialNumbers }`. |\\n| GET | `/v1/passes/:passTypeIdentifier/:serialNumber` | Rebuild and return the current `.pkpass` for a serial. |\\n| POST | `/v1/log` | Acknowledges Apple log messages; the body is discarded without logging PII. |\\n\\nAll device/pass endpoints require the `Authorization: ApplePass ` header, validated by `parseAppleAuthToken`.\\n\\n## Components\\n\\n### Subject loading (`subject.ts`)\\n\\n`readPassSubject(ctx)` is the authenticated entry point. It restricts issuance to roles `trade_company_admin` and `trade_individual`, then runs `loadPassSubject` inside `withTenant` so row-level security applies to the actor's scope.\\n\\n`loadPassSubject` assembles the `PassSubject` from:\\n\\n- `t_party` \u2014 display name, kind, status, `status_at`, `image_uri`, `pass_ver`\\n- `t_party_ident` (kind `D01`) \u2014 the ABN\\n- `t_artifact` (status `F02`/`F04`) \u2014 licence number and earliest document expiry\\n- `t_party_tag` \u2014 trade name via `TAG_NAMES`\\n- `verificationIsPaidFor` for company parties (`K03`) \u2014 payment status (ADR-0025)\\n\\nValidity math:\\n\\n- `issuedAt` = `t_party.status_at` (the verified-since timestamp, not \\\"now\\\")\\n- `expiresAt` = min(`issuedAt` + 1 year, earliest document expiry)\\n- `verified` = party status `verified` AND 11-digit ABN\\n- `statusLabel` from `statusLabelFor`: `Verified`, `Suspended`, `Payment required`, `Action required`, `Not verified`, `Invalid`. Unpaid outranks generic wording because it is the only blocker the holder can clear in one step.\\n\\n`assertPassIssuable(subject)` refuses issuance with distinct errors in order: `PAYMENT_REQUIRED`, `NOT_VERIFIED`, `ABN_REQUIRED` (all 403).\\n\\n`readPassSubjectByPartyId(partyId, personId?)` is the sessionless path used by the Apple web service. It runs `withBypass` and loads by party ID; passing `personId` (recovered from the Apple auth token) keeps the holder's photo on the badge when a company admin refreshes.\\n\\n### Gate resolution (`gate.ts`)\\n\\n`resolveGatePass(tx, { passId, abn, siteId? })`:\\n\\n1. Normalizes the ABN to 11 digits; returns `null` on mismatch.\\n2. Finds the party by its `D01` identifier.\\n3. Recomputes `passIdFor(party.id)`; a mismatch means the QR does not belong to that ABN, so it returns `null` (the route responds 404).\\n4. Maps `PARTY_STATUS[status_cd]` to `portableStatus`; `passValid = portableStatus === \\\"verified\\\"`.\\n5. If `siteId` is supplied, reads the latest non-deleted `t_link` row and maps `LINK_STATE` to `siteReadiness`.\\n6. Builds an operator-facing `message` explaining the decision.\\n\\nThe route sets `Cache-Control: no-store` \u2014 the result must never be cached.\\n\\n### Apple pass builder (`apple.ts`)\\n\\n`buildApplePkpass(body, cfg, opts)` signs a generic `PKPass` using the Pass Type ID certificates from `WalletConfig`. It throws `503 WALLET_APPLE_NOT_CONFIGURED` when certificates are missing.\\n\\nHighlights:\\n\\n- `serialNumber` is the deterministic `passId`; `authenticationToken` comes from `appleAuthTokenFor`; `webServiceURL` is attached when configured.\\n- `voided: !opts.valid`; when invalid, the expiration date is set to now and the status label becomes `Invalid`.\\n- QR barcode carries the `awx.verify.v1` message.\\n- Face thumbnail: `body.selfie` (the server-side account photo \u2014 never client input) is rotated and resized via `sharp` to a 270\u00d7270 PNG.\\n- Field layout: header `STATUS`, primary `NAME`, secondary `TRADE | REGION` and `LICENCE`, auxiliary `ABN` and `EXPIRES`, back fields `Pass ID`, `Issued by`, `Gate`.\\n\\n`opts.personId` matters: for a company admin, the face on the badge is the person's, not the company's.\\n\\n### Google save URL (`google.ts`)\\n\\n`buildGoogleSaveUrl(body, cfg)` returns `https://pay.google.com/gp/v/save/`.\\n\\n- JWT claims: `iss` = service-account email, `aud: \\\"google\\\"`, `typ: \\\"savetowallet\\\"`, `origins` from config, payload with one `genericClass` and one `genericObject`.\\n- The object ID is derived from the pass ID via `objectSuffix`, so re-issuing updates the same Google object.\\n- The JWT deliberately excludes `classTemplateInfo` \u2014 heavy save tokens commonly fail on Google's endpoint with \\\"Something went wrong\\\". Class templates are managed by the separate `ensure-google-wallet-class.mts` script.\\n- Signed RS256 with a 10-minute expiry.\\n- Logo/hero/wide-logo image modules are added only when public HTTPS URIs are configured (`WALLET_GOOGLE_LOGO_URI`, `WALLET_GOOGLE_HERO_URI`, `WALLET_GOOGLE_WIDE_LOGO_URI`, or derived from `WALLET_ASSET_BASE_URL`/`PUBLIC_APP_URL`).\\n\\n### Download store (`pass-store.ts`)\\n\\nSigned `.pkpass` artifacts are never written to disk as files. The issue route creates a short-lived bearer token:\\n\\n- `reservePassToken(filename)` \u2014 called before the CPU-heavy build. In Postgres it inserts a placeholder row with an empty `payload` and `hits_left = 0`; an in-process `pending` map covers local waits.\\n- `putPassBuffer(buffer, filename, token?)` \u2014 stores the real payload with `hits_left = 2`.\\n- `consumePassBuffer(token, { waitMs })` \u2014 polls while the build is pending (150 ms interval), then reads and decrements the hit counter. When `hits_left` reaches 0 the row is deleted.\\n- `cancelPassToken(token)` \u2014 removes the placeholder if the build throws.\\n\\nKey properties:\\n\\n- **TTL**: 90 seconds (`TTL_MS`). A download URL is a bearer capability and should not live longer.\\n- **Double-GET tolerance**: `MAX_DOWNLOADS = 2` because Safari often GETs a `.pkpass` twice (preview + add).\\n- **DB-first, memory fallback**: Postgres table `t_pass_dl` is preferred; the in-memory `Map` is only used when `config.deployEnv` is `local` or `dev` (production fails closed). The availability probe re-runs every 30 s so an outage can flip the mode without a restart.\\n- **Migration-tolerant**: the code detects the `hits_left` column (migration 027). Without it, a process-local `legacyDownloadsLeft` map enforces the download limit.\\n- `getPassBuffer` and `takePassBuffer` are deprecated aliases of `consumePassBuffer`.\\n\\n### Pass IDs and auth tokens (`pass-id.ts`)\\n\\n- `passIdFor(partyId)` \u2014 deterministic HMAC-SHA256 with rejection sampling to keep output uniform over the 30-character alphabet.\\n- `appleAuthTokenFor(partyId, passId, personId?)` \u2014 formats `v1..` (3 parts) for sole traders, or `v1...` (4 parts) when the person differs from the party. The HMAC input binds the token to the party, holder, and pass ID; the token embeds the IDs so the web service can authorize without a reverse lookup.\\n- `parseAppleAuthToken(raw, passId)` \u2014 validates UUID parts by regex, recomputes the expected token, and compares with `crypto.timingSafeEqual`.\\n\\n### Configuration (`config.ts`)\\n\\n`loadWalletConfig()` reads everything from the environment and certificate files, returning a `WalletConfig` with `{ orgName, apple, google }`. It runs per request (status, issue, pass refresh), so lookups are kept cheap. Certificates can come from paths or inline PEM env vars; Google service-account keys can come from a JSON key file, from which `private_key` and `client_email` are extracted.\\n\\n### Asset generation\\n\\n- `generate-assets.mjs` \u2014 writes Apple Wallet icon and logo PNGs into `backend/src/modules/wallet/assets/`. Run it whenever branding changes.\\n- `generate-google-assets.mjs` \u2014 writes `logo.png` and `hero.png` into `frontend/public/wallet/`, composited from the frontend's `fav.png` and `verified.png`. Google fetches these over HTTPS, so the deployed origin must be public.\\n\\n## Configuration reference\\n\\n| Variable | Purpose |\\n|---|---|\\n| `WALLET_ORG_NAME` | Organization name shown on passes (default `Assureworx`). |\\n| `WALLET_PASS_ID_SECRET` | HMAC secret for pass IDs and Apple auth tokens; falls back to `SESSION_SECRET`. |\\n| `WALLET_APPLE_PASS_TYPE_ID` | Apple Pass Type ID. |\\n| `WALLET_APPLE_TEAM_ID` | Apple Team ID. |\\n| `WALLET_APPLE_WWDR_PATH` / `WALLET_APPLE_WWDR_PEM` | Apple WWDR certificate. |\\n| `WALLET_APPLE_SIGNER_CERT_PATH` / `WALLET_APPLE_SIGNER_CERT_PEM` | Pass Type ID signing certificate. |\\n| `WALLET_APPLE_SIGNER_KEY_PATH` / `WALLET_APPLE_SIGNER_KEY_PEM` | Signing private key. |\\n| `WALLET_APPLE_SIGNER_KEY_PASSPHRASE` | Passphrase for the signing key, if any. |\\n| `WALLET_APPLE_WEB_SERVICE_URL` | Full base URL for the PassKit web service; defaults to `${PUBLIC_APP_URL}/api/v1/wallet/apple`. |\\n| `WALLET_GOOGLE_ISSUER_ID` | Google Wallet issuer ID. |\\n| `WALLET_GOOGLE_SERVICE_ACCOUNT_KEY_PATH` | Path to a service-account JSON key file. |\\n| `WALLET_GOOGLE_SERVICE_ACCOUNT_EMAIL` | Service-account client email. |\\n| `WALLET_GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | Inline RSA private key (`\\\\n`-escaped). |\\n| `WALLET_GOOGLE_CLASS_SUFFIX` | Google class suffix (default `assureworx_compliance`). |\\n| `WALLET_GOOGLE_ORIGINS` / `CORS_ORIGIN` | Allowed origins for the Google save JWT (default `http://localhost:3000`). |\\n| `WALLET_GOOGLE_LOGO_URI` / `WALLET_GOOGLE_HERO_URI` / `WALLET_GOOGLE_WIDE_LOGO_URI` | Public HTTPS image URLs for Google Wallet objects. |\\n| `WALLET_ASSET_BASE_URL` / `PUBLIC_APP_URL` | Public origins used to derive Google image URLs and the Apple web service URL. |\\n\\nCertificate setup is documented in `wiki/runbooks/wallet-passes.md` (referenced in the module's own error messages).\\n\\n## Data flows\\n\\n### Issuance\\n\\n1. The client renders the credential via GET `/pass`, then POSTs `/passes` to issue.\\n2. `readPassSubject` loads the subject; `assertPassIssuable` enforces payment, Verified status, and ABN.\\n3. If Apple is configured: `reservePassToken` \u2192 `buildApplePkpass` \u2192 `putPassBuffer`; the response carries `/api/v1/wallet/passes//pkpass`.\\n4. If Google is configured: `buildGoogleSaveUrl` returns a save link.\\n5. The client downloads the `.pkpass` (allowed twice within 90 s) or opens the Google save URL.\\n\\n### Gate scan\\n\\n1. The scanner reads the QR payload (`passId|abn|...`).\\n2. GET `/api/v1/wallet/gate/resolve?passId=&amp;abn=&amp;siteId=` \u2014 public, rate-limited, `Cache-Control: no-store`.\\n3. `resolveGatePass` verifies the pass belongs to that ABN, checks portable Verified status, and (when `siteId` is present) the live placement state.\\n4. The result tells the operator whether to admit \u2014 and exactly why not when refusing.\\n\\n### Apple pass refresh\\n\\n1. The device calls GET `/v1/passes/:passTypeId/:serial` with an `ApplePass` token.\\n2. `appleAuth` \u2192 `parseAppleAuthToken` recovers `partyId`/`personId`.\\n3. `readPassSubjectByPartyId` reloads the subject, and the pass is rebuilt with current validity \u2014 `valid` is only true when `validity === \\\"current\\\"` AND the verification is paid for.\\n4. Because APNs background push is not wired, this refresh happens when the user opens the pass in Wallet. A pass whose status dropped rebuilds as voided and shows `Invalid` rather than being silently revoked.\\n\\n## Security model\\n\\n- **Server-side face on signed passes.** `POST /passes` accepts a `selfie` field for backward compatibility but ignores it; the photo comes from `t_party.image_uri` only. A client-supplied photo can never appear on a signed pass.\\n- **Apple auth tokens** are HMAC-bound to party/holder/pass ID, parsed with strict UUID validation, and compared via `crypto.timingSafeEqual`.\\n- **Download tokens** are 24-byte random values, expire in 90 s, and burn after 2 reads. The URL is a bearer capability; all credential responses set `Cache-Control: no-store`.\\n- **Tenant isolation.** Authenticated routes use `resolveTenantContext` + `withTenant`. Public routes (`gate/resolve`, the Apple web service, pkpass download) use `withBypass` and operate only on identifiers that are themselves authenticated: the pass ID, the Apple auth token, or the download token.\\n- **Rate limits.** `walletLimiter`, `walletIssueLimiter`, `abrLookupLimiter`, and `walletAppleWebServiceLimiter` are applied per route (defined in `shared/middleware/rate-limit.ts`).\\n\\n## Integration points\\n\\n- **Shared middleware/db:** `requireAuth`, `resolveTenantContext`, `withBypass`/`withTenant`, the `T` table schema, shared codes (`PARTY_STATUS`, `LINK_STATE`, `TAG_NAMES`), and `verificationIsPaidFor` from `shared/services/verified-status.ts`.\\n- **Retention:** `modules/retention/erase.ts` calls `passIdFor` during personal erasure so pass IDs referencing an erased party are accounted for.\\n- **Ops scripts:**\\n  - `backend/scripts/ensure-google-wallet-class.mts` \u2014 ensures the Google generic class exists (class templates are kept out of save JWTs).\\n  - `backend/scripts/probe-google-wallet.mts` \u2014 verifies Google issuer configuration.\\n  Both call `loadWalletConfig`.\\n- **Frontend:** Google brand assets are written to `frontend/public/wallet/` by `generate-google-assets.mjs`; the frontend renders the `WalletPass` shape returned by GET `/pass`.\\n- **Shared contract to watch:** `WalletPassIssueBody` is the common input to the API route, `buildApplePkpass`, and `buildGoogleSaveUrl`. Keep all three in sync when the pass face gains fields.\",\"wallet-digital-passes-frontend-src\":\"# Wallet &amp; Digital Passes \u2014 frontend-src\\n\\n# Wallet &amp; Digital Passes \u2014 frontend-src\\n\\n## Overview\\n\\nThis module is the frontend of the Assureworx gate credential: the card a verified holder presents at a site, the terminal screen of the verification journey, the in-app pass sheet reachable from the profile, and the camera/upload flow that puts a face on the badge.\\n\\nIt serves two distinct contexts:\\n\\n- **Verify journey terminal** \u2014 `VerifyPassClient` at `/verify/pass`, the last step of the verification flow. It shows either the server's real credential or a clearly-labelled sample, depending on whether the journey is *bound* (invite or signed-in account) or *anonymous demo*.\\n- **In-app credential** \u2014 `PassOverlay`, a modal sheet from the profile that presents the server credential on a dark \\\"wallet face\\\", plus `PassPhotoPanel`, which manages the photo that appears on the pass.\\n\\nBoth contexts render through the same component \u2014 `DigitalPass` \u2014 fed by a shared view model (`PassView`) that normalises two very different sources: the server-authorised `WalletPass` and the demo session stored in `sessionStorage`.\\n\\nThe module's central invariant, stated explicitly in the `VerifyPassClient` source: **the UI never synthesises a \\\"Verified\\\" badge from local checks passing.** Only the server's `GET /wallet/pass` response can assert validity, status, and the QR payload. The page used to build a card locally in both cases and hardcode `validity: \\\"current\\\"` with the label \\\"Verified\\\", which showed a verified-looking credential to people who had paid nothing (ADR-0025). The current split is the fix for that.\\n\\n## Module layout\\n\\n```\\napp/verify/pass/\\n  page.tsx                 Next.js route; Suspense wrapper (useSearchParams)\\n  VerifyPassClient.tsx     Journey terminal: bound vs demo, payment reconciliation,\\n                           wallet issuing, sharing, footer state machine\\n  pass.module.css          Journey chrome: status pills, result messages, skeleton\\n\\ncomponents/pass/\\n  index.ts                 Barrel export \u2014 the module's public surface\\n  DigitalPass.tsx          The credential card (light + dark tones, tilt, flip, QR)\\n  DigitalPass.module.css\\n  PassOverlay.tsx          In-app modal sheet presenting the credential\\n  PassOverlay.module.css\\n  PassPhotoPanel.tsx       Profile panel: add/replace/remove the pass photo\\n  PassPhotoPanel.module.css\\n  FaceCapture.tsx          Coached camera capture + file-upload fallback\\n  FaceCapture.module.css\\n  face-coach.ts            On-device face framing logic (FaceDetector), hold-lock\\n  use-pass.ts              Data hook: loads credential, wallet status, photo mutations\\n  pass-view.ts             PassView model, WalletPass mapping, QR encoding, photo pipeline\\n```\\n\\n## Architecture\\n\\n```mermaid\\ngraph TD\\n    VPC[\\\"VerifyPassClient(/verify/pass)\\\"] --&gt;|passViewFromWallet / samplePassView| PV[\\\"pass-view.tsPassView \u00b7 QR \u00b7 photo\\\"]\\n    VPC --&gt; DP[\\\"DigitalPass\\\"]\\n    PO[\\\"PassOverlay(profile sheet)\\\"] --&gt; UPH[\\\"usePassCredential\\\"]\\n    PO --&gt; DP\\n    UPH --&gt;|passViewFromWallet| PV\\n    PPP[\\\"PassPhotoPanel(profile panel)\\\"] --&gt; FC[\\\"FaceCapture\\\"]\\n    FC --&gt; FCO[\\\"face-coach.ts\\\"]\\n    FC --&gt;|squareJpegFrom / squareJpegFromFile| PV\\n```\\n\\n`pass-view.ts` is the seam between the API and the UI. `usePassCredential` and `VerifyPassClient` both produce `PassView` objects from a `WalletPass`; the demo journey additionally produces one from `VerifySession`. `DigitalPass` consumes `PassView` and nothing else, so the card itself has no idea which source it is rendering.\\n\\n## The `PassView` model\\n\\n`PassView` (`pass-view.ts`) is deliberately a view model, not the API type. The anonymous journey has no server pass and still needs the same card, so both sources normalise into this shape:\\n\\n```ts\\ntype PassView = {\\n  passId: string;\\n  holderName: string;\\n  entityName: string | null;   // shown only when subject is a company\\n  trade: string;\\n  region: string;\\n  abn: string;\\n  licenceNo: string;\\n  issuedAt: number;\\n  expiresAt: number;\\n  photoUrl: string | null;\\n  qrPayload: string;\\n  validity: \\\"current\\\" | \\\"invalid\\\";\\n  statusLabel: string;\\n  paid: boolean;               // the one blocker the holder can clear in one step\\n  passVer: number;\\n};\\n```\\n\\nProducers:\\n\\n- **`passViewFromWallet(pass: WalletPass)`** \u2014 maps the API type directly. `entityName` is `subjectName` only when `subjectKind === \\\"company\\\"`; for individuals it is `null` so the card does not duplicate the holder name.\\n- **`samplePassView(session: VerifySession)`** \u2014 builds the demo card. Always `validity: \\\"invalid\\\"` with `statusLabel: \\\"Sample\\\"` and `paid: false`. It cannot be issued to a wallet or shared, and the UI says so underneath the card.\\n\\nTwo helper functions complete the model:\\n\\n- `usePassQr(payload, tone)` \u2014 encodes the QR once per payload via the `qrcode` package. Kept in a hook so the card stays a pure render and the synchronous (~5 ms) encode never runs during layout. Colours are tone-specific: Electric Dark on white for the light face, Electric Blue modules on Carbon for the wallet face (literal hex kept in lockstep with `tokens.css`).\\n- `squareJpegFrom(source, mirror)` / `squareJpegFromFile(file)` \u2014 the photo pipeline described under [Photo management](#photo-management).\\n\\nDate helpers (`formatPassDate`, `expiringSoon`, `passIsExpired`) and `initialsOf` also live here.\\n\\n## `DigitalPass` \u2014 the credential card\\n\\n`DigitalPass` renders the holder identity, the fact grid (Pass ID, ABN, Licence, Expires), and the scan code. It is a client component.\\n\\n**Tones.** `tone=\\\"light\\\"` is the verify-journey face on canvas white; `tone=\\\"dark\\\"` is the Carbon wallet face used in `PassOverlay`. All colours are CSS custom properties swapped by `data-tone`, so the component body has no tone-specific branches.\\n\\n**Status and validity.** The component derives its own status label from the view model:\\n\\n- `validity === \\\"invalid\\\"` or expired \u2192 invalid state: the face is greyed (`grayscale(0.35)`), the scan button is disabled, the QR is replaced by a void plate, and the status text shows `pass.statusLabel`, or `\\\"Expired\\\"` if the pass was current and has lapsed.\\n- Otherwise \u2192 `\\\"Verified\\\"` with the seal glyph.\\n\\nThe expiry fact highlights in warning colour when inside the final 30 days (`expiringSoon`).\\n\\n**Tilt and flip.** The card has a subtle 3D tilt that follows the pointer (desktop) or touch, driven through `requestAnimationFrame` with a pose ref, capped at `MAX_TILT = 5`. It is disabled entirely under `prefers-reduced-motion`. The whole card flips 180\u00b0 to a back face showing the QR full-size; `data-flipped` drives the transform. The back face re-states whether the pass is valid and shows the pass ID in monospace.\\n\\n**Wallet actions.** `PassWalletAction[]` renders as a bar under the card: Share, Add to Apple Wallet, Add to Google Wallet. The bar lives **outside** the 3D-transformed card deliberately \u2014 WebKit clips descendants of a transformed face (overflow + `backface-visibility`), which previously hid the buttons under the QR on iPhone.\\n\\n**Photo editing.** `onEditPhoto` and `photoBusy` optionally turn the portrait slot into an editable control (\\\"Add photo\\\" / \\\"Replace\\\" / \\\"Saving\\\"). The verify journey and the in-app overlay do not use this; the profile panel manages photos directly. Without `onEditPhoto`, a missing photo renders initials.\\n\\n## `VerifyPassClient` \u2014 journey terminal\\n\\n`VerifyPassClient` is the last step of `/verify` and the most stateful component in the module.\\n\\n### Boot and the bound/anonymous split\\n\\nOn mount the component reads the demo session from `sessionStorage`:\\n\\n- **Session missing or incomplete** (no checks completed, no `issuedAt`): the journey progress is gone \u2014 new tab, cleared browser, different device. Instead of bouncing the person back to the start (including someone who just paid), the component marks itself **bound** and asks the server directly via `loadServerPass()`. If the server has no pass either, it redirects to `/verify`.\\n- **Session present**: it restores the session, sets `bound = isBoundVerifyMode(s.mode)` (invite and signed-in modes are bound; demo mode is not), and sets `issued` from `session.walletAdded`.\\n\\nBound journeys render the server credential from `GET /wallet/pass`. The anonymous demo renders `samplePassView` \u2014 validity, status wording, and QR are whatever the platform actually believes, no more.\\n\\n### Payment reconciliation\\n\\nThe redirect back from Checkout usually beats the webhook that settles the payment. So when the user arrives with `?from=checkout`, the component does **not** trust its own row \u2014 it polls:\\n\\n- Every `CONFIRMING_POLL_MS` (3 s), up to `CONFIRMING_ATTEMPTS` (10) attempts.\\n- Each tick calls `POST /payments/me/reconcile` (asking the provider directly) and then reloads the server pass.\\n- While polling, the UI shows the `settling` state: title \\\"Confirming payment\\\", a disabled footer button, and a note telling the user the page updates itself. If the poll budget is exhausted it stops and shows the normal paid/unpaid state.\\n\\nThis matters because the webhook arriving late is not supposed to be the difference between a paid pass and a page telling a debited card holder they still owe money.\\n\\n### Issuability and sharing\\n\\n```ts\\nconst issuable = bound &amp;&amp; paid &amp;&amp; view.validity === \\\"current\\\";\\n```\\n\\nEvery action that lets the credential leave the app \u2014 wallet issue, share \u2014 is gated on `issuable`. Sharing uses the Web Share API when available, falling back to clipboard copy with a transient \\\"Copied\\\" label. The share text includes pass ID, holder name, trade, region, and ABN via `formatAbn`.\\n\\nWallet issuing (`addToWallet`) posts `{ region }` to `POST /wallet/passes`, then redirects to `appleDownloadPath` or `googleSaveUrl`. On Android the Google button is ordered first. `walletStatus` (`GET /wallet/status`) decides whether Apple/Google buttons appear at all; if neither is configured, the card shows a \\\"show this pass at the gate\\\" note instead. A non-null `adding` state disables both wallet buttons and shows a spinner on the active one.\\n\\n### Four result states\\n\\nThe page title, footer, and explanatory note form a small state machine:\\n\\n| State | Condition | Title | Footer |\\n|---|---|---|---|\\n| `settling` | bound, not paid, polling | \\\"Confirming payment\\\" | disabled button |\\n| `awaitingEmployer` | bound, not paid, not polling, employer-sponsored | \\\"Waiting for your employer\\\" | \\\"Back to work profile\\\" |\\n| `awaitingPayment` | bound, not paid, not polling | \\\"Payment required\\\" | \\\"Pay to activate\\\" \u2192 `/verify/pay` |\\n| done | bound, paid (or any other bound state) | \\\"Verified pass\\\" | \\\"Finish your profile\\\" / \\\"Back to work profile\\\" |\\n\\n\\\"Payment required\\\" and \\\"still settling\\\" are deliberately different sentences \u2014 telling someone whose card was debited thirty seconds ago that they owe money is the worst moment in the journey to be wrong.\\n\\nThe final bound footer flushes pending staff invites (`flushPendingStaffInvites`) before clearing the session and routing to profile or trade-type onboarding. The anonymous footer just clears the session and returns to `/verify`; the demo sample is explicitly labelled \\\"not a credential\\\".\\n\\n## `PassOverlay` \u2014 in-app presentation\\n\\n`PassOverlay` is the read-only credential sheet from the profile. It renders `DigitalPass` in `tone=\\\"dark\\\"` inside a portal, with `usePresence` handling the enter/exit animation (`EXIT_MS = 200`).\\n\\nIt is deliberately read-only with respect to the photo: the face is managed on the work profile Pass photo section, not from the badge itself.\\n\\n**Modal behaviour.** Focus is trapped inside the sheet with a `Tab` key cycle, `Escape` closes, and focus is restored to the previously-active element on close. While open, the body scroll is locked and the scrollbar-gap compensation is applied.\\n\\n**Blockers.** Loading the credential can fail with one of several conditions (`usePassCredential` maps them):\\n\\n| Blocker | CTA |\\n|---|---|\\n| `unauthenticated` | \\\"Sign in\\\" \u2192 `/login` |\\n| `no-abn` | \\\"Add your ABN\\\" \u2192 `/onboarding/abn` |\\n| `payment-required`, `not-verified`, `not-trade`, `error` | message only |\\n\\n**Issue + share gating** is identical to the journey terminal: `canIssue = pass &amp;&amp; validity === \\\"current\\\" &amp;&amp; paid`. Unpaid passes get a \\\"Pay to activate\\\" button linking to `/billing`; invalid passes get an explanatory note; missing photos nudge the user to Work profile \u2192 Pass photo.\\n\\n## Photo management\\n\\nThe pass photo is stored once, on the person party (`PUT /parties/me/photo` / `DELETE /parties/me/photo`), and read by the badge, the wallet pass, and the profile avatar alike. There is deliberately no second face store to keep in sync.\\n\\n### `PassPhotoPanel`\\n\\nThe profile panel for the pass photo. It exposes an imperative `openCapture()` via `PassPhotoPanelHandle` (used by the profile hero avatar), and otherwise renders an inline preview with \\\"Add/Replace photo\\\", \\\"Remove\\\", and \u2014 for verified trades \u2014 \\\"Show pass\\\" which opens `PassOverlay`.\\n\\nThe panel distinguishes itself from the Didit ID check in `/verify`: the ID check proves identity once; this photo is the visual match a gate guard uses every time the badge is shown.\\n\\n### `FaceCapture`\\n\\nThe camera component with three phases plus an unavailable state:\\n\\n- `starting` \u2014 requesting `getUserMedia`, with a sequence of constraint fallbacks: ideal square 1280 \u2192 unconstrained front camera \u2192 any camera. `NotReadableError` (device held by another app, common on Windows after Strict Mode teardown) is retried with backoff; other errors fall through to a friendly message.\\n- `live` \u2014 video preview with an oval guide. When the browser `FaceDetector` API is available, a coaching loop runs every ~180 ms; when it is not, the UI falls back to soft instructional copy (\\\"come close, centre your face\\\") and never pretends a face was detected.\\n- `review` \u2014 the captured frame shown for confirmation before `onSave(dataUrl)`.\\n- `unavailable` \u2014 camera failed or permissions denied; offers \\\"Try camera\\\" and file upload.\\n\\n**Hold-to-capture.** The capture button only unlocks once a close, centred face has been held steady for `READY_HOLD_MS` (750 ms) \u2014 a Face ID-style hold, implemented by `createReadyHold`, which tracks *continuous* \\\"ready\\\" frames so a flicker cannot unlock capture. When the detector is ready and held, the component auto-captures. In `face-coach.ts`, `coachAllowsLockedCapture` is the gate: without a detector (`\\\"unsupported\\\"`) it returns `true` and capture is purely manual; otherwise it requires `\\\"ready\\\" &amp;&amp; hold.locked`.\\n\\n**Stream lifecycle.** The stream is stopped on capture, on unmount, and whenever the tab is backgrounded (`visibilitychange`) \u2014 the camera never stays open behind a hidden tab.\\n\\n### Image processing and privacy\\n\\nEvery photo \u2014 camera or camera roll \u2014 is centre-cropped to a square and re-encoded to a 512 px JPEG at quality 0.82 in a canvas before it touches the network (`squareJpegFrom`). Re-encoding through a canvas is what strips EXIF, so a camera-roll upload cannot carry GPS coordinates or a capture timestamp into the badge photo. Camera captures are mirrored at capture time to match the mirrored preview.\\n\\nFile uploads are validated in `squareJpegFromFile`:\\n\\n- format must be JPEG/PNG/WebP/HEIC/HEIF (regex) \u2014 `createImageBitmap` handles the decode;\\n- size cap `MAX_PHOTO_FILE_BYTES` (12 MB) rejects bombs before decode;\\n- `ImageBitmap` is closed in `finally`.\\n\\nFraming coaching for picked files runs the same `coachFromFaces` logic; a photo that is not a close, centred face is rejected with a targeted message.\\n\\n## `usePassCredential` \u2014 data hook\\n\\n`usePassCredential(enabled, region)` loads the server credential and manages its photo. The `enabled` flag means multiple surfaces can mount it without firing requests in the background, and `region` can be passed through to `GET /wallet/pass?region=\u2026` as a presentation hint; identity still comes from the server.\\n\\nIt returns:\\n\\n- `pass` (`PassView | null`), `loading`, and `blocker`/`blockerMessage` (the `PassBlocker` union above);\\n- `wallet` (`WalletStatus | null`) \u2014 Apple/Google configuration flags;\\n- `savePhoto(dataUrl)` / `removePhoto()` \u2014 `PUT`/`DELETE /parties/me/photo`, returning `boolean` success.\\n\\nPhoto mutations update the in-memory `pass` optimistically when the hook is still live (`liveRef` guard), so a background tab that completes a save cannot clobber state after navigation.\\n\\n## API surface\\n\\n| Endpoint | Method | Used by | Purpose |\\n|---|---|---|---|\\n| `/wallet/pass` | GET | `VerifyPassClient`, `usePassCredential` | The server's credential \u2014 the only source of validity/status/QR |\\n| `/wallet/status` | GET | both | `{ appleConfigured, googleConfigured }` \u2014 decides whether wallet buttons render |\\n| `/wallet/passes` | POST | both | Issue a wallet pass; body `{ region }`; returns `appleDownloadPath` / `googleSaveUrl` |\\n| `/payments/me/reconcile` | POST | `VerifyPassClient` | Ask the payment provider directly during checkout settling |\\n| `/parties/me/photo` | PUT / DELETE | `PassPhotoPanel`, `usePassCredential` | Read/write the single party-face photo used by badge, wallet pass, avatar |\\n\\n## Error handling\\n\\nError responses arrive as `ApiClientError` with machine-readable codes, mapped per-surface:\\n\\n- **Wallet issuing** (`friendlyWalletError`, `walletProblem`): `UNAUTHENTICATED` \u2192 sign-in message; `PAYMENT_REQUIRED` / `NOT_VERIFIED` / `ABN_REQUIRED` \u2192 the server's own message; `WALLET_NOT_CONFIGURED` / `*NOT_CONFIGURED` \u2192 \\\"ask your Assureworx admin\\\"; fallback \u2192 generic retry message.\\n- **Photo upload** (`photoProblem`): `PHOTO_TOO_LARGE` / `IMAGE_TOO_LARGE`, `INVALID_IMAGE` / `PHOTO_EMPTY`, `RATE_LIMITED`, otherwise the server message.\\n- **Credential load** (`blockerFor`): maps to the `PassBlocker` union so the UI can offer the right CTA (sign in, add ABN, pay) rather than a bare error string.\\n\\nThe distinction between \\\"cannot load\\\" and \\\"blocked\\\" matters: a payer without an ABN gets a different screen and a different next action than someone whose server call failed.\",\"wallet-digital-passes\":\"# Wallet &amp; Digital Passes\\n\\n# Wallet &amp; Digital Passes\\n\\n## Purpose\\n\\nThe Wallet &amp; Digital Passes module issues and presents the Assureworx gate credential \u2014 a digital pass that verified trade professionals carry in Apple Wallet or Google Wallet and present at site gates. The module spans two sub-modules that are two halves of one system:\\n\\n- [backend-src](wallet-backend-src.md) \u2014 signs, stores, and serves `.pkpass` files, produces Google \\\"Add to Wallet\\\" save URLs, and exposes a subset of the PassKit web service for device registration and pass refreshes. Mounted at `/api/v1/wallet`.\\n- [frontend-src](wallet-frontend-src.md) \u2014 the UI of the credential: the verification journey terminal at `/verify/pass`, the in-app pass overlay reachable from the profile, and the camera/upload flow that puts a face on the badge.\\n\\nThe module's defining design decision: **the QR barcode is a pointer, not a credential**. Its payload (`awx.verify.v1|passId|abn|region|passVer`) is a lookup key \u2014 a gate scanner never trusts the pass itself, but calls the backend to confirm the holder is currently Verified.\\n\\n## How the sub-modules fit together\\n\\nThe backend owns the truth; the frontend owns the card.\\n\\nWhen a verified holder completes the verification journey, the frontend's `VerifyPassClient` asks the backend (via `index.ts`) to issue a pass. The backend reserves a one-time token with `reservePassToken`, materialises the credential with `toWalletPass`, and signs platform-specific payloads via `buildApplePkpass` (Apple) and `buildGoogleSaveUrl` (Google). The pass buffer is staged in `pass-store` and delivered to the frontend, where the user taps **Add to Wallet** and the pass lives in their device wallet.\\n\\nLater, at a gate, the frontend's pass card renders in `PassOverlay` / `DigitalPass`, the scanner reads the QR, and the backend answers definitively: `resolveGatePass` (using `passIdFor`) reports whether the holder is currently verified.\\n\\n## Key workflows\\n\\n**Issuing and adding to wallet** \u2014 `VerifyPassClient` derives the credential from the server (`samplePassView` only when the journey is an anonymous demo, using `oneYearOn` for the sample expiry). A real pass triggers `reservePassToken` \u2192 `toWalletPass` \u2192 platform builder \u2192 staged buffer, then the frontend's `addToWallet` hands it to Apple/Google.\\n\\n**Gate verification** \u2014 The QR payload grounds the whole module. `resolveGatePass` looks up the pass ID parsed by `passIdFor` and returns current verification status, so a pass that was valid at issuance can still be rejected if the holder is no longer Verified.\\n\\n**In-app credential presentation** \u2014 `PassOverlay` pulls the server credential through `usePassCredential` \u2192 `passViewFromWallet` and renders it in `DigitalPass`, including photo, initials, and expiry. Users can `sharePass` the card from the same overlay.\\n\\n**Face capture** \u2014 `PassPhotoPanel` drives `FaceCapture`, which uses `createFaceDetector` and the `face-coach` guidance (`coachFromFaces`, `coachMessage`) to frame a good shot, processes it through `squareJpegFromFile`, and attaches it to the pass view as the badge photo.\\n\\n**Apple device sync** \u2014 `apple-webservice.ts` authenticates device registration via `appleAuth` \u2192 `parseAppleAuthToken` and serves pass refreshes; `buildApplePkpass` produces the signed bundle.\\n\\n**Storage lifecycle** \u2014 `pass-store` manages staged pass buffers: `putPassBuffer`/`getPassBuffer` with `consumePassBuffer`, plus `allowMemoryFallback` and `pruneMemory` so issue-time data is released once the frontend claims it.\\n\\n```mermaid\\nflowchart LR\\n    subgraph Frontend[frontend-src]\\n        VPC[VerifyPassClient]\\n        FC[FaceCapture + face-coach]\\n        PPP[PassPhotoPanel]\\n        PO[PassOverlay / DigitalPass]\\n    end\\n    subgraph Backend[backend-src]\\n        IDX[index.ts]\\n        PS[pass-store]\\n        AP[apple.ts / apple-webservice]\\n        GO[google.ts]\\n        GT[gate.ts]\\n    end\\n    W[(Apple / Google Wallet)]\\n    G[Gate scanner]\\n\\n    VPC --&gt;|reservePassToken / toWalletPass| IDX\\n    IDX --&gt; PS\\n    PS --&gt; AP --&gt; W\\n    PS --&gt; GO --&gt; W\\n    VPC --&gt;|addToWallet| W\\n    G --&gt;|QR payload| GT\\n    GT --&gt;|resolveGatePass / passIdFor| IDX\\n    FC --&gt; PPP --&gt; PO\\n    PO --&gt;|usePassCredential / passViewFromWallet| VPC\\n```\\n\\nThe module is symmetric: the backend issues and verifies, the frontend captures and presents, and the wallet ecosystem in between is treated as untrusted transport rather than proof.\",\"workforce-team-management-backend-src\":\"# Workforce &amp; Team Management \u2014 backend-src\\n\\n# Workforce &amp; Team Management \u2014 backend-src\\n\\n## Overview\\n\\nThe Workforce &amp; Team Management module owns the invite-to-membership lifecycle for the AssureWorx platform. It is implemented by the `memberships` router in `backend/src/modules/memberships/index.ts`, with site-placement logic factored out into the shared service `backend/src/shared/services/employee-placement.ts`.\\n\\nThe module is responsible for:\\n\\n- **Seat invitations** \u2014 creating, listing, and accepting invite rows (`core.t_seat`) for three invitation kinds:\\n  - `pc_setup` (`I01`) \u2014 Principal Contractor setup seats, created programmatically by the admin module.\\n  - `pc_seat` (`I02`) \u2014 Colleague seats created by a Principal Contractor admin.\\n  - `employee` (`I03`) \u2014 Employee invitations created by a trade company admin.\\n- **Acceptance processing** \u2014 converting a valid invite token into an actor, person party, organisation grant, and (for employees) an employment edge.\\n- **Employee onboarding configuration** \u2014 the verification-document checklist a trade company requires of its wage staff, plus the per-employee view of what *this* person must supply.\\n- **Site placement** \u2014 assigning an invited employee to an active project site the company is already engaged on, either at acceptance time or later via the parties module.\\n\\nThe `training` router in the same source bundle is a stub; it exposes only a `/health` endpoint and has no business logic yet.\\n\\n---\\n\\n## Module layout\\n\\n| File | Responsibility |\\n|---|---|\\n| `backend/src/modules/memberships/index.ts` | Express router: seat invite CRUD, acceptance, employee-checks configuration, exported helpers (`createSetupSeat`, `emailSeat`, `seatInviteUrl`) |\\n| `backend/src/shared/services/employee-placement.ts` | `listAssignableSites` and `placeEmployeeOnSite` \u2014 shared with the parties module |\\n| `backend/src/modules/training/index.ts` | Placeholder router (health check only) |\\n\\n---\\n\\n## Domain model\\n\\n### Seats (`core.t_seat`)\\n\\nA seat is an invitation row keyed by a random 32-byte hex token:\\n\\n| Field | Meaning |\\n|---|---|\\n| `kind_cd` | `I01` (pc_setup), `I02` (pc_seat), `I03` (employee) |\\n| `status_cd` | `U01` pending, `U03` accepted. Expiry is derived, not stored |\\n| `token_txt` | Opaque invite token; cleared to `NULL` on acceptance |\\n| `expires_at` | Always `NOW() + INTERVAL '14 days'` at creation |\\n| `role_cd` | `R02` (tier1_admin), `R03` (tier1_user), `R05` (employee) |\\n| `verification_keys` | Employee seats only \u2014 snapshot of the employer's verification template at invite time |\\n| `attrs` | JSONB; employee seats may carry `{ \\\"siteId\\\": \\\"...\\\" }` for deferred site placement |\\n| `created_by` | Actor id of the inviter |\\n\\nA seat's effective status is computed in `toSeatRow()`:\\n\\n- `U03` \u2192 `\\\"accepted\\\"`\\n- `expires_at` in the past \u2192 `\\\"expired\\\"`\\n- otherwise \u2192 `\\\"pending\\\"`\\n\\nThe invite URL is `${config.publicAppUrl}/auth/invite/${token}`. The token is only included in API responses for pending seats, and only when the caller explicitly requests it (`includeToken: true`).\\n\\n### Grants\\n\\nAcceptance inserts a row into `core.t_grant` connecting `actor_id` \u2192 `party_id` (the organisation) with the seat's `role_cd`. If the actor already exists and the grant is already present, acceptance is idempotent for that grant. Every grant creation is audited with verb `role.granted`.\\n\\n### Employment edges (`core.t_rel`)\\n\\nEmployee invitations create an employment relationship (`kind_cd = 'L01'`, `status_cd = 'RS1'`) from the person party to the employing organisation. The edge's `attrs` carry the verification-key snapshot the employee was asked for at invite time:\\n\\n```json\\n{ \\\"verificationKeys\\\": [\\\"id_check\\\", \\\"right_to_work\\\", \\\"...\\\"] }\\n```\\n\\nThis snapshot is copied from the seat onto the edge because the seat is consumed at acceptance, while the onboarding checklist must survive for the duration of employment. The `ensureEmployment` helper merges with `attrs || $2::jsonb` so other attributes (such as a `holder_erasure` marker) are preserved when an invitation is re-accepted.\\n\\n---\\n\\n## API reference\\n\\nAll routes below `membershipsRouter.use(requireAuth, resolveTenantContext)` require a valid session. `requireRole` then checks the resolved tenant context's role. The public token routes are additionally protected by `inviteLimiter`.\\n\\n| Method | Path | Auth / roles | Purpose |\\n|---|---|---|---|\\n| `GET` | `/health` | none | Liveness probe |\\n| `GET` | `/seats/token/:token` | public + `inviteLimiter` | Resolve an invite token to its seat context |\\n| `POST` | `/seats/token/:token/accept` | public + `inviteLimiter` | Accept an invitation, create/link actor, issue session |\\n| `GET` | `/me/employee-onboarding` | any signed-in user | Return what an employer-sponsored employee must complete |\\n| `GET` | `/seats` | `tier1_admin`, `trade_company_admin`, `platform_admin` | List seats for the active org (includes pending tokens) |\\n| `POST` | `/seats` | `tier1_admin` + `inviteLimiter` | Invite a PC colleague seat |\\n| `GET` | `/employee-checks` | `trade_company_admin`, `platform_admin` | Read the employee verification template |\\n| `PUT` | `/employee-checks` | `trade_company_admin`, `platform_admin` | Update the employee verification template |\\n| `POST` | `/employees` | `trade_company_admin` + `inviteLimiter` | Invite an employee (not a subcontractor) |\\n\\nRoutes are also gated by organisation kind inside the handlers \u2014 `POST /seats` requires `ctx.orgKind === \\\"principal_contractor\\\"`, and `POST /employees` requires `ctx.orgKind === \\\"trade_company\\\"` \u2014 so the role check alone is not sufficient.\\n\\n---\\n\\n## Core flows\\n\\n### Seat lifecycle\\n\\n```mermaid\\nstateDiagram-v2\\n    [*] --&gt; Pending[\\\"Pending:\\\"] seat created (I01 / I02 / I03)\\n    Pending --&gt; Accepted[\\\"Accepted:\\\"] POST /seats/token/:token/accept\\n    Pending --&gt; Expired[\\\"Expired:\\\"] past expires_at (14 days)\\n    Accepted --&gt; [*]\\n    Expired --&gt; [*]\\n```\\n\\n### Creating an invitation\\n\\nThree creation paths exist, all writing a `U01` seat with a fresh token:\\n\\n1. **PC colleague seat** (`POST /seats`) \u2014 validates the invitee does not already hold a grant in the org (`ALREADY_MEMBER`), writes an `I02` seat with role `R02`/`R03`.\\n2. **Employee invite** (`POST /employees`) \u2014 validates the optional `siteId` against `listAssignableSites` (`SITE_NOT_ASSIGNABLE`), snapshots the current `employee_onboarding` verification template onto `verification_keys`, and writes an `I03` seat with role `R05`.\\n3. **PC setup seat** (`createSetupSeat`, exported for the admin module) \u2014 writes an `I01` seat with role `R02` outside the HTTP layer.\\n\\nIn all cases the invite email is sent *after* the transaction commits, via `emailSeat`. The email is idempotent across BullMQ retries thanks to `emailInviteIdemKey(\\\"seat\\\", seatId, token)`, which combines the seat id and token into a stable deduplication key.\\n\\n### Accepting an invitation\\n\\n`POST /seats/token/:token/accept` is the most involved flow in the module. It runs entirely inside a single `withBypass` transaction:\\n\\n1. **Validate** \u2014 token (min 16 chars) and the zod `acceptSchema` body (`firstName`, `lastName`, optional `phone`, `suburb`, `postcode`, `region`).\\n2. **Lock the seat** \u2014 `SELECT ... FOR UPDATE OF s` joins the seat to its org party. The row lock prevents two concurrent accepts of the same token.\\n3. **State checks** \u2014 the seat must be `U01` (else `NOT_PENDING`) and unexpired (else `EXPIRED`).\\n4. **Normalise input** \u2014 email is lowercased; postcode strips whitespace; region is accepted only if present in `AU_REGIONS`. For employee invites, a suburb or a 4-digit postcode is mandatory (`LOCATION_REQUIRED`, `INVALID_POSTCODE`).\\n5. **Find or create the actor**:\\n   - **Existing actor** (by lowercased email): update the person party's locality/postcode/region via `COALESCE`, insert the grant if missing, audit `role.granted` with `via: \\\"seat_accept\\\"`.\\n   - **New actor**: insert a person party (`kind_cd = 'K03'`; class `M01` for employees, `M02` otherwise), a `partyIdent` of kind `D03` bound to the email, an actor whose subject comes from `actorSubjectForEmail(email)`, and the grant. Audited with `via: \\\"seat_accept_new\\\"`.\\n6. **Employment edge** \u2014 for employee invites, `ensureEmployment` creates or merges the `L01` relationship, carrying the seat's `verification_keys` snapshot.\\n7. **Deferred site placement** \u2014 if the seat's `attrs.siteId` is set, `placeEmployeeOnSite` runs in the same transaction. Any failure is swallowed: the invite still succeeds if the site became unassignable in the meantime.\\n8. **Consume the seat** \u2014 set `status_cd = 'U03'`, `accepted_at = NOW()`, clear `token_txt`, and audit `seat.accepted`.\\n9. **Response branch**:\\n   - **Entra enabled** \u2014 return `201` with `credentialSetupPayload(email, returnTo)`, where `returnTo` is `/verify/details` for employees and `/dashboard` otherwise. The client is expected to complete credential setup before it has a session.\\n   - **Native auth** \u2014 issue the session cookie via `issueSessionCookie`, build the `me` payload with `buildMe(actorId, activeOrgId)`, and return `201` with the full `me` object.\\n\\n### Employee onboarding configuration\\n\\nTrade company admins configure what their **wage staff** must verify via `GET`/`PUT /employee-checks`. This is a separate, narrower catalogue from the trade-invitation template: an ABN cannot be asked of an employee, so it is not offered and is rejected if sent anyway (the service throws `Unknown verification requirement: ...`, which the router maps to `400 UNKNOWN_VERIFICATION_REQUIREMENT`).\\n\\nEdits are forward-only. Every invitation already sent keeps the selection it was sent with, and every employee already working keeps the selection on their `L01` edge \u2014 both hold snapshots rather than pointers, per ADR-0030 \u00a74.\\n\\nThe signed-in employee's view is `GET /me/employee-onboarding`. It returns `404` unless `employerPaysForPerson` confirms the person is employer-sponsored; otherwise it reads the snapshot via `employmentRequirementKeys` (falling back to `DEFAULT_EMPLOYEE_KEYS`) and includes the employer's name.\\n\\n### Assigning an employee to a site\\n\\n`placeEmployeeOnSite` creates a principal-owned `core.t_link` placing an employee on a site the company is already actively working on. It validates, in order:\\n\\n1. **Employment exists** \u2014 an `L01` rel in state `RS1` (or carrying a `holder_erasure` attr). Withdrawal markers on the rel, the party, or a pending closure \u2192 `WITHDREW_INFORMATION`.\\n2. **Template placement exists** \u2014 the company (or any live employee) must already have an active link to that site \u2192 `SITE_NOT_ASSIGNABLE` otherwise.\\n3. **No duplicate** \u2014 an existing link with the same owner, node, and party \u2192 `ALREADY_ASSIGNED`.\\n4. **Party not suspended** \u2014 status `S04` \u2192 `PARTY_SUSPENDED`.\\n5. **Hard-block evidence** \u2014 required documents with a hard-block artefact class (`blockCd === \\\"B01\\\"`) that are missing, expired, or rejected \u2192 `HARD_BLOCK` naming the offending documents.\\n\\nThe new link is inserted with the owner set to the principal contractor, the readiness state derived from `READINESS_BY_STATUS[party.status_cd]` (default `J01`), and any engaged-for tags copied from the company's template placement. The insertion is audited (`employee.assigned`) and followed by `evaluateLinks`, which recalculates requirement satisfaction for the new link.\\n\\n`listAssignableSites` backs the `siteId` validation in `POST /employees` and is also used by the parties module. Its `MEMBERS_CTE` expands the org to include all live employees, so a site is assignable if the company *or any of its employees* already holds an active placement there.\\n\\n---\\n\\n## Design decisions\\n\\n- **Snapshot semantics (ADR-0030 \u00a74)** \u2014 Verification keys are copied twice: seat \u2192 `L01` edge at acceptance. Later template edits never change what an in-flight or in-progress onboarding was asked to supply.\\n- **Row locking** \u2014 `SELECT ... FOR UPDATE OF s` on the seat serialises accepts and prevents double-consumption of a token.\\n- **Send-after-commit emails** \u2014 Invite emails are queued only after the seat insert transaction commits, so a rollback cannot produce a dangling invitation email.\\n- **Email idempotency** \u2014 `emailInviteIdemKey(\\\"seat\\\", seatId, token)` guarantees one email per invite even if BullMQ retries the job.\\n- **Tenant scoping** \u2014 Public token routes use `withBypass` (no tenant context); authenticated routes use `resolveTenantContext` plus `withScope(ctx.scopeOrgId, ...)`. Handlers filter rows by `ctx.orgId`.\\n- **Failure isolation at acceptance** \u2014 Site placement is best-effort at accept time. The invite must not fail because a project ended between invite and acceptance.\\n\\n---\\n\\n## Integration points\\n\\nThe module is a consumer and producer across several shared services:\\n\\n- **`modules/admin`** \u2014 imports `createSetupSeat`, `emailSeat`, and `seatInviteUrl` to provision PC setup invitations programmatically.\\n- **`modules/auth`** \u2014 `buildMe` is called after native-auth acceptance to return the new session's user payload.\\n- **`modules/parties`** \u2014 reuses `listAssignableSites` and `placeEmployeeOnSite` for admin-driven employee assignments.\\n- **`shared/services/verification-template`** \u2014 `getVerificationTemplate` / `saveVerificationTemplate` for employee checks; `employmentRequirementKeys` and `DEFAULT_EMPLOYEE_KEYS` for the employee-facing view.\\n- **`shared/services/email`** \u2014 `inviteEmailBodies` and `queueEmail`, with idempotency keys from `shared/services/idempotency`.\\n- **`shared/services/audit`** \u2014 every grant, invite, acceptance, template update, and assignment is audited with a structured delta.\\n- **`shared/auth/*`** \u2014 `entraEnabled` / `actorSubjectForEmail` / `credentialSetupPayload` / `issueSessionCookie` implement the dual auth-mode response path.\",\"workforce-team-management-frontend-src\":\"# Workforce &amp; Team Management \u2014 frontend-src\\n\\n# Workforce &amp; Team Management \u2014 frontend-src\\n\\n## Overview\\n\\nThe Workforce &amp; Team Management module covers the trade-portal pages and shared domain components concerned with **who is working where, for whom, and whether they are verified**. It is split into four areas:\\n\\n| Area | Files | Audience |\\n|---|---|---|\\n| **Jobs** \u2014 a trade company's placements | `app/(app)/jobs/` , `components/domain/EngagementList.tsx` | Trade company viewing active/completed site placements |\\n| **Team** \u2014 salaried staff management | `app/(app)/team/` | Trade company admin managing employees, invites, verification payment |\\n| **Site workforce** \u2014 who is assigned to a site | `components/domain/SiteWorkforceManager.tsx` | Principal Contractor `tier1_admin` viewing/editing a site's assigned trades |\\n| **Readiness widgets** \u2014 compliance status presentation | `components/domain/WorkChecksBoard.tsx`, `WorkforceMetricRow.tsx` | Any profile/site view that needs a checks or readiness summary |\\n| **Staged invite flushing** | `lib/flush-pending-staff.ts` | Demo onboarding paths that defer invites until the real membership is created |\\n\\nThe module is consumed from three route groups (`(app)/jobs`, `(app)/team`, `(app)/training`) and shared by `ProfileClient`, `SiteDetailClient`, `ProjectDetailClient`, `VerifyPassClient`, and the onboarding `ReviewClient`.\\n\\n```mermaid\\nflowchart LR\\n    TeamPage --&gt;|\\\"loadAppPage \u00d73 (parallel)\\\"| TeamClient\\n    TeamClient --&gt;|\\\"GET team / assignable-sites / seats / payments/config\\\"| API[(useApi)]\\n    TeamClient --&gt;|\\\"POST checkout \u2192 redirect\\\"| Stripe[Provider-hosted checkout]\\n    TeamClient --&gt;|\\\"POST abandon-open on mount / pageshow\\\"| API\\n    TeamClient --&gt;|\\\"POST employees \u00b7 DELETE team/:id \u00b7 POST assign\\\"| API\\n    TeamClient --&gt; EmployeeChecksPanel\\n    EmployeeChecksPanel --&gt;|\\\"GET/PUT employee-checks\\\"| API\\n```\\n\\n## Server entry points\\n\\nAll three server pages live in the `(app)` route group and share the same pattern: gate on a portal role, prefetch initial data, then render a `\\\"use client\\\"` component with that data.\\n\\n| Page | Portal gate | Prefetched data |\\n|---|---|---|\\n| `jobs/page.tsx` (`TradeJobsPage`) | `requirePortal(\\\"trade\\\")` | `MyEngagements` from `GET /parties/me/engagements` |\\n| `team/page.tsx` (`TeamPage`) | `requirePortal(\\\"trade\\\")` | `TeamMember[]`, `AssignableSite[] | null`, `SeatInviteRow[]` \u2014 loaded in parallel via `Promise.all` |\\n| `training/page.tsx` (`TrainingPage`) | `requirePortal(\\\"member\\\")` | none \u2014 renders `ComingSoonPage` |\\n\\nThe team page uses both `loadAppPage` (required) and `loadAppPageOptional` (nullable, for sites and seats). A `null` sites payload means \\\"no active placements yet\\\" and is passed through to the client so the invite form can show the appropriate hint.\\n\\nThe client components hydrate their `useApi` state from these props, so first paint shows data without a fetch round-trip; subsequent refreshes go through the normal `useApi` cache/network path.\\n\\n## Jobs: a trade's placements\\n\\n`JobsClient` is a thin tabbed wrapper. It holds two pieces of state:\\n\\n- **Data** \u2014 `MyEngagements` from `useApi(\\\"/parties/me/engagements\\\")`, seeded with the server-fetched `initialData`.\\n- **Tab** \u2014 `\\\"active\\\" | \\\"completed\\\"`, defaulting to `\\\"active\\\"`.\\n\\nEach tab renders an `EngagementList` with a `variant` prop matching the tab, or an `EmptyState` with tab-specific copy. The tab buttons show live counts from `data.active.length` / `data.completed.length`. While `loading &amp;&amp; !data` (i.e. no server seed), four `SkeletonRows` are shown inside the panel.\\n\\n### EngagementList\\n\\n`EngagementList` renders a 4-column register (Job/site, Relationship, Status, Date) as a CSS grid row list. Behavior varies by variant:\\n\\n- **Active** \u2014 shows a readiness chip from `readinessChip(job.readiness)` and a `\\\"Since {startedOn}\\\"` date.\\n- **Completed** \u2014 always shows a green `\\\"Completed\\\"` chip and `\\\"Ended {endedOn}\\\"`.\\n\\nThe helper `place(job)` joins `locality` and `region`; null when neither exists. The relationship column shows the principal company, plus optional location and supplied-person facts. The leading glyph uses `initialsFromName(job.siteName)`.\\n\\n`EngagementList` is not exclusive to the jobs page \u2014 `ProfileClient` reuses it to show a trade's placements on their profile.\\n\\n## Team: salaried staff management\\n\\n`TeamClient` is the largest component in this module. It is a full CRUD surface for a trade company's salaried staff: registration invites, assignment to projects, de-registration, per-person verification status, and bulk payment for verification.\\n\\n### Data sources\\n\\nFour parallel `useApi` calls feed the page:\\n\\n| Endpoint | Purpose |\\n|---|---|\\n| `GET /parties/me/team` | The employee rows; refreshable via `refresh()` |\\n| `GET /parties/me/assignable-sites` | Projects this company is already placed on (used in invite and assign forms) |\\n| `GET /memberships/seats` | Invite seats; refreshed separately as `refreshSeats()` |\\n| `GET /payments/config` | `PaymentsConfigView` \u2014 controls whether the pay bar is enabled and what the unit price is |\\n\\n`assignable-sites` passes `undefined` options when the server returned `null`, so the hook fetches lazily rather than seeding a null.\\n\\n### Payable determination and bulk payment\\n\\nA team member is payable only if all of these hold (`payableFor`):\\n\\n- `withdrewInformation` is falsy\\n- `verificationFunded !== true`\\n- `verificationClaimed !== true`\\n\\nThe selection state is a `Set` of party IDs, but the values actually sent are **re-derived from the live rows**: `chosenIds = payableIds.filter(id =&gt; selected.has(id))`. This protects against the list changing under a stale selection \u2014 a member who gets paid for elsewhere or leaves the company is automatically dropped from the charge.\\n\\nThe pay bar appears only when `chosenIds.length &gt; 0`. It shows the per-person unit amount (with GST handling via `payConfig.taxMode`), a clear button, and the pay button. The button is disabled when `payConfig.configured !== true` or the selection exceeds `MAX_SEATS = 50` \u2014 a client-side mirror of the server's one-order cap, kept in sync deliberately (\\\"matching it here keeps the message honest\\\").\\n\\n`payForSelected()`:\\n\\n1. POSTs `{ personPartyIds: chosenIds, journey: \\\"team\\\" }` to `/payments/workforce/checkout`.\\n2. On success, full-page navigates to `start.redirectUrl` (Stripe-hosted). Card details never touch this app.\\n3. On failure, shows the error, **refreshes the team list** (the refusals name a person who is no longer payable), and stays on the page.\\n\\n### Abandoning stale checkouts\\n\\nBecause browser Back from Stripe does not hit the `cancel_url`, `TeamClient` proactively abandons open checkouts:\\n\\n- On mount, and on `pageshow` when `event.persisted` (bfcache restore), it POSTs `/payments/workforce/abandon-open`.\\n- If the URL contained `?from=cancelled`, it announces the cancellation toast (only when `cancelled &gt; 0`) and strips the query param via `history.replaceState`.\\n\\nThe effect runs once on mount and relies on `pageshow` for restores; the cleanup sets an `alive` flag so async completions don't touch unmounted state.\\n\\n### Employee registration and pending invites\\n\\n`registerEmployee()` POSTs to `/memberships/employees` with `{ email, name?, siteId? }`. The response is a `SeatInviteRow` whose `emailSent` flag decides the toast copy (\\\"emailed\\\" vs \\\"ready \u2014 copy the link\\\"). A \\\"Last invite link\\\" panel appears when the returned row has an `inviteUrl`.\\n\\nPending invites are computed as `seats.filter(s =&gt; s.kind === \\\"employee\\\" &amp;&amp; s.status === \\\"pending\\\")` and rendered in a separate panel with a copy-link action per row. The copy action uses `navigator.clipboard.writeText` and toasts on both success and failure.\\n\\nThe distinction between salaried staff and subcontractors is enforced in the UI copy and by the endpoint itself: employees go through `/memberships/employees`, subcontractors through the Requests flow (`/requests?new=1`), which is linked from the header actions and the empty state.\\n\\n### Assignment and de-registration\\n\\n- **`assignToProject()`** POSTs `{ siteId }` to `/parties/me/team/:partyId/assign`, announces `{ projectName } | { siteName }`, and refreshes. The assign panel shows a \\\"no active jobs\\\" note when `assignable.length === 0`.\\n- **`deregister()`** DELETEs `/parties/me/team/:partyId`. The response `{ name, sitesReleased }` drives the toast \u2014 mentioning release from N active sites when applicable.\\n\\nDe-registration uses an inline confirmation (`role=\\\"alertdialog\\\"`) rather than a modal. Focus management is handled with a `wasConfirming` ref: on open, the first button in the confirm row is focused via a ref callback; on cancel, focus is restored to the trigger. This matters for keyboard users because the confirm row replaces the action row in the DOM.\\n\\n### Employee checks template\\n\\n`EmployeeChecksPanel` is opened from the Team header (\\\"Employee checks\\\" button). It edits what a company requires of its **own salaried staff** \u2014 deliberately a narrower list than the trade-invitation template on Requests, since salaried staff have no ABN.\\n\\nBehavior notes:\\n\\n- Data comes from `GET /memberships/employee-checks` (`VerificationTemplate`), with local `keys` state initialized once `data` arrives.\\n- Checks are grouped into five categories via the `GROUPS` constant: Profile, Safety, Insurance, Licences, Qualifications. Items are filtered from `data.catalogue` by `category`.\\n- The `identity` key is in `LOCKED` and rendered as a disabled, always-on toggle with the description \\\"Always required | this is the verification.\\\" The code comment explains why: turning it off would leave nothing to verify.\\n- Saving PUTs `requirementKeys: [...new Set([...keys, ...LOCKED])]` \u2014 the union guarantees the locked key survives even if it somehow wasn't in local state.\\n- The panel's doc comment calls out the **forward-only** semantics (ADR-0030 \u00a74): invitations already sent and staff already onboarding keep the selection they were given. The same caveat appears in the UI hint below the save button.\\n\\n## Site-level workforce management\\n\\n`SiteWorkforceManager` is a domain component used by `SiteDetailClient` and `ProjectDetailClient`, not by the team/jobs routes. It answers \\\"who is assigned to this site and are they ready?\\\"\\n\\nKey mechanics:\\n\\n- **Write access** is `me.active?.role === \\\"tier1_admin\\\"`. Non-admins see the roster but no Add/Remove controls.\\n- **Data** comes from `GET /nodes/sites/:siteId`, optionally seeded with `initialData` for SSR.\\n- **Add trade** opens an inline form. The directory is fetched lazily \u2014 `GET /directory?pageSize=100` only when the form is first opened \u2014 and filtered to `!assignedIds.has(partyId) &amp;&amp; !withdrewInformation`.\\n- **Assign** POSTs `/nodes/sites/:siteId/assign` with `{ partyId }`. **Remove** DELETEs `/nodes/links/:linkId` after an inline confirm (\\\"Remove? Yes / Keep\\\").\\n- Both mutations call `notifyChanged()`, which awaits `refresh()` and the optional `onChanged` callback \u2014 so the parent site/project page can update its own derived metrics.\\n- Each row shows the trade's profile status (`verifiedChip`) and site readiness (`readinessChip`), with a link to `/trades/:partyId` (companies) or `/trades/individuals/:partyId` (individuals).\\n- The whole section is collapsible with an `aria-expanded` chevron; the assign form is dismissed when collapsed.\\n\\n## Readiness widgets\\n\\n### WorkChecksBoard\\n\\n`WorkChecksBoard` presents the five core checks (ABN, Identity, Licences, Insurance, Inductions) as a flat horizontal rail of tick nodes \u2014 explicitly *not* a card grid. It is driven by `RequirementChecklist[\\\"coreChecks\\\"]` plus an `EvidenceRow[]` list for expiry context.\\n\\nState derivation is local and pure:\\n\\n- `required` = checks where `state !== \\\"not_required\\\"`; `current` = count of `state === \\\"passed\\\"`; the header shows `current of required current`.\\n- `stepTone()` maps check state to one of `passed | pending | failed | warn | notRequired`. A `warn` is signalled by any related evidence row with `status === \\\"expiring_soon\\\"`.\\n- `stepMeta()` produces the sub-label: \\\"Needs attention\\\" for failed/pending, \\\"N days left\\\" / \\\"Expires today\\\" / \\\"Expiring\\\" for warnings (via `daysUntil`), `coreCheckPassedLabel` for passed identity/ABN, otherwise `Exp {date}` or \\\"Collected\\\".\\n- `matchesCheck()` maps class codes to check keys \u2014 ABN is `A003`, identity `A002`, insurance `A004/A005/A110`, inductions the `A1xx` induction set; licence is the remaining `A1xx` range.\\n\\nInteractive behavior:\\n\\n- Each node is a ``; it is expandable only when the parent supplies a `drawers` entry for that key. Expanding opens a `Modal` (size `lg`) containing the drawer content.\\n- A needs-attention node shows a secondary action button \u2014 `actionLabel()` produces \\\"Re-enter ABN\\\", \\\"Verify\\\", or \\\"Fix\\\" \u2014 which calls `onResolve(key)` when provided, falling back to toggling the drawer.\\n- A banner beneath the rail highlights the first failed (or pending) check with a \\\"Resolve now\\\" action.\\n- Connectors between nodes turn green when the preceding check passed.\\n- The rail is responsive: 5 columns \u2192 3 \u2192 single column, with connectors hidden at the breakpoints.\\n\\n`ProfileClient` is the main consumer, but the component is generic enough to embed anywhere a `RequirementChecklist` and evidence rows are available.\\n\\n### WorkforceMetricRow\\n\\n`WorkforceMetricRow` is the \\\"Team readiness\\\" footer for a company's Work profile \u2014 designed to live under the personal status hero, never above it.\\n\\n- `segmentsFor()` builds four `DonutSegment`s (verified/pending/action required/suspended) from a `WorkforceBreakdown`. When a trade type is selected, the counts come from that `TradeTypeCount` row; otherwise from the overall breakdown.\\n- The donut (`StatusDonut`, compact, no legend) is accompanied by an `aria-hidden` counts grid that reuses the tone colors (ready/pending/attention/blocked).\\n- The trades rail lists filter chips with `TradeTypeIcon`, name, and count. Clicking toggles `selectedCode`; \\\"Show all\\\" clears it. `aria-pressed` reflects the active state.\\n- A \\\"Manage team\\\" link points at `/team`, wiring the profile footer back to `TeamClient`.\\n\\n## Staged invite flushing\\n\\n`flushPendingStaffInvites()` exists to bridge the demo/onboarding flow and the real memberships API. The `ReviewClient` submit path and `VerifyPassClient` stage employee invites in the demo session (`session.pendingStaff`); this function flushes them to `POST /memberships/employees` once a real membership exists.\\n\\nIt is deliberately safe to call with no pending staff \u2014 it returns `{ sent: 0, failed: 0, errors: [] }` immediately. Otherwise it loops the staged rows, validates that the email contains `@`, POSTs each invite, and returns a `StaffFlushResult` with per-row error strings. The session's `pendingStaff` is cleared on completion via `updateSession({ pendingStaff: [] })`.\\n\\n## Shared patterns worth noting\\n\\n- **`useApi` with `initialData`** is the standard data-loading contract across this module. Server pages prefetch, client components hydrate; `refresh()` is the mutation-invalidation primitive.\\n- **Optimistic vs. server-confirmed state**: `TeamClient` keeps a local `Set` for selection but re-derives the payload from server rows before every payment \u2014 avoid \\\"trusting\\\" the local set for anything that hits the API.\\n- **Mutation error handling** consistently maps `ApiClientError.message` into toast or inline error strips, with a generic fallback string, and refreshes affected lists afterwards.\\n- **Access control is role-based at render time** (`tier1_admin` for site workforce write), while portal gating happens server-side in each `page.tsx`.\",\"workforce-team-management\":\"# Workforce &amp; Team Management\\n\\n# Workforce &amp; Team Management\\n\\n## Overview\\n\\nThe Workforce &amp; Team Management module is the membership layer of the Assureworx platform. It manages **who belongs to which organisation** and **what work they can be assigned to**, covering three distinct membership flows:\\n\\n- **Principal Contractor colleague seats** \u2014 PC admins invite other people into their organisation (`pc_seat`, plus `pc_setup` for initial admin provisioning).\\n- **Trade company employees** \u2014 trade company admins invite salaried staff onto their team (`employee`). These are people on wages, not subcontractors; subcontractor onboarding is handled separately via the Requests module.\\n- **Site workforce assignment** \u2014 placing companies and individual employees onto active project sites, with hard-block evidence enforcement.\\n\\nThe module also seeds the employee onboarding checklist, drives bulk payment for employee verification, and provides a read-only Jobs view of a trade company's active and completed engagements.\\n\\n---\\n\\n## Portals and access control\\n\\nThe module serves two portals with different capabilities:\\n\\n| Portal | Portal key | Capabilities |\\n|---|---|---|\\n| Trade company | `requirePortal(\\\"trade\\\")` | Register employees, assign them to sites, configure employee checks, pay for verification, view jobs |\\n| Principal Contractor | `requirePortal(\\\"member\\\")` (for training); site workforce managed from site/project pages | Assign trades to sites, remove assignments, view site readiness |\\n\\nAccess is enforced by route-level guards in the memberships router:\\n\\n- `requireRole(\\\"tier1_admin\\\")` \u2014 create PC colleague seats\\n- `requireRole(\\\"tier1_admin\\\", \\\"trade_company_admin\\\", \\\"platform_admin\\\")` \u2014 list seats\\n- `requireRole(\\\"trade_company_admin\\\", \\\"platform_admin\\\")` \u2014 read/update employee checks\\n- `requireRole(\\\"trade_company_admin\\\")` \u2014 invite employees\\n\\nThe public invite-acceptance endpoints (`GET /seats/token/:token`, `POST /seats/token/:token/accept`) are unauthenticated but protected by `inviteLimiter`.\\n\\n---\\n\\n## Seat invites: the membership primitive\\n\\nAll three membership flows share a single table (`core.t_seat`, referenced as `T.seat`). A seat row represents a pending invitation with a token, an expiry, and a target role.\\n\\n### Invite kinds\\n\\n| Kind | Code | Created by | Role granted | Purpose |\\n|---|---|---|---|---|\\n| `pc_setup` | `I01` | Admin provision via `createSetupSeat(tx, opts)` | `tier1_admin` (`R02`) | Initial Principal Contractor admin account |\\n| `pc_seat` | `I02` | `POST /seats` | `tier1_admin` (`R02`) or `tier1_user` (`R03`) | Colleague seat in a PC organisation |\\n| `employee` | `I03` | `POST /employees` | `R05` | Salaried employee of a trade company |\\n\\nThe mapping between codes and kinds lives in two constants: `KIND_CD` (kind \u2192 code) and `KIND_FROM` (code \u2192 kind). Rows are serialised to the API via `toSeatRow()`, which computes the effective status (`accepted`, `expired`, or `pending`) from `status_cd` and `expires_at`.\\n\\n### Lifecycle\\n\\n```mermaid\\nflowchart TD\\n    A[\\\"Admin creates invite(pc_setup / pc_seat / employee)\\\"] --&gt; B[(\\\"core.t_seat\\\")]\\n    B --&gt; C[\\\"Invitee opens token link\\\"]\\n    C --&gt; D[\\\"POST /seats/token/:token/accept\\\"]\\n    D --&gt; E{\\\"Existing actor?\\\"}\\n    E --&gt;|yes| F[\\\"Add grant if missing,update party location\\\"]\\n    E --&gt;|no| G[\\\"Create person party,identity, actor, grant\\\"]\\n    F --&gt; H[\\\"ensureEmployment(employee invites only)\\\"]\\n    G --&gt; H\\n    H --&gt; I[\\\"Deferred site placement(seat.attrs.siteId)\\\"]\\n    I --&gt; J[\\\"Session issued orcredential setup returned\\\"]\\n```\\n\\nKey invariants:\\n\\n- Tokens are 32-byte random hex (`randomBytes(32).toString(\\\"hex\\\")`).\\n- Invites expire 14 days after creation (`NOW() + INTERVAL '14 days'`).\\n- Acceptance is guarded by a `FOR UPDATE OF s` row lock inside a bypass transaction, so a seat can only be accepted once \u2014 `status_cd` moves from `U01` (pending) to `U03` (accepted) and the token is cleared (`token_txt = NULL`).\\n- `emailSeat()` queues email through BullMQ with an idempotency key derived from the seat ID and token, so retries never send duplicates.\\n\\n### Acceptance flow\\n\\n`POST /seats/token/:token/accept` performs the following, all inside a single `withBypass` transaction:\\n\\n1. **Validate the seat** \u2014 token must exist, not be deleted, be `U01`, and not be expired.\\n2. **Parse the profile** \u2014 `acceptSchema` captures first/last name, phone, suburb, postcode, region. For `employee` invites, a suburb or a valid 4-digit postcode is mandatory (`LOCATION_REQUIRED` / `INVALID_POSTCODE` errors).\\n3. **Find or create the actor**:\\n   - **Existing actor**: updates the person party's location fields via `COALESCE`, adds the organisation grant if the actor does not already have one, and records a `role.granted` audit event with `via: \\\"seat_accept\\\"`.\\n   - **New actor**: inserts a `K03` person party (class `M01` for employees, `M02` otherwise), a `D03` party identity (email), an actor with an Entra-compatible subject (`actorSubjectForEmail`), and the organisation grant.\\n4. **Create the employment edge** \u2014 for `employee` invites, `ensureEmployment(tx, personId, orgId, verificationKeys)` creates or updates a `T.rel` row with `kind_cd = 'L01'` (person \u2192 employer). The seat's snapshotted `verification_keys` are copied onto the relation's `attrs` (merging with any existing attrs).\\n5. **Deferred site placement** \u2014 if the seat's `attrs.siteId` is set, `placeEmployeeOnSite` is attempted. Failures are swallowed deliberately: the invite must succeed even if the site is no longer assignable.\\n6. **Finalise** \u2014 the seat is marked `U03` and audited as `seat.accepted`.\\n7. **Return session** \u2014 if Entra is enabled, a `credentialSetupPayload` is returned so the user can set a password; otherwise a session cookie is issued (`issueSessionCookie`) and `buildMe` returns the full session payload.\\n\\n---\\n\\n## Employee onboarding checks\\n\\nThe module separates two verification templates:\\n\\n- The **trade-invitation template** (used by Requests) \u2014 can ask subcontractors for an ABN.\\n- The **`employee_onboarding` template** \u2014 what a trade company asks of its *own salaried staff*. It deliberately excludes ABN (wage earners don't have one) and the server rejects it if sent anyway.\\n\\n### Serving the checklist\\n\\n`GET /me/employee-onboarding` tells the signed-in employee's client what they must complete. It uses `employerPaysForPerson()` to confirm the person is employer-sponsored (an `R04` trade-org admin funds their own cycle and is excluded), then returns:\\n\\n- `employerSponsored: true`\\n- `employerName` \u2014 the display name from the live `L01` relation with status `RS1`\\n- `verificationRequirementKeys` \u2014 the keys from the employer's relation attrs, falling back to `DEFAULT_EMPLOYEE_KEYS`\\n\\nThis seeds `/verify` so salaried staff never see ABN or Pay options.\\n\\n### Configuring checks\\n\\n- `GET /employee-checks` \u2014 reads the `employee_onboarding` template via `getVerificationTemplate()`.\\n- `PUT /employee-checks` \u2014 saves it via `saveVerificationTemplate()`. Unknown requirement keys surface as `400 UNKNOWN_VERIFICATION_REQUIREMENT`.\\n\\nThe frontend `EmployeeChecksPanel` renders the catalogue grouped by category (profile, safety, insurance, licences, qualifications). The `identity` key is locked \u2014 it cannot be toggled off, because it is the verification that the employer's payment actually buys. Saving always re-adds locked keys before sending.\\n\\n### Snapshot semantics (ADR-0030 \u00a74)\\n\\nBoth the invite and the employment edge **snapshot** the requirement keys at the moment they are created:\\n\\n- `POST /employees` copies the current `employee_onboarding` template keys onto the seat's `verification_keys` column.\\n- `ensureEmployment` copies those keys onto the `L01` relation's `attrs` at acceptance.\\n\\nThis is deliberate: edits to the employer's template are **forward-only**. An invitation already sent, and an employee already onboarding, keep the selection they were given \u2014 a later edit cannot change what someone part-way through was asked for. The `||` JSON merge in `ensureEmployment` preserves other attrs (e.g. an erasure marker) while updating the keys.\\n\\n---\\n\\n## Team management (trade company)\\n\\nThe Team page (`frontend/src/app/(app)/team/`) is the trade company admin's hub. The server component `page.tsx` loads three datasets in parallel and passes them to `TeamClient` as initial data:\\n\\n- `/parties/me/team` \u2014 current team members\\n- `/parties/me/assignable-sites` \u2014 optional; sites the company is already placed on\\n- `/memberships/seats` \u2014 pending invites (seats the company has sent)\\n\\n### Registering an employee\\n\\nThe \\\"Register new employee\\\" panel (`POST /memberships/employees`) takes an email, an optional name, and an optional site. The server:\\n\\n1. Verifies the active org is a trade company (`forbidden` otherwise).\\n2. If `siteId` is provided, validates it against `listAssignableSites()` \u2014 the site must be an active job the company is already placed on.\\n3. Snapshots the `employee_onboarding` template keys onto the seat.\\n4. Creates the `I03` seat with role `R05` and `attrs = { siteId }` when deferred placement is wanted.\\n5. Emails the invite via `emailSeat()`.\\n\\nOn success the client shows either a confirmation that the email was sent, or the invite link to copy manually (when `emailSent` is false). Pending invites appear in a separate panel with a \\\"Copy invite link\\\" action.\\n\\n### Assigning employees to projects\\n\\n\\\"Assign to project\\\" (`POST /parties/me/team/:partyId/assign`) calls `placeEmployeeOnSite()` with the selected site. The UI only offers sites from the assignable-sites endpoint, and the server re-validates.\\n\\n### Bulk payment\\n\\nThe Team table exposes checkboxes for every *payable* member \u2014 someone who has not withdrawn information, is not already `verificationFunded`, and has no `verificationClaimed` in flight (`payableFor()`). The selection is re-derived from live rows before checkout, so stale selections can't pay for someone who has since left or been funded.\\n\\nCheckout posts to `/payments/workforce/checkout` with the chosen person party IDs and `journey: \\\"team\\\"` (so Stripe's cancel URL lands back on Team). The client:\\n\\n- Caps one order at `MAX_SEATS = 50` and shows an over-cap warning.\\n- Reads the unit price and tax mode from `/payments/config`.\\n- Navigates to the provider-hosted page with `window.location.assign()` \u2014 card details never touch the application.\\n\\nOn mount and on `pageshow` (back/forward cache restore), `TeamClient` calls `POST /payments/workforce/abandon-open` to release any unfinished Stripe checkouts, because a browser Back from Stripe never hits the cancel URL. The `?from=cancelled` query param (with an optional toast) is handled and then stripped from the URL.\\n\\n### De-registration\\n\\n`DELETE /parties/me/team/:partyId` de-registers the person. The confirm dialog warns about active site releases, and the response includes the number of sites released for the toast.\\n\\n---\\n\\n## Employee placement service\\n\\n`backend/src/shared/services/employee-placement.ts` contains the two placement operations used by both the Team page and the Principal Contractor site pages.\\n\\n### `listAssignableSites(tx, orgId)`\\n\\nReturns sites where the trade company is already actively working. The `MEMBERS_CTE` expands the org to include all live `L01` employees, then finds `T.link` rows where:\\n\\n- the linked party is a member of the org,\\n- the node is a site with status `RS1`,\\n- the parent project is `RS1` and has not ended (`ends_on &gt;= CURRENT_DATE`),\\n- the link owner is a Principal Contractor.\\n\\n`DISTINCT ON (s.id)` with `ORDER BY l.created_at DESC` keeps the most recent placement per site.\\n\\n### `placeEmployeeOnSite(tx, opts)`\\n\\nCreates a placement link (owned by the Principal Contractor) for a specific employee, under bypass scope since a trade scope cannot write owner rows. The validation chain is:\\n\\n1. **Employment** \u2014 the person must have a live `L01` relation to the org (`notFound` otherwise).\\n2. **Withdrawal** \u2014 rejects `WITHDREW_INFORMATION` if the person or the relation carries a `holder_erasure` marker, or the party has a `closure_requested_at`.\\n3. **Assignability** \u2014 finds a template link (the company's own placement on the site); `SITE_NOT_ASSIGNABLE` if the site is no longer active for this company.\\n4. **Duplicate** \u2014 `ALREADY_ASSIGNED` if a link already exists for this person/site/owner.\\n5. **Suspension** \u2014 `PARTY_SUSPENDED` for status `S04`.\\n6. **Hard blocks** \u2014 loads the person's evidence and artifact classes; any required document whose class has `blockCd === \\\"B01\\\"` and is missing, expired, or rejected fails with `HARD_BLOCK` listing the document names.\\n\\nOn success it:\\n\\n- Inserts the link with a readiness state from `READINESS_BY_STATUS[party.status_cd]`,\\n- Copies engaged-for tags from the company's template placement via `INSERT ... SELECT` from `T.linkTag` with `ON CONFLICT DO NOTHING`,\\n- Audits `employee.assigned` with the readiness label from `LINK_STATE`,\\n- Calls `evaluateLinks(tx, [linkId], actorId)` to recalculate site readiness.\\n\\n---\\n\\n## Site workforce (Principal Contractor)\\n\\n`SiteWorkforceManager` is the PC-side counterpart, rendered inside site and project detail pages. It lists assignments for a site (`/nodes/sites/:siteId`) and lets a `tier1_admin`:\\n\\n- **Assign a trade** \u2014 `POST /nodes/sites/:siteId/assign` with a party ID from the directory (`/directory?pageSize=100`). Candidates exclude already-assigned parties and anyone who withdrew information.\\n- **Remove a trade** \u2014 `DELETE /nodes/links/:linkId` with an inline confirm.\\n\\nReadiness is recalculated against the site's requirements after either mutation, which is why both actions call `notifyChanged()` \u2192 `refresh()` + the parent's `onChanged()` callback.\\n\\n---\\n\\n## Jobs view\\n\\nThe Jobs page (trade portal only) renders `MyEngagements` from `/parties/me/engagements` in two tabs:\\n\\n- **Active jobs** \u2014 current placements; the empty state explains that a job appears when a PC places the company or one of its people on a site.\\n- **Completed jobs** \u2014 finished placements forming the company's work history.\\n\\n`JobsClient` uses `useApi` with the server-loaded initial data, and the tab switcher is purely client-side state.\\n\\n---\\n\\n## Training module\\n\\nThe training module is currently a stub:\\n\\n- Backend: `trainingRouter` exposes only `GET /health` returning `{ module: \\\"training\\\", status: \\\"wired\\\" }`.\\n- Frontend: the Training page renders `ComingSoonPage` describing \\\"Principal Contractor authored modules and completion tracking. Quiz builder is Phase 2.\\\"\\n\\nThere is no data model or business logic yet; the route registration and portal guard (`requirePortal(\\\"member\\\")`) are in place for when the feature lands.\\n\\n---\\n\\n## Cross-module connections\\n\\n```mermaid\\ngraph LR\\n    subgraph Trade[\\\"Trade company portal\\\"]\\n        TC[\\\"TeamClient\\\"]\\n        EC[\\\"EmployeeChecksPanel\\\"]\\n        JC[\\\"JobsClient\\\"]\\n    end\\n\\n    subgraph PC[\\\"Principal Contractor portal\\\"]\\n        SW[\\\"SiteWorkforceManager\\\"]\\n    end\\n\\n    subgraph API[\\\"Backend\\\"]\\n        M[\\\"membershipsRouter\\\"]\\n        P[\\\"placeEmployeeOnSite\\\"]\\n        L[\\\"listAssignableSites\\\"]\\n        OA[\\\"org-admin services\\\"]\\n    end\\n\\n    TC --&gt;|POST /memberships/employees| M\\n    TC --&gt;|POST /parties/me/team/:id/assign| P\\n    TC --&gt;|checkout / abandon-open| PAY[\\\"payments module\\\"]\\n    EC --&gt;|GET/PUT /employee-checks| M\\n    JC --&gt;|GET /parties/me/engagements| PART[\\\"parties module\\\"]\\n    SW --&gt;|assign / remove| PART\\n    M --&gt;|accept flow| P\\n    M --&gt;|employerPaysForPerson| OA\\n    P --&gt;|hard-block evidence| EV[\\\"evidence / requirements services\\\"]\\n```\\n\\n- **Payments module** consumes `employerPaysForPerson()` and `administeredTradeOrgId()` to decide who pays for a verification cycle, and exposes the workforce checkout endpoints the Team page calls.\\n- **Parties module** owns `/parties/me/team`, `/parties/me/engagements`, and `/parties/me/assignable-sites`, and reuses `listAssignableSites` / `placeEmployeeOnSite` for the PC site-assignment routes.\\n- **Admin module** imports `createSetupSeat`, `emailSeat`, and `seatInviteUrl` from the memberships router for initial PC provisioning.\\n- **Requirements/evidence services** back the hard-block validation in `placeEmployeeOnSite` and recalculate readiness after placements.\\n- **`flushPendingStaffInvites()`** (frontend lib) replays staged demo invites through the real `POST /memberships/employees` endpoint, used by verify-pass and review flows.\\n\\nThe `org-admin` services (`administeredTradeOrgId`, `employerPaysForPerson`) are also called from the verified-status computation and payments eligibility checks, so an employee's funded verification state stays consistent across Team, payments, and the profile's compliance status.\"};\nvar TREE = [{\"name\":\"Authentication &amp; Session Management\",\"slug\":\"authentication-session-management\",\"files\":[],\"children\":[{\"name\":\"Authentication &amp; Session Management \u2014 backend-src\",\"slug\":\"authentication-session-management-backend-src\",\"files\":[\"backend/src/modules/auth/index.ts\",\"backend/src/modules/auth/otp-email.ts\",\"backend/src/shared/auth/dev-auth-gate.ts\",\"backend/src/shared/auth/ensure-trade-profile.ts\",\"backend/src/shared/auth/entra.ts\",\"backend/src/shared/auth/graph-email.ts\",\"backend/src/shared/auth/link-actor.ts\",\"backend/src/shared/auth/native-auth.ts\",\"backend/src/shared/auth/otp-callback-auth.test.ts\",\"backend/src/shared/auth/otp-callback-auth.ts\",\"backend/src/shared/auth/session.ts\",\"backend/src/shared/middleware/require-auth.ts\",\"backend/src/shared/services/email-change.ts\"]},{\"name\":\"Authentication &amp; Session Management \u2014 frontend-src\",\"slug\":\"authentication-session-management-frontend-src\",\"files\":[\"frontend/src/app/auth/callback/AuthCallbackClient.tsx\",\"frontend/src/app/auth/callback/page.tsx\",\"frontend/src/app/auth/created/CreatedClient.tsx\",\"frontend/src/app/auth/created/page.tsx\",\"frontend/src/app/auth/credentials/CredentialsClient.tsx\",\"frontend/src/app/auth/credentials/page.tsx\",\"frontend/src/app/auth/email-change/[kind]/EmailChangeClient.tsx\",\"frontend/src/app/auth/email-change/[kind]/confirm.module.css\",\"frontend/src/app/auth/email-change/[kind]/page.tsx\",\"frontend/src/app/auth/invite/[token]/InviteClient.tsx\",\"frontend/src/app/auth/invite/[token]/invite.module.css\",\"frontend/src/app/auth/invite/[token]/page.tsx\",\"frontend/src/app/auth/login/LoginClient.tsx\",\"frontend/src/app/auth/login/login.module.css\",\"frontend/src/app/auth/login/page.tsx\",\"frontend/src/app/auth/register/RegisterClient.tsx\",\"frontend/src/app/auth/register/page.tsx\",\"frontend/src/app/auth/register/register.module.css\",\"frontend/src/app/auth/reset/ResetClient.tsx\",\"frontend/src/app/auth/reset/new-password/NewPasswordClient.tsx\",\"frontend/src/app/auth/reset/new-password/page.tsx\",\"frontend/src/app/auth/reset/page.tsx\",\"frontend/src/app/auth/verify-code/VerifyCodeClient.tsx\",\"frontend/src/app/auth/verify-code/page.tsx\",\"frontend/src/components/auth/AuthBrandShell.tsx\",\"frontend/src/components/auth/LoginReveal.module.css\",\"frontend/src/components/auth/LoginReveal.tsx\",\"frontend/src/lib/credential-setup.ts\",\"frontend/src/lib/entra.ts\",\"frontend/src/lib/login-reveal.ts\",\"frontend/src/lib/safe-return-to.ts\",\"frontend/src/lib/server-session.ts\",\"frontend/src/lib/session-core.ts\",\"frontend/src/lib/session.module.css\",\"frontend/src/lib/session.tsx\",\"frontend/src/middleware.ts\"]}]},{\"name\":\"Identity Verification &amp; Onboarding\",\"slug\":\"identity-verification-onboarding\",\"files\":[],\"children\":[{\"name\":\"Identity Verification &amp; Onboarding \u2014 backend-src\",\"slug\":\"identity-verification-onboarding-backend-src\",\"files\":[\"backend/src/modules/identity/consent-version.test.ts\",\"backend/src/modules/identity/consent.ts\",\"backend/src/modules/identity/didit-boundary.test.ts\",\"backend/src/modules/identity/didit.ts\",\"backend/src/modules/identity/index.ts\",\"backend/src/modules/identity/reconcile.ts\",\"backend/src/modules/identity/scheduler.ts\",\"backend/src/modules/identity/service.ts\",\"backend/src/modules/identity/types.ts\",\"backend/src/modules/onboarding/index.ts\",\"backend/src/modules/verification/index.ts\",\"backend/src/shared/services/identity-check.ts\",\"backend/src/shared/services/identity-consent.ts\",\"backend/src/shared/services/identity-currency.test.ts\",\"backend/src/shared/services/identity.ts\",\"backend/src/shared/services/image-bounds.ts\",\"backend/src/shared/services/verification-template.ts\",\"backend/src/shared/services/verified-status.ts\"]},{\"name\":\"Identity Verification &amp; Onboarding \u2014 frontend-src\",\"slug\":\"identity-verification-onboarding-frontend-src\",\"files\":[\"frontend/src/app/(app)/profile/ProfileClient.tsx\",\"frontend/src/app/(app)/profile/profile.module.css\",\"frontend/src/app/(app)/profile/page.tsx\",\"frontend/src/app/(app)/profile/documents/DocumentsClient.tsx\",\"frontend/src/app/(app)/profile/documents/documents.module.css\",\"frontend/src/app/(app)/profile/documents/page.tsx\",\"frontend/src/app/onboarding/abn/AbnClient.tsx\",\"frontend/src/app/onboarding/abn/abn.module.css\",\"frontend/src/app/onboarding/abn/page.tsx\",\"frontend/src/app/onboarding/documents/DocumentsClient.tsx\",\"frontend/src/app/onboarding/documents/documents.module.css\",\"frontend/src/app/onboarding/documents/page.tsx\",\"frontend/src/app/onboarding/identity/IdentityClient.tsx\",\"frontend/src/app/onboarding/identity/identity.module.css\",\"frontend/src/app/onboarding/identity/page.tsx\",\"frontend/src/app/onboarding/invite/[token]/OnboardingInviteClient.tsx\",\"frontend/src/app/onboarding/invite/[token]/invite.module.css\",\"frontend/src/app/onboarding/invite/[token]/page.tsx\",\"frontend/src/app/onboarding/layout.tsx\",\"frontend/src/app/onboarding/review/ReviewClient.tsx\",\"frontend/src/app/onboarding/review/review.module.css\",\"frontend/src/app/onboarding/review/page.tsx\",\"frontend/src/app/onboarding/trade-types/TradeTypesClient.tsx\",\"frontend/src/app/onboarding/trade-types/trade-types.module.css\",\"frontend/src/app/onboarding/trade-types/page.tsx\",\"frontend/src/app/verify/VerifyLandingClient.tsx\",\"frontend/src/app/verify/abr/VerifyAbrClient.tsx\",\"frontend/src/app/verify/abr/page.tsx\",\"frontend/src/app/verify/certificates/VerifyCertificatesClient.tsx\",\"frontend/src/app/verify/certificates/page.tsx\",\"frontend/src/app/verify/checks/VerifyChecksClient.tsx\",\"frontend/src/app/verify/checks/checks.module.css\",\"frontend/src/app/verify/checks/page.tsx\",\"frontend/src/app/verify/details/VerifyDetailsClient.tsx\",\"frontend/src/app/verify/details/details.module.css\",\"frontend/src/app/verify/details/page.tsx\",\"frontend/src/app/verify/i/[token]/VerifyInviteClient.tsx\",\"frontend/src/app/verify/i/[token]/invited.module.css\",\"frontend/src/app/verify/i/[token]/page.tsx\",\"frontend/src/app/verify/journey/VerifyJourneyClient.tsx\",\"frontend/src/app/verify/journey/journey.module.css\",\"frontend/src/app/verify/journey/page.tsx\",\"frontend/src/app/verify/layout.tsx\",\"frontend/src/app/verify/page.tsx\",\"frontend/src/app/verify/pay/VerifyPayClient.tsx\",\"frontend/src/app/verify/pay/pay.module.css\",\"frontend/src/app/verify/pay/page.tsx\",\"frontend/src/app/verify/photo/VerifyPhotoClient.tsx\",\"frontend/src/app/verify/photo/photo.module.css\",\"frontend/src/app/verify/photo/page.tsx\",\"frontend/src/app/verify/scan/VerifyScanClient.tsx\",\"frontend/src/app/verify/scan/scan.module.css\",\"frontend/src/app/verify/scan/page.tsx\",\"frontend/src/app/verify/staff/VerifyStaffClient.tsx\",\"frontend/src/app/verify/staff/staff.module.css\",\"frontend/src/app/verify/staff/page.tsx\",\"frontend/src/app/verify/trade/VerifyTradeClient.tsx\",\"frontend/src/app/verify/trade/page.tsx\",\"frontend/src/app/verify/verify.module.css\",\"frontend/src/components/domain/IdentityPanel.module.css\",\"frontend/src/components/domain/IdentityPanel.tsx\",\"frontend/src/components/domain/ProfileHeader.module.css\",\"frontend/src/components/domain/ProfileHeader.tsx\",\"frontend/src/components/domain/ProfileSheet.module.css\",\"frontend/src/components/domain/ProfileSheet.tsx\",\"frontend/src/components/domain/VerifiedSplit.module.css\",\"frontend/src/components/domain/VerifiedSplit.tsx\",\"frontend/src/components/onboarding/WizardShell.module.css\",\"frontend/src/components/onboarding/WizardShell.tsx\",\"frontend/src/components/verify/IdentityHandoff.module.css\",\"frontend/src/components/verify/IdentityHandoff.tsx\",\"frontend/src/components/verify/VerifyEvidenceStep.module.css\",\"frontend/src/components/verify/VerifyEvidenceStep.tsx\",\"frontend/src/components/verify/VerifyShell.module.css\",\"frontend/src/components/verify/VerifyShell.tsx\",\"frontend/src/lib/identity.ts\",\"frontend/src/lib/onboarding-errors.ts\",\"frontend/src/lib/use-onboarding-draft-owner.ts\",\"frontend/src/lib/verify-demo.ts\"]}]},{\"name\":\"Evidence, Checks &amp; Compliance\",\"slug\":\"evidence-checks-compliance\",\"files\":[],\"children\":[{\"name\":\"Evidence, Checks &amp; Compliance \u2014 backend-src\",\"slug\":\"evidence-checks-compliance-backend-src\",\"files\":[\"backend/src/modules/cases/index.ts\",\"backend/src/modules/checks/index.ts\",\"backend/src/shared/services/artifact-storage.ts\",\"backend/src/shared/services/checklist.ts\",\"backend/src/shared/services/evidence-action-reason.test.ts\",\"backend/src/shared/services/evidence.ts\",\"backend/src/shared/services/extraction/cloud-read.ts\",\"backend/src/shared/services/extraction/decide-status.test.ts\",\"backend/src/shared/services/extraction/decide-status.ts\",\"backend/src/shared/services/extraction/index.ts\",\"backend/src/shared/services/extraction/parse.ts\",\"backend/src/shared/services/extraction/preprocess.test.ts\",\"backend/src/shared/services/extraction/preprocess.ts\",\"backend/src/shared/services/extraction/present.ts\",\"backend/src/shared/services/extraction/preview-cache.ts\",\"backend/src/shared/services/extraction/rules.ts\",\"backend/src/shared/services/extraction/sources.ts\",\"backend/src/shared/services/extraction/types.ts\",\"backend/src/shared/services/extraction/validate.ts\",\"backend/src/shared/services/requirements.ts\"]},{\"name\":\"Evidence, Checks &amp; Compliance \u2014 frontend-src\",\"slug\":\"evidence-checks-compliance-frontend-src\",\"files\":[\"frontend/src/components/domain/CoreChecks.module.css\",\"frontend/src/components/domain/CoreChecks.tsx\",\"frontend/src/components/domain/DocumentRead.module.css\",\"frontend/src/components/domain/DocumentRead.tsx\",\"frontend/src/components/domain/DocumentScanOverlay.module.css\",\"frontend/src/components/domain/DocumentScanOverlay.tsx\",\"frontend/src/components/domain/EvidenceBoard.module.css\",\"frontend/src/components/domain/EvidenceBoard.tsx\",\"frontend/src/components/domain/EvidenceMeter.module.css\",\"frontend/src/components/domain/EvidenceMeter.tsx\",\"frontend/src/components/domain/EvidenceTable.module.css\",\"frontend/src/components/domain/EvidenceTable.tsx\",\"frontend/src/components/domain/EvidenceUploadForm.module.css\",\"frontend/src/components/domain/EvidenceUploadForm.tsx\",\"frontend/src/components/domain/StatusDonut.module.css\",\"frontend/src/components/domain/StatusDonut.tsx\",\"frontend/src/lib/check-labels.ts\",\"frontend/src/lib/evidence-groups.ts\",\"frontend/src/lib/use-extraction.ts\"]}]},{\"name\":\"Retention &amp; Data Governance\",\"slug\":\"retention-data-governance\",\"files\":[\"backend/src/modules/retention/closure.ts\",\"backend/src/modules/retention/erase.ts\",\"backend/src/modules/retention/retention.test.ts\",\"backend/src/modules/retention/scheduler.ts\",\"backend/src/modules/retention/sweep.ts\"]},{\"name\":\"Payments &amp; Billing\",\"slug\":\"payments-billing\",\"files\":[],\"children\":[{\"name\":\"Payments &amp; Billing \u2014 backend-src\",\"slug\":\"payments-billing-backend-src\",\"files\":[\"backend/src/modules/billing/index.ts\",\"backend/src/modules/payments/attempts.ts\",\"backend/src/modules/payments/boot.test.ts\",\"backend/src/modules/payments/bulk.dbtest.ts\",\"backend/src/modules/payments/cases.ts\",\"backend/src/modules/payments/checkout.ts\",\"backend/src/modules/payments/eligibility.ts\",\"backend/src/modules/payments/flow.dbtest.ts\",\"backend/src/modules/payments/inbox.ts\",\"backend/src/modules/payments/index.ts\",\"backend/src/modules/payments/ledger.ts\",\"backend/src/modules/payments/orders.ts\",\"backend/src/modules/payments/pricing.test.ts\",\"backend/src/modules/payments/pricing.ts\",\"backend/src/modules/payments/process-event.ts\",\"backend/src/modules/payments/provider/fake.ts\",\"backend/src/modules/payments/provider/index.ts\",\"backend/src/modules/payments/provider/provider.test.ts\",\"backend/src/modules/payments/provider/stripe.ts\",\"backend/src/modules/payments/provider/types.ts\",\"backend/src/modules/payments/reconcile.ts\",\"backend/src/modules/payments/refund.dbtest.ts\",\"backend/src/modules/payments/refunds.ts\",\"backend/src/modules/payments/reliability.test.ts\",\"backend/src/modules/payments/scheduler.ts\",\"backend/src/modules/payments/schema.dbtest.ts\",\"backend/src/modules/payments/state.test.ts\",\"backend/src/modules/payments/state.ts\",\"backend/src/modules/payments/thank-you-email.ts\",\"backend/src/modules/payments/views.ts\",\"backend/src/modules/payments/webhook.ts\",\"backend/src/shared/services/meter.ts\"]},{\"name\":\"Payments &amp; Billing \u2014 frontend-src\",\"slug\":\"payments-billing-frontend-src\",\"files\":[\"frontend/src/app/(app)/billing/BillingClient.tsx\",\"frontend/src/app/(app)/billing/billing.module.css\",\"frontend/src/app/(app)/billing/page.tsx\",\"frontend/src/app/(app)/payments/cancelled/page.tsx\",\"frontend/src/app/(app)/payments/return/page.tsx\",\"frontend/src/components/domain/FundingCallout.module.css\",\"frontend/src/components/domain/FundingCallout.tsx\"]}]},{\"name\":\"Wallet &amp; Digital Passes\",\"slug\":\"wallet-digital-passes\",\"files\":[],\"children\":[{\"name\":\"Wallet &amp; Digital Passes \u2014 backend-src\",\"slug\":\"wallet-digital-passes-backend-src\",\"files\":[\"backend/src/modules/wallet/apple-webservice.ts\",\"backend/src/modules/wallet/apple.ts\",\"backend/src/modules/wallet/config.ts\",\"backend/src/modules/wallet/gate.ts\",\"backend/src/modules/wallet/generate-assets.mjs\",\"backend/src/modules/wallet/generate-google-assets.mjs\",\"backend/src/modules/wallet/google.ts\",\"backend/src/modules/wallet/index.ts\",\"backend/src/modules/wallet/pass-id.ts\",\"backend/src/modules/wallet/pass-store.ts\",\"backend/src/modules/wallet/subject.ts\"]},{\"name\":\"Wallet &amp; Digital Passes \u2014 frontend-src\",\"slug\":\"wallet-digital-passes-frontend-src\",\"files\":[\"frontend/src/app/verify/pass/VerifyPassClient.tsx\",\"frontend/src/app/verify/pass/pass.module.css\",\"frontend/src/app/verify/pass/page.tsx\",\"frontend/src/components/pass/DigitalPass.module.css\",\"frontend/src/components/pass/DigitalPass.tsx\",\"frontend/src/components/pass/FaceCapture.module.css\",\"frontend/src/components/pass/FaceCapture.tsx\",\"frontend/src/components/pass/PassOverlay.module.css\",\"frontend/src/components/pass/PassOverlay.tsx\",\"frontend/src/components/pass/PassPhotoPanel.module.css\",\"frontend/src/components/pass/PassPhotoPanel.tsx\",\"frontend/src/components/pass/face-coach.ts\",\"frontend/src/components/pass/index.ts\",\"frontend/src/components/pass/pass-view.ts\",\"frontend/src/components/pass/use-pass.ts\"]}]},{\"name\":\"Projects, Sites &amp; Requests\",\"slug\":\"projects-sites-requests\",\"files\":[],\"children\":[{\"name\":\"Projects, Sites &amp; Requests \u2014 backend-src\",\"slug\":\"projects-sites-requests-backend-src\",\"files\":[\"backend/src/modules/nodes/index.ts\",\"backend/src/modules/requests/index.ts\",\"backend/src/shared/services/geocode.ts\"]},{\"name\":\"Projects, Sites &amp; Requests \u2014 frontend-public\",\"slug\":\"projects-sites-requests-frontend-public\",\"files\":[\"frontend/public/leaflet/leaflet.css\"]},{\"name\":\"Projects, Sites &amp; Requests \u2014 frontend-src\",\"slug\":\"projects-sites-requests-frontend-src\",\"files\":[\"frontend/src/app/(app)/projects/CreateProjectWizard.module.css\",\"frontend/src/app/(app)/projects/CreateProjectWizard.tsx\",\"frontend/src/app/(app)/projects/ProjectsClient.tsx\",\"frontend/src/app/(app)/projects/[id]/ProjectDetailClient.tsx\",\"frontend/src/app/(app)/projects/[id]/ProjectInviteComposer.tsx\",\"frontend/src/app/(app)/projects/[id]/page.tsx\",\"frontend/src/app/(app)/projects/[id]/project-detail.module.css\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/SiteDetailClient.tsx\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/page.tsx\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/site.module.css\",\"frontend/src/app/(app)/projects/page.tsx\",\"frontend/src/app/(app)/projects/projects.module.css\",\"frontend/src/app/(app)/requests/RequestsClient.tsx\",\"frontend/src/app/(app)/requests/page.tsx\",\"frontend/src/app/(app)/requests/requests.module.css\",\"frontend/src/components/domain/SiteMapBoard.module.css\",\"frontend/src/components/domain/SiteMapBoard.tsx\",\"frontend/src/components/domain/SiteMapBoardLazy.tsx\",\"frontend/src/lib/geo.ts\",\"frontend/src/lib/project-wizard-drafts.ts\"]}]},{\"name\":\"Workforce &amp; Team Management\",\"slug\":\"workforce-team-management\",\"files\":[],\"children\":[{\"name\":\"Workforce &amp; Team Management \u2014 backend-src\",\"slug\":\"workforce-team-management-backend-src\",\"files\":[\"backend/src/modules/memberships/index.ts\",\"backend/src/modules/training/index.ts\",\"backend/src/shared/services/employee-placement.ts\"]},{\"name\":\"Workforce &amp; Team Management \u2014 frontend-src\",\"slug\":\"workforce-team-management-frontend-src\",\"files\":[\"frontend/src/app/(app)/jobs/JobsClient.tsx\",\"frontend/src/app/(app)/jobs/jobs.module.css\",\"frontend/src/app/(app)/jobs/page.tsx\",\"frontend/src/app/(app)/team/EmployeeChecksPanel.tsx\",\"frontend/src/app/(app)/team/TeamClient.tsx\",\"frontend/src/app/(app)/team/page.tsx\",\"frontend/src/app/(app)/team/team.module.css\",\"frontend/src/app/(app)/training/page.tsx\",\"frontend/src/components/domain/EngagementList.module.css\",\"frontend/src/components/domain/EngagementList.tsx\",\"frontend/src/components/domain/SiteWorkforceManager.module.css\",\"frontend/src/components/domain/SiteWorkforceManager.tsx\",\"frontend/src/components/domain/WorkChecksBoard.module.css\",\"frontend/src/components/domain/WorkChecksBoard.tsx\",\"frontend/src/components/domain/WorkforceMetricRow.module.css\",\"frontend/src/components/domain/WorkforceMetricRow.tsx\",\"frontend/src/lib/flush-pending-staff.ts\"]}]},{\"name\":\"Trades &amp; Company Directory\",\"slug\":\"trades-company-directory\",\"files\":[],\"children\":[{\"name\":\"Trades &amp; Company Directory \u2014 backend-src\",\"slug\":\"trades-company-directory-backend-src\",\"files\":[\"backend/src/modules/directory/index.ts\",\"backend/src/modules/links/index.ts\",\"backend/src/modules/parties/index.ts\",\"backend/src/shared/services/abn-verifier.ts\",\"backend/src/shared/services/network.ts\",\"backend/src/shared/services/org-admin.ts\"]},{\"name\":\"Trades &amp; Company Directory \u2014 frontend-src\",\"slug\":\"trades-company-directory-frontend-src\",\"files\":[\"frontend/src/app/(app)/trades/TradesClient.tsx\",\"frontend/src/app/(app)/trades/[id]/CompanyProfileClient.tsx\",\"frontend/src/app/(app)/trades/[id]/page.tsx\",\"frontend/src/app/(app)/trades/individuals/[id]/IndividualProfileClient.tsx\",\"frontend/src/app/(app)/trades/individuals/[id]/page.tsx\",\"frontend/src/app/(app)/trades/page.tsx\",\"frontend/src/app/(app)/trades/trades.module.css\",\"frontend/src/components/domain/LineageSummary.module.css\",\"frontend/src/components/domain/LineageSummary.tsx\",\"frontend/src/components/domain/LineageTree.module.css\",\"frontend/src/components/domain/LineageTree.tsx\",\"frontend/src/components/domain/TradeTypeIcon.tsx\",\"frontend/src/components/domain/TradeTypeList.module.css\",\"frontend/src/components/domain/TradeTypeList.tsx\",\"frontend/src/lib/trade-pack.ts\"]}]},{\"name\":\"Notifications &amp; Communication\",\"slug\":\"notifications-communication\",\"files\":[\"backend/src/modules/email/webhook.ts\",\"backend/src/modules/notifications/index.ts\",\"backend/src/modules/sms/index.ts\",\"backend/src/modules/sms/scheduler.ts\",\"backend/src/modules/sms/webhook.ts\",\"backend/src/shared/services/email-brand.test.ts\",\"backend/src/shared/services/email-brand.ts\",\"backend/src/shared/services/email-delivery.ts\",\"backend/src/shared/services/email.ts\",\"backend/src/shared/services/invite-resend.test.ts\",\"backend/src/shared/services/invite-resend.ts\",\"backend/src/shared/services/sms/config.test.ts\",\"backend/src/shared/services/sms/index.ts\",\"backend/src/shared/services/sms/notify.ts\",\"backend/src/shared/services/sms/outbox.ts\",\"backend/src/shared/services/sms/phone.test.ts\",\"backend/src/shared/services/sms/phone.ts\",\"backend/src/shared/services/sms/provider.test.ts\",\"backend/src/shared/services/sms/provider.ts\",\"backend/src/shared/services/sms/queue.ts\",\"backend/src/shared/services/sms/reliability.test.ts\",\"backend/src/shared/services/sms/templates.test.ts\",\"backend/src/shared/services/sms/templates.ts\",\"frontend/src/lib/notification-href.ts\"]},{\"name\":\"Admin, Support &amp; Reporting\",\"slug\":\"admin-support-reporting\",\"files\":[],\"children\":[{\"name\":\"Admin, Support &amp; Reporting \u2014 backend-src\",\"slug\":\"admin-support-reporting-backend-src\",\"files\":[\"backend/src/modules/admin/index.ts\",\"backend/src/modules/assurance/scheduler.ts\",\"backend/src/modules/assurance/sweep.test.ts\",\"backend/src/modules/assurance/sweep.ts\",\"backend/src/modules/reporting/index.ts\",\"backend/src/modules/support/index.ts\",\"backend/src/modules/support/sla.ts\",\"backend/src/shared/services/audit-context.ts\",\"backend/src/shared/services/audit-query.test.ts\",\"backend/src/shared/services/audit-query.ts\",\"backend/src/shared/services/audit.ts\",\"backend/src/shared/services/schema-erd.ts\"]},{\"name\":\"Admin, Support &amp; Reporting \u2014 frontend-src\",\"slug\":\"admin-support-reporting-frontend-src\",\"files\":[\"frontend/src/app/(app)/admin/AdminClient.tsx\",\"frontend/src/app/(app)/admin/admin.module.css\",\"frontend/src/app/(app)/admin/page.tsx\",\"frontend/src/app/(app)/reports/page.tsx\",\"frontend/src/app/(app)/settings/SettingsClient.tsx\",\"frontend/src/app/(app)/settings/account/AccountClient.tsx\",\"frontend/src/app/(app)/settings/account/account.module.css\",\"frontend/src/app/(app)/settings/account/page.tsx\",\"frontend/src/app/(app)/settings/page.tsx\",\"frontend/src/app/(app)/settings/settings.module.css\",\"frontend/src/app/(app)/support/SupportClient.tsx\",\"frontend/src/app/(app)/support/page.tsx\",\"frontend/src/app/(app)/support/support.module.css\",\"frontend/src/components/admin/AzureLineageStudio.module.css\",\"frontend/src/components/admin/AzureLineageStudio.tsx\",\"frontend/src/components/admin/PaymentsConsole.tsx\",\"frontend/src/components/admin/SchemaErdStudio.module.css\",\"frontend/src/components/admin/SchemaErdStudio.tsx\",\"frontend/src/components/admin/azureLineageData.ts\"]}]},{\"name\":\"UI Components &amp; App Shell\",\"slug\":\"ui-components-app-shell\",\"files\":[\"frontend/src/app/(app)/dashboard/DashboardClient.tsx\",\"frontend/src/app/(app)/dashboard/dashboard.module.css\",\"frontend/src/app/(app)/dashboard/page.tsx\",\"frontend/src/app/(app)/error.tsx\",\"frontend/src/app/(app)/layout.tsx\",\"frontend/src/app/(app)/loading.tsx\",\"frontend/src/app/globals.css\",\"frontend/src/app/layout.tsx\",\"frontend/src/app/page.tsx\",\"frontend/src/components/shell/ActivityRail.module.css\",\"frontend/src/components/shell/ActivityRail.tsx\",\"frontend/src/components/shell/AppShell.module.css\",\"frontend/src/components/shell/AppShell.tsx\",\"frontend/src/components/shell/HistoryNav.tsx\",\"frontend/src/components/shell/NavIcon.tsx\",\"frontend/src/components/shell/Sidebar.module.css\",\"frontend/src/components/shell/Sidebar.tsx\",\"frontend/src/components/shell/TopBar.module.css\",\"frontend/src/components/shell/TopBar.tsx\",\"frontend/src/components/shell/topbar-title-slot.ts\",\"frontend/src/components/ui/Button.module.css\",\"frontend/src/components/ui/Button.tsx\",\"frontend/src/components/ui/ComingSoonPage.tsx\",\"frontend/src/components/ui/DraftStatus.module.css\",\"frontend/src/components/ui/DraftStatus.tsx\",\"frontend/src/components/ui/EmptyState.module.css\",\"frontend/src/components/ui/EmptyState.tsx\",\"frontend/src/components/ui/Field.module.css\",\"frontend/src/components/ui/Field.tsx\",\"frontend/src/components/ui/MetaIcon.tsx\",\"frontend/src/components/ui/Modal.module.css\",\"frontend/src/components/ui/Modal.tsx\",\"frontend/src/components/ui/PageHeader.tsx\",\"frontend/src/components/ui/Panel.module.css\",\"frontend/src/components/ui/Panel.tsx\",\"frontend/src/components/ui/PartyAvatar.module.css\",\"frontend/src/components/ui/PartyAvatar.tsx\",\"frontend/src/components/ui/PartyIdentity.module.css\",\"frontend/src/components/ui/PartyIdentity.tsx\",\"frontend/src/components/ui/Skeleton.module.css\",\"frontend/src/components/ui/Skeleton.tsx\",\"frontend/src/components/ui/StatusChip.module.css\",\"frontend/src/components/ui/StatusChip.tsx\",\"frontend/src/components/ui/Toast.module.css\",\"frontend/src/components/ui/Toast.tsx\",\"frontend/src/lib/copy.ts\",\"frontend/src/lib/format.ts\",\"frontend/src/lib/initials.ts\",\"frontend/src/lib/nav.ts\",\"frontend/src/lib/use-app-history.ts\",\"frontend/src/lib/use-before-unload.ts\",\"frontend/src/lib/use-media-query.ts\",\"frontend/src/lib/use-persistent-draft.ts\",\"frontend/src/lib/use-presence.ts\",\"frontend/src/styles/page.module.css\",\"frontend/src/styles/tokens.css\"]},{\"name\":\"API &amp; Backend Infrastructure\",\"slug\":\"api-backend-infrastructure\",\"files\":[],\"children\":[{\"name\":\"API &amp; Backend Infrastructure \u2014 backend-src\",\"slug\":\"api-backend-infrastructure-backend-src\",\"files\":[\"backend/src/app.ts\",\"backend/src/server.ts\",\"backend/src/seed.ts\",\"backend/src/shared/cache.ts\",\"backend/src/shared/codes.ts\",\"backend/src/shared/config.ts\",\"backend/src/shared/config-dev-auth.test.ts\",\"backend/src/shared/db/pool.ts\",\"backend/src/shared/http/envelope.ts\",\"backend/src/shared/http/errors.ts\",\"backend/src/shared/http/safe-return-to.ts\",\"backend/src/shared/jobs/handlers.ts\",\"backend/src/shared/jobs/queue.test.ts\",\"backend/src/shared/jobs/queue.ts\",\"backend/src/shared/middleware/csrf.ts\",\"backend/src/shared/middleware/rate-limit-policy.test.ts\",\"backend/src/shared/middleware/rate-limit.ts\",\"backend/src/shared/middleware/resolve-tenant-context.ts\",\"backend/src/shared/photo-url.ts\",\"backend/src/shared/redis.ts\",\"backend/src/shared/telemetry.ts\",\"backend/src/shared/services/idempotency.test.ts\",\"backend/src/shared/services/idempotency.ts\"]},{\"name\":\"API &amp; Backend Infrastructure \u2014 frontend-src\",\"slug\":\"api-backend-infrastructure-frontend-src\",\"files\":[\"frontend/src/lib/api.ts\",\"frontend/src/lib/load-app-page.ts\",\"frontend/src/lib/server-api.ts\",\"frontend/src/lib/use-api.ts\"]}]},{\"name\":\"Database Migrations &amp; Schema\",\"slug\":\"database-migrations-schema\",\"files\":[],\"children\":[{\"name\":\"Database Migrations &amp; Schema \u2014 src\",\"slug\":\"database-migrations-schema-src\",\"files\":[\"backend/src/shared/schema/tables.ts\"]},{\"name\":\"Database Migrations &amp; Schema \u2014 migrations\",\"slug\":\"database-migrations-schema-migrations\",\"files\":[\"db/migrations/001_core_orgs_and_people.sql\",\"db/migrations/002_enterprise_core.sql\",\"db/migrations/003_hardening.sql\",\"db/migrations/004_account_invites.sql\",\"db/migrations/005_support_requests.sql\",\"db/migrations/006_identity_portability.sql\",\"db/migrations/007_email_change.sql\",\"db/migrations/008_security_hardening.sql\",\"db/migrations/009_rls_identity_evidence.sql\",\"db/migrations/010_sync_link_readiness.sql\",\"db/migrations/011_identity_consent_and_meter.sql\",\"db/migrations/012_check_sequence.sql\",\"db/migrations/013_check_contact_email.sql\",\"db/migrations/014_rls_remaining_tables.sql\",\"db/migrations/015_trade_engagement_visibility.sql\",\"db/migrations/016_audit_enrichment.sql\",\"db/migrations/017_engagement_requirements.sql\",\"db/migrations/018_meta_pass_dl.sql\",\"db/migrations/019_meta_read_via_definer.sql\",\"db/migrations/020_perf_indexes.sql\",\"db/migrations/021_party_locality_and_seat_attrs.sql\",\"db/migrations/022_invitation_verification_templates.sql\",\"db/migrations/023_verification_cases.sql\",\"db/migrations/024_payment_orders.sql\",\"db/migrations/025_refunds_and_disputes.sql\",\"db/migrations/026_payment_gated_verification.sql\",\"db/migrations/027_pass_dl_hits_and_devices.sql\",\"db/migrations/028_rls_actor_email_meter_idem.sql\",\"db/migrations/029_check_diagnostics.sql\",\"db/migrations/030_retention_and_account_closure.sql\",\"db/migrations/031_rel_attrs_holder_erasure.sql\",\"db/migrations/032_sms_notifications.sql\",\"db/migrations/033_artifact_pc_ack.sql\",\"db/migrations/034_employee_check_templates.sql\",\"db/migrations/035_refund_retry_schedule.sql\",\"db/migrations/036_email_delivery_status.sql\",\"db/migrations/037_seat_token_nullable.sql\",\"db/migrations/038_abn_identity_not_upload_universal.sql\",\"db/migrations/039_identity_consent_currency_fn.sql\",\"db/migrations/040_identity_admin_party_ids_fn.sql\"]},{\"name\":\"Database Migrations &amp; Schema \u2014 shared-types\",\"slug\":\"database-migrations-schema-shared-types\",\"files\":[\"packages/shared-types/src/index.ts\"]}]},{\"name\":\"Infrastructure &amp; Deployment\",\"slug\":\"infrastructure-deployment\",\"files\":[\"backend/Dockerfile\",\"backend/eslint.config.mjs\",\"backend/package.json\",\"backend/tsconfig.json\",\"frontend/Dockerfile\",\"frontend/eslint.config.mjs\",\"frontend/gate-proxy.mjs\",\"frontend/next.config.ts\",\"frontend/package.json\",\"frontend/tsconfig.json\",\"package.json\",\"packages/shared-types/package.json\",\"packages/shared-types/tsconfig.json\"]},{\"name\":\"Development Tooling &amp; Scripts\",\"slug\":\"development-tooling-scripts\",\"files\":[],\"children\":[{\"name\":\"Development Tooling &amp; Scripts \u2014 backend-scripts\",\"slug\":\"development-tooling-scripts-backend-scripts\",\"files\":[\"backend/scripts/ensure-google-wallet-class.mts\",\"backend/scripts/mint-invite.mjs\",\"backend/scripts/probe-cloud-read.ts\",\"backend/scripts/probe-engagement-requirements.ts\",\"backend/scripts/probe-extraction.ts\",\"backend/scripts/probe-google-wallet.mts\",\"backend/scripts/probe-schema-erd.ts\",\"backend/scripts/probe-webhook.mjs\",\"backend/scripts/purge-party.mjs\",\"backend/scripts/verify-identity.ts\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 backend-src\",\"slug\":\"development-tooling-scripts-backend-src\",\"files\":[\"backend/src/modules/_module.template.md\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 frontend-public\",\"slug\":\"development-tooling-scripts-frontend-public\",\"files\":[\"frontend/public/strip_text.py\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 scripts\",\"slug\":\"development-tooling-scripts-scripts\",\"files\":[\"scripts/apply-004.mjs\",\"scripts/apply-ciam-branding.ps1\",\"scripts/bind-dev-domains.ps1\",\"scripts/bind-test-domains.ps1\",\"scripts/bootstrap-platform-admin.mjs\",\"scripts/build-isms-starter-docs.py\",\"scripts/cancel-shutdown.bat\",\"scripts/deploy-dev.bat\",\"scripts/deploy-dev.ps1\",\"scripts/deploy-test.ps1\",\"scripts/enable-ciam-mfa-dev.ps1\",\"scripts/enable-ciam-native-auth.ps1\",\"scripts/enable-ciam-otp-email.ps1\",\"scripts/entra-app-reg-test.ps1\",\"scripts/entra-app-reg.ps1\",\"scripts/force-shutdown-10min.bat\",\"scripts/free-ports.ps1\",\"scripts/gen-data-catalogue.mjs\",\"scripts/isms-progress.mjs\",\"scripts/isms-truth-check.mjs\",\"scripts/migrate.mjs\",\"scripts/package-for-laptop.bat\",\"scripts/pg-ssl.mjs\",\"scripts/provision-pc.mjs\",\"scripts/provision-redis-dev.ps1\",\"scripts/reset-test-identity.bat\",\"scripts/reset-test-identity.mjs\",\"scripts/reset-test-identity.ps1\",\"scripts/run-mobile-demo.bat\",\"scripts/secure-local-infra.ps1\",\"scripts/slice-tradie-icons.py\",\"scripts/start-dev.bat\",\"scripts/switch-acs-sender-info.ps1\",\"scripts/wipe-test-app-data.mjs\",\"scripts/wipe-tradie.bat\",\"scripts/wire-acs-managed-identity.ps1\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 scripts-backup\",\"slug\":\"development-tooling-scripts-scripts-backup\",\"files\":[\"scripts/backup/backup-to-d-drive.bat\",\"scripts/backup/backup-to-e-drive.bat\",\"scripts/backup/backup-to-folder.bat\",\"scripts/backup/sync-docs-to-e-drive.bat\",\"scripts/backup/sync-frontend-backend-to-wiki-raw.bat\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 scripts-db-lifecycle\",\"slug\":\"development-tooling-scripts-scripts-db-lifecycle\",\"files\":[\"scripts/db-lifecycle/README.md\",\"scripts/db-lifecycle/_common.ps1\",\"scripts/db-lifecycle/backup-dev.bat\",\"scripts/db-lifecycle/backup-dev.ps1\",\"scripts/db-lifecycle/backup-local.bat\",\"scripts/db-lifecycle/backup-local.ps1\",\"scripts/db-lifecycle/ensure-founder.mjs\",\"scripts/db-lifecycle/inventory.mjs\",\"scripts/db-lifecycle/restore-to-local.bat\",\"scripts/db-lifecycle/restore-to-local.ps1\",\"scripts/db-lifecycle/wipe-local.bat\",\"scripts/db-lifecycle/wipe-local.ps1\"]}]},{\"name\":\"Performance &amp; Reliability Testing\",\"slug\":\"performance-reliability-testing\",\"files\":[],\"children\":[{\"name\":\"Performance &amp; Reliability Testing \u2014 helpers\",\"slug\":\"performance-reliability-testing-helpers\",\"files\":[\"e2e/helpers/flows.ts\",\"e2e/helpers/instrument.ts\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 e2e\",\"slug\":\"performance-reliability-testing-e2e\",\"files\":[\"e2e/package.json\",\"e2e/playwright.config.ts\",\"e2e/tsconfig.json\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 tests\",\"slug\":\"performance-reliability-testing-tests\",\"files\":[\"e2e/tests/deep-diagnostics.spec.ts\",\"e2e/tests/stability.spec.ts\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 src\",\"slug\":\"performance-reliability-testing-src\",\"files\":[\"frontend/src/components/dev/PerfProfiler.tsx\",\"frontend/src/components/dev/PerfRecorder.module.css\",\"frontend/src/components/dev/PerfRecorder.tsx\",\"frontend/src/components/dev/PerfRecorderHost.tsx\",\"frontend/src/components/dev/PerfSection.tsx\",\"frontend/src/lib/perf/findings.ts\",\"frontend/src/lib/perf/index.ts\",\"frontend/src/lib/perf/localhost-stress.ts\",\"frontend/src/lib/perf/post-fix-scenario.ts\",\"frontend/src/lib/perf/prod-scenarios.ts\",\"frontend/src/lib/perf/selftest.ts\",\"frontend/src/lib/perf/store.ts\",\"frontend/src/lib/perf/types.ts\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 loadtests\",\"slug\":\"performance-reliability-testing-loadtests\",\"files\":[\"loadtests/README.md\",\"loadtests/RESULTS.md\",\"loadtests/assurance-fix-evidence-20260806-191548.txt\",\"loadtests/azure-aca-metrics.json\",\"loadtests/azure-health-burst.txt\",\"loadtests/azure-health-probe.txt\",\"loadtests/azure-pg-metrics.json\",\"loadtests/baseline-stdout.txt\",\"loadtests/load-stdout.txt\",\"loadtests/pentest-curl-proofs.mjs\",\"loadtests/pentest-curl-proofs.txt\",\"loadtests/pentest-deep-probes.mjs\",\"loadtests/pentest-evidence.json\",\"loadtests/pentest-jwt-forge-proof.txt\",\"loadtests/pentest-jwt-pg-chain-proof.json\",\"loadtests/pentest-jwt-pg-chain.mjs\",\"loadtests/pentest-live-probes.mjs\",\"loadtests/pentest-redis-sqlmap.mjs\",\"loadtests/pentest-route-extract.mjs\",\"loadtests/pentest-routes.json\",\"loadtests/results-baseline-1785793598363.json\",\"loadtests/results-baseline-1785794235448.json\",\"loadtests/results-load-1785793776827.json\",\"loadtests/results-load-1785793998515.json\",\"loadtests/results-load-1785794174371.json\",\"loadtests/results-load-1785794406385.json\",\"loadtests/results-load-1785794745183.json\",\"loadtests/results-stress-1785795073564.json\",\"loadtests/results-stress-1785795296871.json\",\"loadtests/sqlmap-abn.txt\",\"loadtests/stress-window-end.txt\",\"loadtests/stress-window-start.txt\",\"loadtests/test-window-start.txt\",\"loadtests/user-journey.js\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 zap\",\"slug\":\"performance-reliability-testing-zap\",\"files\":[\"loadtests/zap/zap-baseline-stdout.txt\",\"loadtests/zap/zap-baseline.html\",\"loadtests/zap/zap-baseline.json\",\"loadtests/zap/zap.yaml\"]}]},{\"name\":\"Product &amp; Architecture Documentation\",\"slug\":\"product-architecture-documentation\",\"files\":[],\"children\":[{\"name\":\"Product &amp; Architecture Documentation \u2014 AGENTS.md\",\"slug\":\"product-architecture-documentation-agents-md\",\"files\":[\"AGENTS.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 CLAUDE.md\",\"slug\":\"product-architecture-documentation-claude-md\",\"files\":[\"CLAUDE.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 DESIGN.md\",\"slug\":\"product-architecture-documentation-design-md\",\"files\":[\"DESIGN.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 PRODUCT.md\",\"slug\":\"product-architecture-documentation-product-md\",\"files\":[\"PRODUCT.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 README.md\",\"slug\":\"product-architecture-documentation-readme-md\",\"files\":[\"README.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 Raw\",\"slug\":\"product-architecture-documentation-raw\",\"files\":[\"Raw/ASSUREWORX_BRAND.md\",\"Raw/ASSUREWORX_PRODUCT.md\",\"Raw/assureworx-platform-spec.html\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 backend\",\"slug\":\"product-architecture-documentation-backend\",\"files\":[\"backend/README.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 frontend\",\"slug\":\"product-architecture-documentation-frontend\",\"files\":[\"frontend/README.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 architecture\",\"slug\":\"product-architecture-documentation-architecture\",\"files\":[\"wiki/architecture/azure-dev-inventory.md\",\"wiki/architecture/azure-test-inventory.md\",\"wiki/architecture/backend.md\",\"wiki/architecture/cicd-target-operating-model.md\",\"wiki/architecture/code-map.md\",\"wiki/architecture/data-classification-catalogue.md\",\"wiki/architecture/data-model.md\",\"wiki/architecture/document-extraction.md\",\"wiki/architecture/frontend.md\",\"wiki/architecture/full-codebase-audit-2026-07-28.md\",\"wiki/architecture/identity-verification.md\",\"wiki/architecture/implementation-review-2026-07-21.md\",\"wiki/architecture/infrastructure.md\",\"wiki/architecture/integrations.md\",\"wiki/architecture/page-inventory-2026-07-25.md\",\"wiki/architecture/payments-design-2026-08-01.md\",\"wiki/architecture/plan-review-2026-07-20.md\",\"wiki/architecture/platform-audit-2026-07-25.md\",\"wiki/architecture/prod-rendering-report-2026-08-01.md\",\"wiki/architecture/production-readiness-2026-07-21.md\",\"wiki/architecture/production-readiness-qa-audit-2026-08-01.md\",\"wiki/architecture/production-readiness-report-2026-08-01.md\",\"wiki/architecture/qa-audit-plan-2026-08-01.md\",\"wiki/architecture/realtime-verify.md\",\"wiki/architecture/rsc-performance-plan-2026-07-31.md\",\"wiki/architecture/schema-mapping.md\",\"wiki/architecture/security-audit-2026-07-25.md\",\"wiki/architecture/security-engagement-2026-08-04.md\",\"wiki/architecture/security.md\",\"wiki/architecture/stack.md\",\"wiki/architecture/ui-collection-antipattern-audit-2026-07-30.md\",\"wiki/architecture/ux-ui-audit-2026-07-24.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 wiki\",\"slug\":\"product-architecture-documentation-wiki\",\"files\":[\"wiki/brand.md\",\"wiki/glossary.md\",\"wiki/index.md\",\"wiki/log.md\",\"wiki/overview.md\",\"wiki/ways-of-working.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 decisions\",\"slug\":\"product-architecture-documentation-decisions\",\"files\":[\"wiki/decisions/0001-full-azure-stack.md\",\"wiki/decisions/0002-postgresql-flexible-server.md\",\"wiki/decisions/0003-repository-layer-tenant-isolation.md\",\"wiki/decisions/0004-pricing-model.md\",\"wiki/decisions/0005-verified-scope-hybrid.md\",\"wiki/decisions/0006-email-azure-communication-services.md\",\"wiki/decisions/0007-obfuscated-physical-schema.md\",\"wiki/decisions/0008-bff-proxy-session-auth.md\",\"wiki/decisions/0009-realtime-verification-orchestration.md\",\"wiki/decisions/0010-pc-cold-start-admin-provisioned.md\",\"wiki/decisions/0011-in-app-support-desk.md\",\"wiki/decisions/0012-assureworx-id-and-identity-resolution.md\",\"wiki/decisions/0013-login-email-change.md\",\"wiki/decisions/0014-didit-identity-verification.md\",\"wiki/decisions/0015-identity-reverification-triggers.md\",\"wiki/decisions/0016-derived-job-state.md\",\"wiki/decisions/0017-employee-deregistration.md\",\"wiki/decisions/0018-engagement-scoped-requirements.md\",\"wiki/decisions/0019-document-extraction-over-registry-verification.md\",\"wiki/decisions/0020-azure-document-intelligence-tier-3.md\",\"wiki/decisions/0021-sms-deferred-entra-acs-email.md\",\"wiki/decisions/0022-invitation-verification-template-snapshots.md\",\"wiki/decisions/0023-pay-per-verification.md\",\"wiki/decisions/0024-payment-provider-boundary.md\",\"wiki/decisions/0025-native-auth-bff-proxy.md\",\"wiki/decisions/0026-identity-check-diagnostics.md\",\"wiki/decisions/0027-data-retention-and-public-legal.md\",\"wiki/decisions/0028-transactional-sms-twilio.md\",\"wiki/decisions/0029-employer-sponsored-bulk-verification.md\",\"wiki/decisions/0030-employer-employee-check-selection.md\",\"wiki/decisions/0031-refund-reconciliation-schedule.md\",\"wiki/decisions/0032-di-auto-accept-and-pc-document-visibility.md\",\"wiki/decisions/index.md\",\"wiki/decisions/open-questions.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 product\",\"slug\":\"product-architecture-documentation-product\",\"files\":[\"wiki/product/account-and-invite-journeys.md\",\"wiki/product/billing.md\",\"wiki/product/compliance-and-verification.md\",\"wiki/product/employee-verification-journey.md\",\"wiki/product/employer-pays-for-crew.md\",\"wiki/product/engagement-requirements.md\",\"wiki/product/entity-hierarchy.md\",\"wiki/product/identity-and-portability.md\",\"wiki/product/modules.md\",\"wiki/product/onboarding-and-request-chain.md\",\"wiki/product/pc-create-project-journey.md\",\"wiki/product/pc-project-request-journey.md\",\"wiki/product/roles-and-permissions.md\",\"wiki/product/trade-classifications.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 runbooks\",\"slug\":\"product-architecture-documentation-runbooks\",\"files\":[\"wiki/runbooks/acs-email-dev.md\",\"wiki/runbooks/azure-dev-deploy.md\",\"wiki/runbooks/azure-test-deploy.md\",\"wiki/runbooks/ciam-company-branding.md\",\"wiki/runbooks/dev-custom-domains.md\",\"wiki/runbooks/didit-retention-30-days.md\",\"wiki/runbooks/entra-external-id-dev.md\",\"wiki/runbooks/entra-monitoring-and-cost-dev.md\",\"wiki/runbooks/privacy-counsel-brief.md\",\"wiki/runbooks/redis-and-payment-reliability.md\",\"wiki/runbooks/reset-test-identity.md\",\"wiki/runbooks/stripe-setup.md\",\"wiki/runbooks/vscode-postgres-setup.md\",\"wiki/runbooks/wallet-passes.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 sources\",\"slug\":\"product-architecture-documentation-sources\",\"files\":[\"wiki/sources/assureworx-brand-guidelines.md\",\"wiki/sources/assureworx-platform-spec.md\",\"wiki/sources/assureworx-product-definition.md\"]}]},{\"name\":\"Security &amp; Compliance Documentation\",\"slug\":\"security-compliance-documentation\",\"files\":[],\"children\":[{\"name\":\"Security &amp; Compliance Documentation \u2014 legal\",\"slug\":\"security-compliance-documentation-legal\",\"files\":[\"docs/legal/Assureworx-Privacy-Statement-2026-08-04.html\",\"docs/legal/Assureworx-Terms-of-Use-2026-08-04.html\",\"docs/legal/generate-director-pdfs.mjs\",\"docs/legal/print-styles.css\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 src\",\"slug\":\"security-compliance-documentation-src\",\"files\":[\"frontend/src/app/(legal)/LegalNav.tsx\",\"frontend/src/app/(legal)/layout.tsx\",\"frontend/src/app/(legal)/legal.module.css\",\"frontend/src/app/(legal)/privacy/page.tsx\",\"frontend/src/app/(legal)/terms/page.tsx\",\"frontend/src/components/legal/LegalLinks.module.css\",\"frontend/src/components/legal/LegalLinks.tsx\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 isms\",\"slug\":\"security-compliance-documentation-isms\",\"files\":[\"isms/AGENTS.md\",\"isms/Dashboard.md\",\"isms/README_OBSIDIAN.md\",\"isms/data-inventory.md\",\"isms/didit-posture.md\",\"isms/doc-intel-posture.md\",\"isms/environments.md\",\"isms/evidence-register.md\",\"isms/gap-analysis.md\",\"isms/getting-started.md\",\"isms/index.md\",\"isms/log.md\",\"isms/overview.md\",\"isms/risk-register.md\",\"isms/scope.md\",\"isms/soa.md\",\"isms/soc2-mapping.md\",\"isms/stripe-posture.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 archive\",\"slug\":\"security-compliance-documentation-archive\",\"files\":[\"isms/archive/README.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 assurance\",\"slug\":\"security-compliance-documentation-assurance\",\"files\":[\"isms/assurance/control-truth-register.json\",\"isms/assurance/index.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 evidence\",\"slug\":\"security-compliance-documentation-evidence\",\"files\":[\"isms/evidence/README.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 exports\",\"slug\":\"security-compliance-documentation-exports\",\"files\":[\"isms/exports/README.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 governance\",\"slug\":\"security-compliance-documentation-governance\",\"files\":[\"isms/governance/approval-record.md\",\"isms/governance/document-control-and-approval-procedure.md\",\"isms/governance/document-register.md\",\"isms/governance/factual-assurance-procedure.md\",\"isms/governance/shareholder-brief.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 plans\",\"slug\":\"security-compliance-documentation-plans\",\"files\":[\"isms/plans/01-governance.md\",\"isms/plans/02-access-control.md\",\"isms/plans/03-change-management.md\",\"isms/plans/04-environments.md\",\"isms/plans/05-logging-monitoring.md\",\"isms/plans/06-data-protection.md\",\"isms/plans/07-supplier-management.md\",\"isms/plans/08-resilience.md\",\"isms/plans/09-people.md\",\"isms/plans/10-audit-evidence.md\",\"isms/plans/index.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 policies\",\"slug\":\"security-compliance-documentation-policies\",\"files\":[\"isms/policies/alert-triage.md\",\"isms/policies/data-classification-and-handling-policy.md\",\"isms/policies/index.md\",\"isms/policies/information-security-policy.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 templates\",\"slug\":\"security-compliance-documentation-templates\",\"files\":[\"isms/templates/confidentiality-and-non-disclosure-agreement.md\",\"isms/templates/index.md\",\"isms/templates/personnel-offboarding-checklist.md\",\"isms/templates/personnel-onboarding-checklist.md\",\"isms/templates/policy-acknowledgement-register.md\",\"isms/templates/policy-acknowledgement.md\"]}]},{\"name\":\"Legacy &amp; Experimental Artifacts\",\"slug\":\"legacy-experimental-artifacts\",\"files\":[\"LLoyd/invite.module.css\",\"LLoyd/page 2 (1).tsx\",\"LLoyd/page 2.tsx\",\"LLoyd/page 3.tsx\",\"LLoyd/page 4.tsx\",\"LLoyd/requests.module.css\",\"Untitled.base\"]},{\"name\":\"Other\",\"slug\":\"other\",\"files\":[\"backend/src/modules/memberships/employee-checks.dbtest.ts\"]}];\nvar META = {\"fromCommit\":\"f5fdc8f6a291d60393eefbcdf66b3056a8f0580a\",\"generatedAt\":\"2026-08-08T04:15:47.788Z\",\"model\":\"deepseek-v4-flash\",\"lang\":\"\",\"moduleFiles\":{\"Authentication &amp; Session Management\":[\"backend/src/modules/auth/index.ts\",\"backend/src/modules/auth/otp-email.ts\",\"backend/src/shared/auth/dev-auth-gate.ts\",\"backend/src/shared/auth/ensure-trade-profile.ts\",\"backend/src/shared/auth/entra.ts\",\"backend/src/shared/auth/graph-email.ts\",\"backend/src/shared/auth/link-actor.ts\",\"backend/src/shared/auth/native-auth.ts\",\"backend/src/shared/auth/otp-callback-auth.test.ts\",\"backend/src/shared/auth/otp-callback-auth.ts\",\"backend/src/shared/auth/session.ts\",\"backend/src/shared/middleware/require-auth.ts\",\"backend/src/shared/services/email-change.ts\",\"frontend/src/app/auth/callback/AuthCallbackClient.tsx\",\"frontend/src/app/auth/callback/page.tsx\",\"frontend/src/app/auth/created/CreatedClient.tsx\",\"frontend/src/app/auth/created/page.tsx\",\"frontend/src/app/auth/credentials/CredentialsClient.tsx\",\"frontend/src/app/auth/credentials/page.tsx\",\"frontend/src/app/auth/email-change/[kind]/EmailChangeClient.tsx\",\"frontend/src/app/auth/email-change/[kind]/confirm.module.css\",\"frontend/src/app/auth/email-change/[kind]/page.tsx\",\"frontend/src/app/auth/invite/[token]/InviteClient.tsx\",\"frontend/src/app/auth/invite/[token]/invite.module.css\",\"frontend/src/app/auth/invite/[token]/page.tsx\",\"frontend/src/app/auth/login/LoginClient.tsx\",\"frontend/src/app/auth/login/login.module.css\",\"frontend/src/app/auth/login/page.tsx\",\"frontend/src/app/auth/register/RegisterClient.tsx\",\"frontend/src/app/auth/register/page.tsx\",\"frontend/src/app/auth/register/register.module.css\",\"frontend/src/app/auth/reset/ResetClient.tsx\",\"frontend/src/app/auth/reset/new-password/NewPasswordClient.tsx\",\"frontend/src/app/auth/reset/new-password/page.tsx\",\"frontend/src/app/auth/reset/page.tsx\",\"frontend/src/app/auth/verify-code/VerifyCodeClient.tsx\",\"frontend/src/app/auth/verify-code/page.tsx\",\"frontend/src/components/auth/AuthBrandShell.tsx\",\"frontend/src/components/auth/LoginReveal.module.css\",\"frontend/src/components/auth/LoginReveal.tsx\",\"frontend/src/lib/credential-setup.ts\",\"frontend/src/lib/entra.ts\",\"frontend/src/lib/login-reveal.ts\",\"frontend/src/lib/safe-return-to.ts\",\"frontend/src/lib/server-session.ts\",\"frontend/src/lib/session-core.ts\",\"frontend/src/lib/session.module.css\",\"frontend/src/lib/session.tsx\",\"frontend/src/middleware.ts\"],\"Authentication &amp; Session Management \u2014 backend-src\":[\"backend/src/modules/auth/index.ts\",\"backend/src/modules/auth/otp-email.ts\",\"backend/src/shared/auth/dev-auth-gate.ts\",\"backend/src/shared/auth/ensure-trade-profile.ts\",\"backend/src/shared/auth/entra.ts\",\"backend/src/shared/auth/graph-email.ts\",\"backend/src/shared/auth/link-actor.ts\",\"backend/src/shared/auth/native-auth.ts\",\"backend/src/shared/auth/otp-callback-auth.test.ts\",\"backend/src/shared/auth/otp-callback-auth.ts\",\"backend/src/shared/auth/session.ts\",\"backend/src/shared/middleware/require-auth.ts\",\"backend/src/shared/services/email-change.ts\"],\"Authentication &amp; Session Management \u2014 frontend-src\":[\"frontend/src/app/auth/callback/AuthCallbackClient.tsx\",\"frontend/src/app/auth/callback/page.tsx\",\"frontend/src/app/auth/created/CreatedClient.tsx\",\"frontend/src/app/auth/created/page.tsx\",\"frontend/src/app/auth/credentials/CredentialsClient.tsx\",\"frontend/src/app/auth/credentials/page.tsx\",\"frontend/src/app/auth/email-change/[kind]/EmailChangeClient.tsx\",\"frontend/src/app/auth/email-change/[kind]/confirm.module.css\",\"frontend/src/app/auth/email-change/[kind]/page.tsx\",\"frontend/src/app/auth/invite/[token]/InviteClient.tsx\",\"frontend/src/app/auth/invite/[token]/invite.module.css\",\"frontend/src/app/auth/invite/[token]/page.tsx\",\"frontend/src/app/auth/login/LoginClient.tsx\",\"frontend/src/app/auth/login/login.module.css\",\"frontend/src/app/auth/login/page.tsx\",\"frontend/src/app/auth/register/RegisterClient.tsx\",\"frontend/src/app/auth/register/page.tsx\",\"frontend/src/app/auth/register/register.module.css\",\"frontend/src/app/auth/reset/ResetClient.tsx\",\"frontend/src/app/auth/reset/new-password/NewPasswordClient.tsx\",\"frontend/src/app/auth/reset/new-password/page.tsx\",\"frontend/src/app/auth/reset/page.tsx\",\"frontend/src/app/auth/verify-code/VerifyCodeClient.tsx\",\"frontend/src/app/auth/verify-code/page.tsx\",\"frontend/src/components/auth/AuthBrandShell.tsx\",\"frontend/src/components/auth/LoginReveal.module.css\",\"frontend/src/components/auth/LoginReveal.tsx\",\"frontend/src/lib/credential-setup.ts\",\"frontend/src/lib/entra.ts\",\"frontend/src/lib/login-reveal.ts\",\"frontend/src/lib/safe-return-to.ts\",\"frontend/src/lib/server-session.ts\",\"frontend/src/lib/session-core.ts\",\"frontend/src/lib/session.module.css\",\"frontend/src/lib/session.tsx\",\"frontend/src/middleware.ts\"],\"Identity Verification &amp; Onboarding\":[\"backend/src/modules/identity/consent-version.test.ts\",\"backend/src/modules/identity/consent.ts\",\"backend/src/modules/identity/didit-boundary.test.ts\",\"backend/src/modules/identity/didit.ts\",\"backend/src/modules/identity/index.ts\",\"backend/src/modules/identity/reconcile.ts\",\"backend/src/modules/identity/scheduler.ts\",\"backend/src/modules/identity/service.ts\",\"backend/src/modules/identity/types.ts\",\"backend/src/modules/onboarding/index.ts\",\"backend/src/modules/verification/index.ts\",\"backend/src/shared/services/identity-check.ts\",\"backend/src/shared/services/identity-consent.ts\",\"backend/src/shared/services/identity-currency.test.ts\",\"backend/src/shared/services/identity.ts\",\"backend/src/shared/services/image-bounds.ts\",\"backend/src/shared/services/verification-template.ts\",\"backend/src/shared/services/verified-status.ts\",\"frontend/src/app/(app)/profile/ProfileClient.tsx\",\"frontend/src/app/(app)/profile/profile.module.css\",\"frontend/src/app/(app)/profile/page.tsx\",\"frontend/src/app/(app)/profile/documents/DocumentsClient.tsx\",\"frontend/src/app/(app)/profile/documents/documents.module.css\",\"frontend/src/app/(app)/profile/documents/page.tsx\",\"frontend/src/app/onboarding/abn/AbnClient.tsx\",\"frontend/src/app/onboarding/abn/abn.module.css\",\"frontend/src/app/onboarding/abn/page.tsx\",\"frontend/src/app/onboarding/documents/DocumentsClient.tsx\",\"frontend/src/app/onboarding/documents/documents.module.css\",\"frontend/src/app/onboarding/documents/page.tsx\",\"frontend/src/app/onboarding/identity/IdentityClient.tsx\",\"frontend/src/app/onboarding/identity/identity.module.css\",\"frontend/src/app/onboarding/identity/page.tsx\",\"frontend/src/app/onboarding/invite/[token]/OnboardingInviteClient.tsx\",\"frontend/src/app/onboarding/invite/[token]/invite.module.css\",\"frontend/src/app/onboarding/invite/[token]/page.tsx\",\"frontend/src/app/onboarding/layout.tsx\",\"frontend/src/app/onboarding/review/ReviewClient.tsx\",\"frontend/src/app/onboarding/review/review.module.css\",\"frontend/src/app/onboarding/review/page.tsx\",\"frontend/src/app/onboarding/trade-types/TradeTypesClient.tsx\",\"frontend/src/app/onboarding/trade-types/trade-types.module.css\",\"frontend/src/app/onboarding/trade-types/page.tsx\",\"frontend/src/app/verify/VerifyLandingClient.tsx\",\"frontend/src/app/verify/abr/VerifyAbrClient.tsx\",\"frontend/src/app/verify/abr/page.tsx\",\"frontend/src/app/verify/certificates/VerifyCertificatesClient.tsx\",\"frontend/src/app/verify/certificates/page.tsx\",\"frontend/src/app/verify/checks/VerifyChecksClient.tsx\",\"frontend/src/app/verify/checks/checks.module.css\",\"frontend/src/app/verify/checks/page.tsx\",\"frontend/src/app/verify/details/VerifyDetailsClient.tsx\",\"frontend/src/app/verify/details/details.module.css\",\"frontend/src/app/verify/details/page.tsx\",\"frontend/src/app/verify/i/[token]/VerifyInviteClient.tsx\",\"frontend/src/app/verify/i/[token]/invited.module.css\",\"frontend/src/app/verify/i/[token]/page.tsx\",\"frontend/src/app/verify/journey/VerifyJourneyClient.tsx\",\"frontend/src/app/verify/journey/journey.module.css\",\"frontend/src/app/verify/journey/page.tsx\",\"frontend/src/app/verify/layout.tsx\",\"frontend/src/app/verify/page.tsx\",\"frontend/src/app/verify/pay/VerifyPayClient.tsx\",\"frontend/src/app/verify/pay/pay.module.css\",\"frontend/src/app/verify/pay/page.tsx\",\"frontend/src/app/verify/photo/VerifyPhotoClient.tsx\",\"frontend/src/app/verify/photo/photo.module.css\",\"frontend/src/app/verify/photo/page.tsx\",\"frontend/src/app/verify/scan/VerifyScanClient.tsx\",\"frontend/src/app/verify/scan/scan.module.css\",\"frontend/src/app/verify/scan/page.tsx\",\"frontend/src/app/verify/staff/VerifyStaffClient.tsx\",\"frontend/src/app/verify/staff/staff.module.css\",\"frontend/src/app/verify/staff/page.tsx\",\"frontend/src/app/verify/trade/VerifyTradeClient.tsx\",\"frontend/src/app/verify/trade/page.tsx\",\"frontend/src/app/verify/verify.module.css\",\"frontend/src/components/domain/IdentityPanel.module.css\",\"frontend/src/components/domain/IdentityPanel.tsx\",\"frontend/src/components/domain/ProfileHeader.module.css\",\"frontend/src/components/domain/ProfileHeader.tsx\",\"frontend/src/components/domain/ProfileSheet.module.css\",\"frontend/src/components/domain/ProfileSheet.tsx\",\"frontend/src/components/domain/VerifiedSplit.module.css\",\"frontend/src/components/domain/VerifiedSplit.tsx\",\"frontend/src/components/onboarding/WizardShell.module.css\",\"frontend/src/components/onboarding/WizardShell.tsx\",\"frontend/src/components/verify/IdentityHandoff.module.css\",\"frontend/src/components/verify/IdentityHandoff.tsx\",\"frontend/src/components/verify/VerifyEvidenceStep.module.css\",\"frontend/src/components/verify/VerifyEvidenceStep.tsx\",\"frontend/src/components/verify/VerifyShell.module.css\",\"frontend/src/components/verify/VerifyShell.tsx\",\"frontend/src/lib/identity.ts\",\"frontend/src/lib/onboarding-errors.ts\",\"frontend/src/lib/use-onboarding-draft-owner.ts\",\"frontend/src/lib/verify-demo.ts\"],\"Identity Verification &amp; Onboarding \u2014 backend-src\":[\"backend/src/modules/identity/consent-version.test.ts\",\"backend/src/modules/identity/consent.ts\",\"backend/src/modules/identity/didit-boundary.test.ts\",\"backend/src/modules/identity/didit.ts\",\"backend/src/modules/identity/index.ts\",\"backend/src/modules/identity/reconcile.ts\",\"backend/src/modules/identity/scheduler.ts\",\"backend/src/modules/identity/service.ts\",\"backend/src/modules/identity/types.ts\",\"backend/src/modules/onboarding/index.ts\",\"backend/src/modules/verification/index.ts\",\"backend/src/shared/services/identity-check.ts\",\"backend/src/shared/services/identity-consent.ts\",\"backend/src/shared/services/identity-currency.test.ts\",\"backend/src/shared/services/identity.ts\",\"backend/src/shared/services/image-bounds.ts\",\"backend/src/shared/services/verification-template.ts\",\"backend/src/shared/services/verified-status.ts\"],\"Identity Verification &amp; Onboarding \u2014 frontend-src\":[\"frontend/src/app/(app)/profile/ProfileClient.tsx\",\"frontend/src/app/(app)/profile/profile.module.css\",\"frontend/src/app/(app)/profile/page.tsx\",\"frontend/src/app/(app)/profile/documents/DocumentsClient.tsx\",\"frontend/src/app/(app)/profile/documents/documents.module.css\",\"frontend/src/app/(app)/profile/documents/page.tsx\",\"frontend/src/app/onboarding/abn/AbnClient.tsx\",\"frontend/src/app/onboarding/abn/abn.module.css\",\"frontend/src/app/onboarding/abn/page.tsx\",\"frontend/src/app/onboarding/documents/DocumentsClient.tsx\",\"frontend/src/app/onboarding/documents/documents.module.css\",\"frontend/src/app/onboarding/documents/page.tsx\",\"frontend/src/app/onboarding/identity/IdentityClient.tsx\",\"frontend/src/app/onboarding/identity/identity.module.css\",\"frontend/src/app/onboarding/identity/page.tsx\",\"frontend/src/app/onboarding/invite/[token]/OnboardingInviteClient.tsx\",\"frontend/src/app/onboarding/invite/[token]/invite.module.css\",\"frontend/src/app/onboarding/invite/[token]/page.tsx\",\"frontend/src/app/onboarding/layout.tsx\",\"frontend/src/app/onboarding/review/ReviewClient.tsx\",\"frontend/src/app/onboarding/review/review.module.css\",\"frontend/src/app/onboarding/review/page.tsx\",\"frontend/src/app/onboarding/trade-types/TradeTypesClient.tsx\",\"frontend/src/app/onboarding/trade-types/trade-types.module.css\",\"frontend/src/app/onboarding/trade-types/page.tsx\",\"frontend/src/app/verify/VerifyLandingClient.tsx\",\"frontend/src/app/verify/abr/VerifyAbrClient.tsx\",\"frontend/src/app/verify/abr/page.tsx\",\"frontend/src/app/verify/certificates/VerifyCertificatesClient.tsx\",\"frontend/src/app/verify/certificates/page.tsx\",\"frontend/src/app/verify/checks/VerifyChecksClient.tsx\",\"frontend/src/app/verify/checks/checks.module.css\",\"frontend/src/app/verify/checks/page.tsx\",\"frontend/src/app/verify/details/VerifyDetailsClient.tsx\",\"frontend/src/app/verify/details/details.module.css\",\"frontend/src/app/verify/details/page.tsx\",\"frontend/src/app/verify/i/[token]/VerifyInviteClient.tsx\",\"frontend/src/app/verify/i/[token]/invited.module.css\",\"frontend/src/app/verify/i/[token]/page.tsx\",\"frontend/src/app/verify/journey/VerifyJourneyClient.tsx\",\"frontend/src/app/verify/journey/journey.module.css\",\"frontend/src/app/verify/journey/page.tsx\",\"frontend/src/app/verify/layout.tsx\",\"frontend/src/app/verify/page.tsx\",\"frontend/src/app/verify/pay/VerifyPayClient.tsx\",\"frontend/src/app/verify/pay/pay.module.css\",\"frontend/src/app/verify/pay/page.tsx\",\"frontend/src/app/verify/photo/VerifyPhotoClient.tsx\",\"frontend/src/app/verify/photo/photo.module.css\",\"frontend/src/app/verify/photo/page.tsx\",\"frontend/src/app/verify/scan/VerifyScanClient.tsx\",\"frontend/src/app/verify/scan/scan.module.css\",\"frontend/src/app/verify/scan/page.tsx\",\"frontend/src/app/verify/staff/VerifyStaffClient.tsx\",\"frontend/src/app/verify/staff/staff.module.css\",\"frontend/src/app/verify/staff/page.tsx\",\"frontend/src/app/verify/trade/VerifyTradeClient.tsx\",\"frontend/src/app/verify/trade/page.tsx\",\"frontend/src/app/verify/verify.module.css\",\"frontend/src/components/domain/IdentityPanel.module.css\",\"frontend/src/components/domain/IdentityPanel.tsx\",\"frontend/src/components/domain/ProfileHeader.module.css\",\"frontend/src/components/domain/ProfileHeader.tsx\",\"frontend/src/components/domain/ProfileSheet.module.css\",\"frontend/src/components/domain/ProfileSheet.tsx\",\"frontend/src/components/domain/VerifiedSplit.module.css\",\"frontend/src/components/domain/VerifiedSplit.tsx\",\"frontend/src/components/onboarding/WizardShell.module.css\",\"frontend/src/components/onboarding/WizardShell.tsx\",\"frontend/src/components/verify/IdentityHandoff.module.css\",\"frontend/src/components/verify/IdentityHandoff.tsx\",\"frontend/src/components/verify/VerifyEvidenceStep.module.css\",\"frontend/src/components/verify/VerifyEvidenceStep.tsx\",\"frontend/src/components/verify/VerifyShell.module.css\",\"frontend/src/components/verify/VerifyShell.tsx\",\"frontend/src/lib/identity.ts\",\"frontend/src/lib/onboarding-errors.ts\",\"frontend/src/lib/use-onboarding-draft-owner.ts\",\"frontend/src/lib/verify-demo.ts\"],\"Evidence, Checks &amp; Compliance\":[\"backend/src/modules/cases/index.ts\",\"backend/src/modules/checks/index.ts\",\"backend/src/shared/services/artifact-storage.ts\",\"backend/src/shared/services/checklist.ts\",\"backend/src/shared/services/evidence-action-reason.test.ts\",\"backend/src/shared/services/evidence.ts\",\"backend/src/shared/services/extraction/cloud-read.ts\",\"backend/src/shared/services/extraction/decide-status.test.ts\",\"backend/src/shared/services/extraction/decide-status.ts\",\"backend/src/shared/services/extraction/index.ts\",\"backend/src/shared/services/extraction/parse.ts\",\"backend/src/shared/services/extraction/preprocess.test.ts\",\"backend/src/shared/services/extraction/preprocess.ts\",\"backend/src/shared/services/extraction/present.ts\",\"backend/src/shared/services/extraction/preview-cache.ts\",\"backend/src/shared/services/extraction/rules.ts\",\"backend/src/shared/services/extraction/sources.ts\",\"backend/src/shared/services/extraction/types.ts\",\"backend/src/shared/services/extraction/validate.ts\",\"backend/src/shared/services/requirements.ts\",\"frontend/src/components/domain/CoreChecks.module.css\",\"frontend/src/components/domain/CoreChecks.tsx\",\"frontend/src/components/domain/DocumentRead.module.css\",\"frontend/src/components/domain/DocumentRead.tsx\",\"frontend/src/components/domain/DocumentScanOverlay.module.css\",\"frontend/src/components/domain/DocumentScanOverlay.tsx\",\"frontend/src/components/domain/EvidenceBoard.module.css\",\"frontend/src/components/domain/EvidenceBoard.tsx\",\"frontend/src/components/domain/EvidenceMeter.module.css\",\"frontend/src/components/domain/EvidenceMeter.tsx\",\"frontend/src/components/domain/EvidenceTable.module.css\",\"frontend/src/components/domain/EvidenceTable.tsx\",\"frontend/src/components/domain/EvidenceUploadForm.module.css\",\"frontend/src/components/domain/EvidenceUploadForm.tsx\",\"frontend/src/components/domain/StatusDonut.module.css\",\"frontend/src/components/domain/StatusDonut.tsx\",\"frontend/src/lib/check-labels.ts\",\"frontend/src/lib/evidence-groups.ts\",\"frontend/src/lib/use-extraction.ts\"],\"Evidence, Checks &amp; Compliance \u2014 backend-src\":[\"backend/src/modules/cases/index.ts\",\"backend/src/modules/checks/index.ts\",\"backend/src/shared/services/artifact-storage.ts\",\"backend/src/shared/services/checklist.ts\",\"backend/src/shared/services/evidence-action-reason.test.ts\",\"backend/src/shared/services/evidence.ts\",\"backend/src/shared/services/extraction/cloud-read.ts\",\"backend/src/shared/services/extraction/decide-status.test.ts\",\"backend/src/shared/services/extraction/decide-status.ts\",\"backend/src/shared/services/extraction/index.ts\",\"backend/src/shared/services/extraction/parse.ts\",\"backend/src/shared/services/extraction/preprocess.test.ts\",\"backend/src/shared/services/extraction/preprocess.ts\",\"backend/src/shared/services/extraction/present.ts\",\"backend/src/shared/services/extraction/preview-cache.ts\",\"backend/src/shared/services/extraction/rules.ts\",\"backend/src/shared/services/extraction/sources.ts\",\"backend/src/shared/services/extraction/types.ts\",\"backend/src/shared/services/extraction/validate.ts\",\"backend/src/shared/services/requirements.ts\"],\"Evidence, Checks &amp; Compliance \u2014 frontend-src\":[\"frontend/src/components/domain/CoreChecks.module.css\",\"frontend/src/components/domain/CoreChecks.tsx\",\"frontend/src/components/domain/DocumentRead.module.css\",\"frontend/src/components/domain/DocumentRead.tsx\",\"frontend/src/components/domain/DocumentScanOverlay.module.css\",\"frontend/src/components/domain/DocumentScanOverlay.tsx\",\"frontend/src/components/domain/EvidenceBoard.module.css\",\"frontend/src/components/domain/EvidenceBoard.tsx\",\"frontend/src/components/domain/EvidenceMeter.module.css\",\"frontend/src/components/domain/EvidenceMeter.tsx\",\"frontend/src/components/domain/EvidenceTable.module.css\",\"frontend/src/components/domain/EvidenceTable.tsx\",\"frontend/src/components/domain/EvidenceUploadForm.module.css\",\"frontend/src/components/domain/EvidenceUploadForm.tsx\",\"frontend/src/components/domain/StatusDonut.module.css\",\"frontend/src/components/domain/StatusDonut.tsx\",\"frontend/src/lib/check-labels.ts\",\"frontend/src/lib/evidence-groups.ts\",\"frontend/src/lib/use-extraction.ts\"],\"Retention &amp; Data Governance\":[\"backend/src/modules/retention/closure.ts\",\"backend/src/modules/retention/erase.ts\",\"backend/src/modules/retention/retention.test.ts\",\"backend/src/modules/retention/scheduler.ts\",\"backend/src/modules/retention/sweep.ts\"],\"Payments &amp; Billing\":[\"backend/src/modules/billing/index.ts\",\"backend/src/modules/payments/attempts.ts\",\"backend/src/modules/payments/boot.test.ts\",\"backend/src/modules/payments/bulk.dbtest.ts\",\"backend/src/modules/payments/cases.ts\",\"backend/src/modules/payments/checkout.ts\",\"backend/src/modules/payments/eligibility.ts\",\"backend/src/modules/payments/flow.dbtest.ts\",\"backend/src/modules/payments/inbox.ts\",\"backend/src/modules/payments/index.ts\",\"backend/src/modules/payments/ledger.ts\",\"backend/src/modules/payments/orders.ts\",\"backend/src/modules/payments/pricing.test.ts\",\"backend/src/modules/payments/pricing.ts\",\"backend/src/modules/payments/process-event.ts\",\"backend/src/modules/payments/provider/fake.ts\",\"backend/src/modules/payments/provider/index.ts\",\"backend/src/modules/payments/provider/provider.test.ts\",\"backend/src/modules/payments/provider/stripe.ts\",\"backend/src/modules/payments/provider/types.ts\",\"backend/src/modules/payments/reconcile.ts\",\"backend/src/modules/payments/refund.dbtest.ts\",\"backend/src/modules/payments/refunds.ts\",\"backend/src/modules/payments/reliability.test.ts\",\"backend/src/modules/payments/scheduler.ts\",\"backend/src/modules/payments/schema.dbtest.ts\",\"backend/src/modules/payments/state.test.ts\",\"backend/src/modules/payments/state.ts\",\"backend/src/modules/payments/thank-you-email.ts\",\"backend/src/modules/payments/views.ts\",\"backend/src/modules/payments/webhook.ts\",\"backend/src/shared/services/meter.ts\",\"frontend/src/app/(app)/billing/BillingClient.tsx\",\"frontend/src/app/(app)/billing/billing.module.css\",\"frontend/src/app/(app)/billing/page.tsx\",\"frontend/src/app/(app)/payments/cancelled/page.tsx\",\"frontend/src/app/(app)/payments/return/page.tsx\",\"frontend/src/components/domain/FundingCallout.module.css\",\"frontend/src/components/domain/FundingCallout.tsx\"],\"Payments &amp; Billing \u2014 backend-src\":[\"backend/src/modules/billing/index.ts\",\"backend/src/modules/payments/attempts.ts\",\"backend/src/modules/payments/boot.test.ts\",\"backend/src/modules/payments/bulk.dbtest.ts\",\"backend/src/modules/payments/cases.ts\",\"backend/src/modules/payments/checkout.ts\",\"backend/src/modules/payments/eligibility.ts\",\"backend/src/modules/payments/flow.dbtest.ts\",\"backend/src/modules/payments/inbox.ts\",\"backend/src/modules/payments/index.ts\",\"backend/src/modules/payments/ledger.ts\",\"backend/src/modules/payments/orders.ts\",\"backend/src/modules/payments/pricing.test.ts\",\"backend/src/modules/payments/pricing.ts\",\"backend/src/modules/payments/process-event.ts\",\"backend/src/modules/payments/provider/fake.ts\",\"backend/src/modules/payments/provider/index.ts\",\"backend/src/modules/payments/provider/provider.test.ts\",\"backend/src/modules/payments/provider/stripe.ts\",\"backend/src/modules/payments/provider/types.ts\",\"backend/src/modules/payments/reconcile.ts\",\"backend/src/modules/payments/refund.dbtest.ts\",\"backend/src/modules/payments/refunds.ts\",\"backend/src/modules/payments/reliability.test.ts\",\"backend/src/modules/payments/scheduler.ts\",\"backend/src/modules/payments/schema.dbtest.ts\",\"backend/src/modules/payments/state.test.ts\",\"backend/src/modules/payments/state.ts\",\"backend/src/modules/payments/thank-you-email.ts\",\"backend/src/modules/payments/views.ts\",\"backend/src/modules/payments/webhook.ts\",\"backend/src/shared/services/meter.ts\"],\"Payments &amp; Billing \u2014 frontend-src\":[\"frontend/src/app/(app)/billing/BillingClient.tsx\",\"frontend/src/app/(app)/billing/billing.module.css\",\"frontend/src/app/(app)/billing/page.tsx\",\"frontend/src/app/(app)/payments/cancelled/page.tsx\",\"frontend/src/app/(app)/payments/return/page.tsx\",\"frontend/src/components/domain/FundingCallout.module.css\",\"frontend/src/components/domain/FundingCallout.tsx\"],\"Wallet &amp; Digital Passes\":[\"backend/src/modules/wallet/apple-webservice.ts\",\"backend/src/modules/wallet/apple.ts\",\"backend/src/modules/wallet/config.ts\",\"backend/src/modules/wallet/gate.ts\",\"backend/src/modules/wallet/generate-assets.mjs\",\"backend/src/modules/wallet/generate-google-assets.mjs\",\"backend/src/modules/wallet/google.ts\",\"backend/src/modules/wallet/index.ts\",\"backend/src/modules/wallet/pass-id.ts\",\"backend/src/modules/wallet/pass-store.ts\",\"backend/src/modules/wallet/subject.ts\",\"frontend/src/app/verify/pass/VerifyPassClient.tsx\",\"frontend/src/app/verify/pass/pass.module.css\",\"frontend/src/app/verify/pass/page.tsx\",\"frontend/src/components/pass/DigitalPass.module.css\",\"frontend/src/components/pass/DigitalPass.tsx\",\"frontend/src/components/pass/FaceCapture.module.css\",\"frontend/src/components/pass/FaceCapture.tsx\",\"frontend/src/components/pass/PassOverlay.module.css\",\"frontend/src/components/pass/PassOverlay.tsx\",\"frontend/src/components/pass/PassPhotoPanel.module.css\",\"frontend/src/components/pass/PassPhotoPanel.tsx\",\"frontend/src/components/pass/face-coach.ts\",\"frontend/src/components/pass/index.ts\",\"frontend/src/components/pass/pass-view.ts\",\"frontend/src/components/pass/use-pass.ts\"],\"Wallet &amp; Digital Passes \u2014 backend-src\":[\"backend/src/modules/wallet/apple-webservice.ts\",\"backend/src/modules/wallet/apple.ts\",\"backend/src/modules/wallet/config.ts\",\"backend/src/modules/wallet/gate.ts\",\"backend/src/modules/wallet/generate-assets.mjs\",\"backend/src/modules/wallet/generate-google-assets.mjs\",\"backend/src/modules/wallet/google.ts\",\"backend/src/modules/wallet/index.ts\",\"backend/src/modules/wallet/pass-id.ts\",\"backend/src/modules/wallet/pass-store.ts\",\"backend/src/modules/wallet/subject.ts\"],\"Wallet &amp; Digital Passes \u2014 frontend-src\":[\"frontend/src/app/verify/pass/VerifyPassClient.tsx\",\"frontend/src/app/verify/pass/pass.module.css\",\"frontend/src/app/verify/pass/page.tsx\",\"frontend/src/components/pass/DigitalPass.module.css\",\"frontend/src/components/pass/DigitalPass.tsx\",\"frontend/src/components/pass/FaceCapture.module.css\",\"frontend/src/components/pass/FaceCapture.tsx\",\"frontend/src/components/pass/PassOverlay.module.css\",\"frontend/src/components/pass/PassOverlay.tsx\",\"frontend/src/components/pass/PassPhotoPanel.module.css\",\"frontend/src/components/pass/PassPhotoPanel.tsx\",\"frontend/src/components/pass/face-coach.ts\",\"frontend/src/components/pass/index.ts\",\"frontend/src/components/pass/pass-view.ts\",\"frontend/src/components/pass/use-pass.ts\"],\"Projects, Sites &amp; Requests\":[\"backend/src/modules/nodes/index.ts\",\"backend/src/modules/requests/index.ts\",\"backend/src/shared/services/geocode.ts\",\"frontend/public/leaflet/leaflet.css\",\"frontend/src/app/(app)/projects/CreateProjectWizard.module.css\",\"frontend/src/app/(app)/projects/CreateProjectWizard.tsx\",\"frontend/src/app/(app)/projects/ProjectsClient.tsx\",\"frontend/src/app/(app)/projects/[id]/ProjectDetailClient.tsx\",\"frontend/src/app/(app)/projects/[id]/ProjectInviteComposer.tsx\",\"frontend/src/app/(app)/projects/[id]/page.tsx\",\"frontend/src/app/(app)/projects/[id]/project-detail.module.css\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/SiteDetailClient.tsx\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/page.tsx\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/site.module.css\",\"frontend/src/app/(app)/projects/page.tsx\",\"frontend/src/app/(app)/projects/projects.module.css\",\"frontend/src/app/(app)/requests/RequestsClient.tsx\",\"frontend/src/app/(app)/requests/page.tsx\",\"frontend/src/app/(app)/requests/requests.module.css\",\"frontend/src/components/domain/SiteMapBoard.module.css\",\"frontend/src/components/domain/SiteMapBoard.tsx\",\"frontend/src/components/domain/SiteMapBoardLazy.tsx\",\"frontend/src/lib/geo.ts\",\"frontend/src/lib/project-wizard-drafts.ts\"],\"Projects, Sites &amp; Requests \u2014 backend-src\":[\"backend/src/modules/nodes/index.ts\",\"backend/src/modules/requests/index.ts\",\"backend/src/shared/services/geocode.ts\"],\"Projects, Sites &amp; Requests \u2014 frontend-public\":[\"frontend/public/leaflet/leaflet.css\"],\"Projects, Sites &amp; Requests \u2014 frontend-src\":[\"frontend/src/app/(app)/projects/CreateProjectWizard.module.css\",\"frontend/src/app/(app)/projects/CreateProjectWizard.tsx\",\"frontend/src/app/(app)/projects/ProjectsClient.tsx\",\"frontend/src/app/(app)/projects/[id]/ProjectDetailClient.tsx\",\"frontend/src/app/(app)/projects/[id]/ProjectInviteComposer.tsx\",\"frontend/src/app/(app)/projects/[id]/page.tsx\",\"frontend/src/app/(app)/projects/[id]/project-detail.module.css\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/SiteDetailClient.tsx\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/page.tsx\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/site.module.css\",\"frontend/src/app/(app)/projects/page.tsx\",\"frontend/src/app/(app)/projects/projects.module.css\",\"frontend/src/app/(app)/requests/RequestsClient.tsx\",\"frontend/src/app/(app)/requests/page.tsx\",\"frontend/src/app/(app)/requests/requests.module.css\",\"frontend/src/components/domain/SiteMapBoard.module.css\",\"frontend/src/components/domain/SiteMapBoard.tsx\",\"frontend/src/components/domain/SiteMapBoardLazy.tsx\",\"frontend/src/lib/geo.ts\",\"frontend/src/lib/project-wizard-drafts.ts\"],\"Workforce &amp; Team Management\":[\"backend/src/modules/memberships/index.ts\",\"backend/src/modules/training/index.ts\",\"backend/src/shared/services/employee-placement.ts\",\"frontend/src/app/(app)/jobs/JobsClient.tsx\",\"frontend/src/app/(app)/jobs/jobs.module.css\",\"frontend/src/app/(app)/jobs/page.tsx\",\"frontend/src/app/(app)/team/EmployeeChecksPanel.tsx\",\"frontend/src/app/(app)/team/TeamClient.tsx\",\"frontend/src/app/(app)/team/page.tsx\",\"frontend/src/app/(app)/team/team.module.css\",\"frontend/src/app/(app)/training/page.tsx\",\"frontend/src/components/domain/EngagementList.module.css\",\"frontend/src/components/domain/EngagementList.tsx\",\"frontend/src/components/domain/SiteWorkforceManager.module.css\",\"frontend/src/components/domain/SiteWorkforceManager.tsx\",\"frontend/src/components/domain/WorkChecksBoard.module.css\",\"frontend/src/components/domain/WorkChecksBoard.tsx\",\"frontend/src/components/domain/WorkforceMetricRow.module.css\",\"frontend/src/components/domain/WorkforceMetricRow.tsx\",\"frontend/src/lib/flush-pending-staff.ts\"],\"Workforce &amp; Team Management \u2014 backend-src\":[\"backend/src/modules/memberships/index.ts\",\"backend/src/modules/training/index.ts\",\"backend/src/shared/services/employee-placement.ts\"],\"Workforce &amp; Team Management \u2014 frontend-src\":[\"frontend/src/app/(app)/jobs/JobsClient.tsx\",\"frontend/src/app/(app)/jobs/jobs.module.css\",\"frontend/src/app/(app)/jobs/page.tsx\",\"frontend/src/app/(app)/team/EmployeeChecksPanel.tsx\",\"frontend/src/app/(app)/team/TeamClient.tsx\",\"frontend/src/app/(app)/team/page.tsx\",\"frontend/src/app/(app)/team/team.module.css\",\"frontend/src/app/(app)/training/page.tsx\",\"frontend/src/components/domain/EngagementList.module.css\",\"frontend/src/components/domain/EngagementList.tsx\",\"frontend/src/components/domain/SiteWorkforceManager.module.css\",\"frontend/src/components/domain/SiteWorkforceManager.tsx\",\"frontend/src/components/domain/WorkChecksBoard.module.css\",\"frontend/src/components/domain/WorkChecksBoard.tsx\",\"frontend/src/components/domain/WorkforceMetricRow.module.css\",\"frontend/src/components/domain/WorkforceMetricRow.tsx\",\"frontend/src/lib/flush-pending-staff.ts\"],\"Trades &amp; Company Directory\":[\"backend/src/modules/directory/index.ts\",\"backend/src/modules/links/index.ts\",\"backend/src/modules/parties/index.ts\",\"backend/src/shared/services/abn-verifier.ts\",\"backend/src/shared/services/network.ts\",\"backend/src/shared/services/org-admin.ts\",\"frontend/src/app/(app)/trades/TradesClient.tsx\",\"frontend/src/app/(app)/trades/[id]/CompanyProfileClient.tsx\",\"frontend/src/app/(app)/trades/[id]/page.tsx\",\"frontend/src/app/(app)/trades/individuals/[id]/IndividualProfileClient.tsx\",\"frontend/src/app/(app)/trades/individuals/[id]/page.tsx\",\"frontend/src/app/(app)/trades/page.tsx\",\"frontend/src/app/(app)/trades/trades.module.css\",\"frontend/src/components/domain/LineageSummary.module.css\",\"frontend/src/components/domain/LineageSummary.tsx\",\"frontend/src/components/domain/LineageTree.module.css\",\"frontend/src/components/domain/LineageTree.tsx\",\"frontend/src/components/domain/TradeTypeIcon.tsx\",\"frontend/src/components/domain/TradeTypeList.module.css\",\"frontend/src/components/domain/TradeTypeList.tsx\",\"frontend/src/lib/trade-pack.ts\"],\"Trades &amp; Company Directory \u2014 backend-src\":[\"backend/src/modules/directory/index.ts\",\"backend/src/modules/links/index.ts\",\"backend/src/modules/parties/index.ts\",\"backend/src/shared/services/abn-verifier.ts\",\"backend/src/shared/services/network.ts\",\"backend/src/shared/services/org-admin.ts\"],\"Trades &amp; Company Directory \u2014 frontend-src\":[\"frontend/src/app/(app)/trades/TradesClient.tsx\",\"frontend/src/app/(app)/trades/[id]/CompanyProfileClient.tsx\",\"frontend/src/app/(app)/trades/[id]/page.tsx\",\"frontend/src/app/(app)/trades/individuals/[id]/IndividualProfileClient.tsx\",\"frontend/src/app/(app)/trades/individuals/[id]/page.tsx\",\"frontend/src/app/(app)/trades/page.tsx\",\"frontend/src/app/(app)/trades/trades.module.css\",\"frontend/src/components/domain/LineageSummary.module.css\",\"frontend/src/components/domain/LineageSummary.tsx\",\"frontend/src/components/domain/LineageTree.module.css\",\"frontend/src/components/domain/LineageTree.tsx\",\"frontend/src/components/domain/TradeTypeIcon.tsx\",\"frontend/src/components/domain/TradeTypeList.module.css\",\"frontend/src/components/domain/TradeTypeList.tsx\",\"frontend/src/lib/trade-pack.ts\"],\"Notifications &amp; Communication\":[\"backend/src/modules/email/webhook.ts\",\"backend/src/modules/notifications/index.ts\",\"backend/src/modules/sms/index.ts\",\"backend/src/modules/sms/scheduler.ts\",\"backend/src/modules/sms/webhook.ts\",\"backend/src/shared/services/email-brand.test.ts\",\"backend/src/shared/services/email-brand.ts\",\"backend/src/shared/services/email-delivery.ts\",\"backend/src/shared/services/email.ts\",\"backend/src/shared/services/invite-resend.test.ts\",\"backend/src/shared/services/invite-resend.ts\",\"backend/src/shared/services/sms/config.test.ts\",\"backend/src/shared/services/sms/index.ts\",\"backend/src/shared/services/sms/notify.ts\",\"backend/src/shared/services/sms/outbox.ts\",\"backend/src/shared/services/sms/phone.test.ts\",\"backend/src/shared/services/sms/phone.ts\",\"backend/src/shared/services/sms/provider.test.ts\",\"backend/src/shared/services/sms/provider.ts\",\"backend/src/shared/services/sms/queue.ts\",\"backend/src/shared/services/sms/reliability.test.ts\",\"backend/src/shared/services/sms/templates.test.ts\",\"backend/src/shared/services/sms/templates.ts\",\"frontend/src/lib/notification-href.ts\"],\"Admin, Support &amp; Reporting\":[\"backend/src/modules/admin/index.ts\",\"backend/src/modules/assurance/scheduler.ts\",\"backend/src/modules/assurance/sweep.test.ts\",\"backend/src/modules/assurance/sweep.ts\",\"backend/src/modules/reporting/index.ts\",\"backend/src/modules/support/index.ts\",\"backend/src/modules/support/sla.ts\",\"backend/src/shared/services/audit-context.ts\",\"backend/src/shared/services/audit-query.test.ts\",\"backend/src/shared/services/audit-query.ts\",\"backend/src/shared/services/audit.ts\",\"backend/src/shared/services/schema-erd.ts\",\"frontend/src/app/(app)/admin/AdminClient.tsx\",\"frontend/src/app/(app)/admin/admin.module.css\",\"frontend/src/app/(app)/admin/page.tsx\",\"frontend/src/app/(app)/reports/page.tsx\",\"frontend/src/app/(app)/settings/SettingsClient.tsx\",\"frontend/src/app/(app)/settings/account/AccountClient.tsx\",\"frontend/src/app/(app)/settings/account/account.module.css\",\"frontend/src/app/(app)/settings/account/page.tsx\",\"frontend/src/app/(app)/settings/page.tsx\",\"frontend/src/app/(app)/settings/settings.module.css\",\"frontend/src/app/(app)/support/SupportClient.tsx\",\"frontend/src/app/(app)/support/page.tsx\",\"frontend/src/app/(app)/support/support.module.css\",\"frontend/src/components/admin/AzureLineageStudio.module.css\",\"frontend/src/components/admin/AzureLineageStudio.tsx\",\"frontend/src/components/admin/PaymentsConsole.tsx\",\"frontend/src/components/admin/SchemaErdStudio.module.css\",\"frontend/src/components/admin/SchemaErdStudio.tsx\",\"frontend/src/components/admin/azureLineageData.ts\"],\"Admin, Support &amp; Reporting \u2014 backend-src\":[\"backend/src/modules/admin/index.ts\",\"backend/src/modules/assurance/scheduler.ts\",\"backend/src/modules/assurance/sweep.test.ts\",\"backend/src/modules/assurance/sweep.ts\",\"backend/src/modules/reporting/index.ts\",\"backend/src/modules/support/index.ts\",\"backend/src/modules/support/sla.ts\",\"backend/src/shared/services/audit-context.ts\",\"backend/src/shared/services/audit-query.test.ts\",\"backend/src/shared/services/audit-query.ts\",\"backend/src/shared/services/audit.ts\",\"backend/src/shared/services/schema-erd.ts\"],\"Admin, Support &amp; Reporting \u2014 frontend-src\":[\"frontend/src/app/(app)/admin/AdminClient.tsx\",\"frontend/src/app/(app)/admin/admin.module.css\",\"frontend/src/app/(app)/admin/page.tsx\",\"frontend/src/app/(app)/reports/page.tsx\",\"frontend/src/app/(app)/settings/SettingsClient.tsx\",\"frontend/src/app/(app)/settings/account/AccountClient.tsx\",\"frontend/src/app/(app)/settings/account/account.module.css\",\"frontend/src/app/(app)/settings/account/page.tsx\",\"frontend/src/app/(app)/settings/page.tsx\",\"frontend/src/app/(app)/settings/settings.module.css\",\"frontend/src/app/(app)/support/SupportClient.tsx\",\"frontend/src/app/(app)/support/page.tsx\",\"frontend/src/app/(app)/support/support.module.css\",\"frontend/src/components/admin/AzureLineageStudio.module.css\",\"frontend/src/components/admin/AzureLineageStudio.tsx\",\"frontend/src/components/admin/PaymentsConsole.tsx\",\"frontend/src/components/admin/SchemaErdStudio.module.css\",\"frontend/src/components/admin/SchemaErdStudio.tsx\",\"frontend/src/components/admin/azureLineageData.ts\"],\"UI Components &amp; App Shell\":[\"frontend/src/app/(app)/dashboard/DashboardClient.tsx\",\"frontend/src/app/(app)/dashboard/dashboard.module.css\",\"frontend/src/app/(app)/dashboard/page.tsx\",\"frontend/src/app/(app)/error.tsx\",\"frontend/src/app/(app)/layout.tsx\",\"frontend/src/app/(app)/loading.tsx\",\"frontend/src/app/globals.css\",\"frontend/src/app/layout.tsx\",\"frontend/src/app/page.tsx\",\"frontend/src/components/shell/ActivityRail.module.css\",\"frontend/src/components/shell/ActivityRail.tsx\",\"frontend/src/components/shell/AppShell.module.css\",\"frontend/src/components/shell/AppShell.tsx\",\"frontend/src/components/shell/HistoryNav.tsx\",\"frontend/src/components/shell/NavIcon.tsx\",\"frontend/src/components/shell/Sidebar.module.css\",\"frontend/src/components/shell/Sidebar.tsx\",\"frontend/src/components/shell/TopBar.module.css\",\"frontend/src/components/shell/TopBar.tsx\",\"frontend/src/components/shell/topbar-title-slot.ts\",\"frontend/src/components/ui/Button.module.css\",\"frontend/src/components/ui/Button.tsx\",\"frontend/src/components/ui/ComingSoonPage.tsx\",\"frontend/src/components/ui/DraftStatus.module.css\",\"frontend/src/components/ui/DraftStatus.tsx\",\"frontend/src/components/ui/EmptyState.module.css\",\"frontend/src/components/ui/EmptyState.tsx\",\"frontend/src/components/ui/Field.module.css\",\"frontend/src/components/ui/Field.tsx\",\"frontend/src/components/ui/MetaIcon.tsx\",\"frontend/src/components/ui/Modal.module.css\",\"frontend/src/components/ui/Modal.tsx\",\"frontend/src/components/ui/PageHeader.tsx\",\"frontend/src/components/ui/Panel.module.css\",\"frontend/src/components/ui/Panel.tsx\",\"frontend/src/components/ui/PartyAvatar.module.css\",\"frontend/src/components/ui/PartyAvatar.tsx\",\"frontend/src/components/ui/PartyIdentity.module.css\",\"frontend/src/components/ui/PartyIdentity.tsx\",\"frontend/src/components/ui/Skeleton.module.css\",\"frontend/src/components/ui/Skeleton.tsx\",\"frontend/src/components/ui/StatusChip.module.css\",\"frontend/src/components/ui/StatusChip.tsx\",\"frontend/src/components/ui/Toast.module.css\",\"frontend/src/components/ui/Toast.tsx\",\"frontend/src/lib/copy.ts\",\"frontend/src/lib/format.ts\",\"frontend/src/lib/initials.ts\",\"frontend/src/lib/nav.ts\",\"frontend/src/lib/use-app-history.ts\",\"frontend/src/lib/use-before-unload.ts\",\"frontend/src/lib/use-media-query.ts\",\"frontend/src/lib/use-persistent-draft.ts\",\"frontend/src/lib/use-presence.ts\",\"frontend/src/styles/page.module.css\",\"frontend/src/styles/tokens.css\"],\"API &amp; Backend Infrastructure\":[\"backend/src/app.ts\",\"backend/src/server.ts\",\"backend/src/seed.ts\",\"backend/src/shared/cache.ts\",\"backend/src/shared/codes.ts\",\"backend/src/shared/config.ts\",\"backend/src/shared/config-dev-auth.test.ts\",\"backend/src/shared/db/pool.ts\",\"backend/src/shared/http/envelope.ts\",\"backend/src/shared/http/errors.ts\",\"backend/src/shared/http/safe-return-to.ts\",\"backend/src/shared/jobs/handlers.ts\",\"backend/src/shared/jobs/queue.test.ts\",\"backend/src/shared/jobs/queue.ts\",\"backend/src/shared/middleware/csrf.ts\",\"backend/src/shared/middleware/rate-limit-policy.test.ts\",\"backend/src/shared/middleware/rate-limit.ts\",\"backend/src/shared/middleware/resolve-tenant-context.ts\",\"backend/src/shared/photo-url.ts\",\"backend/src/shared/redis.ts\",\"backend/src/shared/telemetry.ts\",\"backend/src/shared/services/idempotency.test.ts\",\"backend/src/shared/services/idempotency.ts\",\"frontend/src/lib/api.ts\",\"frontend/src/lib/load-app-page.ts\",\"frontend/src/lib/server-api.ts\",\"frontend/src/lib/use-api.ts\"],\"API &amp; Backend Infrastructure \u2014 backend-src\":[\"backend/src/app.ts\",\"backend/src/server.ts\",\"backend/src/seed.ts\",\"backend/src/shared/cache.ts\",\"backend/src/shared/codes.ts\",\"backend/src/shared/config.ts\",\"backend/src/shared/config-dev-auth.test.ts\",\"backend/src/shared/db/pool.ts\",\"backend/src/shared/http/envelope.ts\",\"backend/src/shared/http/errors.ts\",\"backend/src/shared/http/safe-return-to.ts\",\"backend/src/shared/jobs/handlers.ts\",\"backend/src/shared/jobs/queue.test.ts\",\"backend/src/shared/jobs/queue.ts\",\"backend/src/shared/middleware/csrf.ts\",\"backend/src/shared/middleware/rate-limit-policy.test.ts\",\"backend/src/shared/middleware/rate-limit.ts\",\"backend/src/shared/middleware/resolve-tenant-context.ts\",\"backend/src/shared/photo-url.ts\",\"backend/src/shared/redis.ts\",\"backend/src/shared/telemetry.ts\",\"backend/src/shared/services/idempotency.test.ts\",\"backend/src/shared/services/idempotency.ts\"],\"API &amp; Backend Infrastructure \u2014 frontend-src\":[\"frontend/src/lib/api.ts\",\"frontend/src/lib/load-app-page.ts\",\"frontend/src/lib/server-api.ts\",\"frontend/src/lib/use-api.ts\"],\"Database Migrations &amp; Schema\":[\"backend/src/shared/schema/tables.ts\",\"db/migrations/001_core_orgs_and_people.sql\",\"db/migrations/002_enterprise_core.sql\",\"db/migrations/003_hardening.sql\",\"db/migrations/004_account_invites.sql\",\"db/migrations/005_support_requests.sql\",\"db/migrations/006_identity_portability.sql\",\"db/migrations/007_email_change.sql\",\"db/migrations/008_security_hardening.sql\",\"db/migrations/009_rls_identity_evidence.sql\",\"db/migrations/010_sync_link_readiness.sql\",\"db/migrations/011_identity_consent_and_meter.sql\",\"db/migrations/012_check_sequence.sql\",\"db/migrations/013_check_contact_email.sql\",\"db/migrations/014_rls_remaining_tables.sql\",\"db/migrations/015_trade_engagement_visibility.sql\",\"db/migrations/016_audit_enrichment.sql\",\"db/migrations/017_engagement_requirements.sql\",\"db/migrations/018_meta_pass_dl.sql\",\"db/migrations/019_meta_read_via_definer.sql\",\"db/migrations/020_perf_indexes.sql\",\"db/migrations/021_party_locality_and_seat_attrs.sql\",\"db/migrations/022_invitation_verification_templates.sql\",\"db/migrations/023_verification_cases.sql\",\"db/migrations/024_payment_orders.sql\",\"db/migrations/025_refunds_and_disputes.sql\",\"db/migrations/026_payment_gated_verification.sql\",\"db/migrations/027_pass_dl_hits_and_devices.sql\",\"db/migrations/028_rls_actor_email_meter_idem.sql\",\"db/migrations/029_check_diagnostics.sql\",\"db/migrations/030_retention_and_account_closure.sql\",\"db/migrations/031_rel_attrs_holder_erasure.sql\",\"db/migrations/032_sms_notifications.sql\",\"db/migrations/033_artifact_pc_ack.sql\",\"db/migrations/034_employee_check_templates.sql\",\"db/migrations/035_refund_retry_schedule.sql\",\"db/migrations/036_email_delivery_status.sql\",\"db/migrations/037_seat_token_nullable.sql\",\"db/migrations/038_abn_identity_not_upload_universal.sql\",\"db/migrations/039_identity_consent_currency_fn.sql\",\"db/migrations/040_identity_admin_party_ids_fn.sql\",\"packages/shared-types/src/index.ts\"],\"Database Migrations &amp; Schema \u2014 src\":[\"backend/src/shared/schema/tables.ts\"],\"Database Migrations &amp; Schema \u2014 migrations\":[\"db/migrations/001_core_orgs_and_people.sql\",\"db/migrations/002_enterprise_core.sql\",\"db/migrations/003_hardening.sql\",\"db/migrations/004_account_invites.sql\",\"db/migrations/005_support_requests.sql\",\"db/migrations/006_identity_portability.sql\",\"db/migrations/007_email_change.sql\",\"db/migrations/008_security_hardening.sql\",\"db/migrations/009_rls_identity_evidence.sql\",\"db/migrations/010_sync_link_readiness.sql\",\"db/migrations/011_identity_consent_and_meter.sql\",\"db/migrations/012_check_sequence.sql\",\"db/migrations/013_check_contact_email.sql\",\"db/migrations/014_rls_remaining_tables.sql\",\"db/migrations/015_trade_engagement_visibility.sql\",\"db/migrations/016_audit_enrichment.sql\",\"db/migrations/017_engagement_requirements.sql\",\"db/migrations/018_meta_pass_dl.sql\",\"db/migrations/019_meta_read_via_definer.sql\",\"db/migrations/020_perf_indexes.sql\",\"db/migrations/021_party_locality_and_seat_attrs.sql\",\"db/migrations/022_invitation_verification_templates.sql\",\"db/migrations/023_verification_cases.sql\",\"db/migrations/024_payment_orders.sql\",\"db/migrations/025_refunds_and_disputes.sql\",\"db/migrations/026_payment_gated_verification.sql\",\"db/migrations/027_pass_dl_hits_and_devices.sql\",\"db/migrations/028_rls_actor_email_meter_idem.sql\",\"db/migrations/029_check_diagnostics.sql\",\"db/migrations/030_retention_and_account_closure.sql\",\"db/migrations/031_rel_attrs_holder_erasure.sql\",\"db/migrations/032_sms_notifications.sql\",\"db/migrations/033_artifact_pc_ack.sql\",\"db/migrations/034_employee_check_templates.sql\",\"db/migrations/035_refund_retry_schedule.sql\",\"db/migrations/036_email_delivery_status.sql\",\"db/migrations/037_seat_token_nullable.sql\",\"db/migrations/038_abn_identity_not_upload_universal.sql\",\"db/migrations/039_identity_consent_currency_fn.sql\",\"db/migrations/040_identity_admin_party_ids_fn.sql\"],\"Database Migrations &amp; Schema \u2014 shared-types\":[\"packages/shared-types/src/index.ts\"],\"Infrastructure &amp; Deployment\":[\"backend/Dockerfile\",\"backend/eslint.config.mjs\",\"backend/package.json\",\"backend/tsconfig.json\",\"frontend/Dockerfile\",\"frontend/eslint.config.mjs\",\"frontend/gate-proxy.mjs\",\"frontend/next.config.ts\",\"frontend/package.json\",\"frontend/tsconfig.json\",\"package.json\",\"packages/shared-types/package.json\",\"packages/shared-types/tsconfig.json\"],\"Development Tooling &amp; Scripts\":[\"backend/scripts/ensure-google-wallet-class.mts\",\"backend/scripts/mint-invite.mjs\",\"backend/scripts/probe-cloud-read.ts\",\"backend/scripts/probe-engagement-requirements.ts\",\"backend/scripts/probe-extraction.ts\",\"backend/scripts/probe-google-wallet.mts\",\"backend/scripts/probe-schema-erd.ts\",\"backend/scripts/probe-webhook.mjs\",\"backend/scripts/purge-party.mjs\",\"backend/scripts/verify-identity.ts\",\"backend/src/modules/_module.template.md\",\"frontend/public/strip_text.py\",\"scripts/apply-004.mjs\",\"scripts/apply-ciam-branding.ps1\",\"scripts/bind-dev-domains.ps1\",\"scripts/bind-test-domains.ps1\",\"scripts/bootstrap-platform-admin.mjs\",\"scripts/build-isms-starter-docs.py\",\"scripts/cancel-shutdown.bat\",\"scripts/deploy-dev.bat\",\"scripts/deploy-dev.ps1\",\"scripts/deploy-test.ps1\",\"scripts/enable-ciam-mfa-dev.ps1\",\"scripts/enable-ciam-native-auth.ps1\",\"scripts/enable-ciam-otp-email.ps1\",\"scripts/entra-app-reg-test.ps1\",\"scripts/entra-app-reg.ps1\",\"scripts/force-shutdown-10min.bat\",\"scripts/free-ports.ps1\",\"scripts/gen-data-catalogue.mjs\",\"scripts/isms-progress.mjs\",\"scripts/isms-truth-check.mjs\",\"scripts/migrate.mjs\",\"scripts/package-for-laptop.bat\",\"scripts/pg-ssl.mjs\",\"scripts/provision-pc.mjs\",\"scripts/provision-redis-dev.ps1\",\"scripts/reset-test-identity.bat\",\"scripts/reset-test-identity.mjs\",\"scripts/reset-test-identity.ps1\",\"scripts/run-mobile-demo.bat\",\"scripts/secure-local-infra.ps1\",\"scripts/slice-tradie-icons.py\",\"scripts/start-dev.bat\",\"scripts/switch-acs-sender-info.ps1\",\"scripts/wipe-test-app-data.mjs\",\"scripts/wipe-tradie.bat\",\"scripts/wire-acs-managed-identity.ps1\",\"scripts/backup/backup-to-d-drive.bat\",\"scripts/backup/backup-to-e-drive.bat\",\"scripts/backup/backup-to-folder.bat\",\"scripts/backup/sync-docs-to-e-drive.bat\",\"scripts/backup/sync-frontend-backend-to-wiki-raw.bat\",\"scripts/db-lifecycle/README.md\",\"scripts/db-lifecycle/_common.ps1\",\"scripts/db-lifecycle/backup-dev.bat\",\"scripts/db-lifecycle/backup-dev.ps1\",\"scripts/db-lifecycle/backup-local.bat\",\"scripts/db-lifecycle/backup-local.ps1\",\"scripts/db-lifecycle/ensure-founder.mjs\",\"scripts/db-lifecycle/inventory.mjs\",\"scripts/db-lifecycle/restore-to-local.bat\",\"scripts/db-lifecycle/restore-to-local.ps1\",\"scripts/db-lifecycle/wipe-local.bat\",\"scripts/db-lifecycle/wipe-local.ps1\"],\"Development Tooling &amp; Scripts \u2014 backend-scripts\":[\"backend/scripts/ensure-google-wallet-class.mts\",\"backend/scripts/mint-invite.mjs\",\"backend/scripts/probe-cloud-read.ts\",\"backend/scripts/probe-engagement-requirements.ts\",\"backend/scripts/probe-extraction.ts\",\"backend/scripts/probe-google-wallet.mts\",\"backend/scripts/probe-schema-erd.ts\",\"backend/scripts/probe-webhook.mjs\",\"backend/scripts/purge-party.mjs\",\"backend/scripts/verify-identity.ts\"],\"Development Tooling &amp; Scripts \u2014 backend-src\":[\"backend/src/modules/_module.template.md\"],\"Development Tooling &amp; Scripts \u2014 frontend-public\":[\"frontend/public/strip_text.py\"],\"Development Tooling &amp; Scripts \u2014 scripts\":[\"scripts/apply-004.mjs\",\"scripts/apply-ciam-branding.ps1\",\"scripts/bind-dev-domains.ps1\",\"scripts/bind-test-domains.ps1\",\"scripts/bootstrap-platform-admin.mjs\",\"scripts/build-isms-starter-docs.py\",\"scripts/cancel-shutdown.bat\",\"scripts/deploy-dev.bat\",\"scripts/deploy-dev.ps1\",\"scripts/deploy-test.ps1\",\"scripts/enable-ciam-mfa-dev.ps1\",\"scripts/enable-ciam-native-auth.ps1\",\"scripts/enable-ciam-otp-email.ps1\",\"scripts/entra-app-reg-test.ps1\",\"scripts/entra-app-reg.ps1\",\"scripts/force-shutdown-10min.bat\",\"scripts/free-ports.ps1\",\"scripts/gen-data-catalogue.mjs\",\"scripts/isms-progress.mjs\",\"scripts/isms-truth-check.mjs\",\"scripts/migrate.mjs\",\"scripts/package-for-laptop.bat\",\"scripts/pg-ssl.mjs\",\"scripts/provision-pc.mjs\",\"scripts/provision-redis-dev.ps1\",\"scripts/reset-test-identity.bat\",\"scripts/reset-test-identity.mjs\",\"scripts/reset-test-identity.ps1\",\"scripts/run-mobile-demo.bat\",\"scripts/secure-local-infra.ps1\",\"scripts/slice-tradie-icons.py\",\"scripts/start-dev.bat\",\"scripts/switch-acs-sender-info.ps1\",\"scripts/wipe-test-app-data.mjs\",\"scripts/wipe-tradie.bat\",\"scripts/wire-acs-managed-identity.ps1\"],\"Development Tooling &amp; Scripts \u2014 scripts-backup\":[\"scripts/backup/backup-to-d-drive.bat\",\"scripts/backup/backup-to-e-drive.bat\",\"scripts/backup/backup-to-folder.bat\",\"scripts/backup/sync-docs-to-e-drive.bat\",\"scripts/backup/sync-frontend-backend-to-wiki-raw.bat\"],\"Development Tooling &amp; Scripts \u2014 scripts-db-lifecycle\":[\"scripts/db-lifecycle/README.md\",\"scripts/db-lifecycle/_common.ps1\",\"scripts/db-lifecycle/backup-dev.bat\",\"scripts/db-lifecycle/backup-dev.ps1\",\"scripts/db-lifecycle/backup-local.bat\",\"scripts/db-lifecycle/backup-local.ps1\",\"scripts/db-lifecycle/ensure-founder.mjs\",\"scripts/db-lifecycle/inventory.mjs\",\"scripts/db-lifecycle/restore-to-local.bat\",\"scripts/db-lifecycle/restore-to-local.ps1\",\"scripts/db-lifecycle/wipe-local.bat\",\"scripts/db-lifecycle/wipe-local.ps1\"],\"Performance &amp; Reliability Testing\":[\"e2e/helpers/flows.ts\",\"e2e/helpers/instrument.ts\",\"e2e/package.json\",\"e2e/playwright.config.ts\",\"e2e/tsconfig.json\",\"e2e/tests/deep-diagnostics.spec.ts\",\"e2e/tests/stability.spec.ts\",\"frontend/src/components/dev/PerfProfiler.tsx\",\"frontend/src/components/dev/PerfRecorder.module.css\",\"frontend/src/components/dev/PerfRecorder.tsx\",\"frontend/src/components/dev/PerfRecorderHost.tsx\",\"frontend/src/components/dev/PerfSection.tsx\",\"frontend/src/lib/perf/findings.ts\",\"frontend/src/lib/perf/index.ts\",\"frontend/src/lib/perf/localhost-stress.ts\",\"frontend/src/lib/perf/post-fix-scenario.ts\",\"frontend/src/lib/perf/prod-scenarios.ts\",\"frontend/src/lib/perf/selftest.ts\",\"frontend/src/lib/perf/store.ts\",\"frontend/src/lib/perf/types.ts\",\"loadtests/README.md\",\"loadtests/RESULTS.md\",\"loadtests/assurance-fix-evidence-20260806-191548.txt\",\"loadtests/azure-aca-metrics.json\",\"loadtests/azure-health-burst.txt\",\"loadtests/azure-health-probe.txt\",\"loadtests/azure-pg-metrics.json\",\"loadtests/baseline-stdout.txt\",\"loadtests/load-stdout.txt\",\"loadtests/pentest-curl-proofs.mjs\",\"loadtests/pentest-curl-proofs.txt\",\"loadtests/pentest-deep-probes.mjs\",\"loadtests/pentest-evidence.json\",\"loadtests/pentest-jwt-forge-proof.txt\",\"loadtests/pentest-jwt-pg-chain-proof.json\",\"loadtests/pentest-jwt-pg-chain.mjs\",\"loadtests/pentest-live-probes.mjs\",\"loadtests/pentest-redis-sqlmap.mjs\",\"loadtests/pentest-route-extract.mjs\",\"loadtests/pentest-routes.json\",\"loadtests/results-baseline-1785793598363.json\",\"loadtests/results-baseline-1785794235448.json\",\"loadtests/results-load-1785793776827.json\",\"loadtests/results-load-1785793998515.json\",\"loadtests/results-load-1785794174371.json\",\"loadtests/results-load-1785794406385.json\",\"loadtests/results-load-1785794745183.json\",\"loadtests/results-stress-1785795073564.json\",\"loadtests/results-stress-1785795296871.json\",\"loadtests/sqlmap-abn.txt\",\"loadtests/stress-window-end.txt\",\"loadtests/stress-window-start.txt\",\"loadtests/test-window-start.txt\",\"loadtests/user-journey.js\",\"loadtests/zap/zap-baseline-stdout.txt\",\"loadtests/zap/zap-baseline.html\",\"loadtests/zap/zap-baseline.json\",\"loadtests/zap/zap.yaml\"],\"Performance &amp; Reliability Testing \u2014 helpers\":[\"e2e/helpers/flows.ts\",\"e2e/helpers/instrument.ts\"],\"Performance &amp; Reliability Testing \u2014 e2e\":[\"e2e/package.json\",\"e2e/playwright.config.ts\",\"e2e/tsconfig.json\"],\"Performance &amp; Reliability Testing \u2014 tests\":[\"e2e/tests/deep-diagnostics.spec.ts\",\"e2e/tests/stability.spec.ts\"],\"Performance &amp; Reliability Testing \u2014 src\":[\"frontend/src/components/dev/PerfProfiler.tsx\",\"frontend/src/components/dev/PerfRecorder.module.css\",\"frontend/src/components/dev/PerfRecorder.tsx\",\"frontend/src/components/dev/PerfRecorderHost.tsx\",\"frontend/src/components/dev/PerfSection.tsx\",\"frontend/src/lib/perf/findings.ts\",\"frontend/src/lib/perf/index.ts\",\"frontend/src/lib/perf/localhost-stress.ts\",\"frontend/src/lib/perf/post-fix-scenario.ts\",\"frontend/src/lib/perf/prod-scenarios.ts\",\"frontend/src/lib/perf/selftest.ts\",\"frontend/src/lib/perf/store.ts\",\"frontend/src/lib/perf/types.ts\"],\"Performance &amp; Reliability Testing \u2014 loadtests\":[\"loadtests/README.md\",\"loadtests/RESULTS.md\",\"loadtests/assurance-fix-evidence-20260806-191548.txt\",\"loadtests/azure-aca-metrics.json\",\"loadtests/azure-health-burst.txt\",\"loadtests/azure-health-probe.txt\",\"loadtests/azure-pg-metrics.json\",\"loadtests/baseline-stdout.txt\",\"loadtests/load-stdout.txt\",\"loadtests/pentest-curl-proofs.mjs\",\"loadtests/pentest-curl-proofs.txt\",\"loadtests/pentest-deep-probes.mjs\",\"loadtests/pentest-evidence.json\",\"loadtests/pentest-jwt-forge-proof.txt\",\"loadtests/pentest-jwt-pg-chain-proof.json\",\"loadtests/pentest-jwt-pg-chain.mjs\",\"loadtests/pentest-live-probes.mjs\",\"loadtests/pentest-redis-sqlmap.mjs\",\"loadtests/pentest-route-extract.mjs\",\"loadtests/pentest-routes.json\",\"loadtests/results-baseline-1785793598363.json\",\"loadtests/results-baseline-1785794235448.json\",\"loadtests/results-load-1785793776827.json\",\"loadtests/results-load-1785793998515.json\",\"loadtests/results-load-1785794174371.json\",\"loadtests/results-load-1785794406385.json\",\"loadtests/results-load-1785794745183.json\",\"loadtests/results-stress-1785795073564.json\",\"loadtests/results-stress-1785795296871.json\",\"loadtests/sqlmap-abn.txt\",\"loadtests/stress-window-end.txt\",\"loadtests/stress-window-start.txt\",\"loadtests/test-window-start.txt\",\"loadtests/user-journey.js\"],\"Performance &amp; Reliability Testing \u2014 zap\":[\"loadtests/zap/zap-baseline-stdout.txt\",\"loadtests/zap/zap-baseline.html\",\"loadtests/zap/zap-baseline.json\",\"loadtests/zap/zap.yaml\"],\"Product &amp; Architecture Documentation\":[\"AGENTS.md\",\"CLAUDE.md\",\"DESIGN.md\",\"PRODUCT.md\",\"README.md\",\"Raw/ASSUREWORX_BRAND.md\",\"Raw/ASSUREWORX_PRODUCT.md\",\"Raw/assureworx-platform-spec.html\",\"backend/README.md\",\"frontend/README.md\",\"wiki/architecture/azure-dev-inventory.md\",\"wiki/architecture/azure-test-inventory.md\",\"wiki/architecture/backend.md\",\"wiki/architecture/cicd-target-operating-model.md\",\"wiki/architecture/code-map.md\",\"wiki/architecture/data-classification-catalogue.md\",\"wiki/architecture/data-model.md\",\"wiki/architecture/document-extraction.md\",\"wiki/architecture/frontend.md\",\"wiki/architecture/full-codebase-audit-2026-07-28.md\",\"wiki/architecture/identity-verification.md\",\"wiki/architecture/implementation-review-2026-07-21.md\",\"wiki/architecture/infrastructure.md\",\"wiki/architecture/integrations.md\",\"wiki/architecture/page-inventory-2026-07-25.md\",\"wiki/architecture/payments-design-2026-08-01.md\",\"wiki/architecture/plan-review-2026-07-20.md\",\"wiki/architecture/platform-audit-2026-07-25.md\",\"wiki/architecture/prod-rendering-report-2026-08-01.md\",\"wiki/architecture/production-readiness-2026-07-21.md\",\"wiki/architecture/production-readiness-qa-audit-2026-08-01.md\",\"wiki/architecture/production-readiness-report-2026-08-01.md\",\"wiki/architecture/qa-audit-plan-2026-08-01.md\",\"wiki/architecture/realtime-verify.md\",\"wiki/architecture/rsc-performance-plan-2026-07-31.md\",\"wiki/architecture/schema-mapping.md\",\"wiki/architecture/security-audit-2026-07-25.md\",\"wiki/architecture/security-engagement-2026-08-04.md\",\"wiki/architecture/security.md\",\"wiki/architecture/stack.md\",\"wiki/architecture/ui-collection-antipattern-audit-2026-07-30.md\",\"wiki/architecture/ux-ui-audit-2026-07-24.md\",\"wiki/brand.md\",\"wiki/glossary.md\",\"wiki/index.md\",\"wiki/log.md\",\"wiki/overview.md\",\"wiki/ways-of-working.md\",\"wiki/decisions/0001-full-azure-stack.md\",\"wiki/decisions/0002-postgresql-flexible-server.md\",\"wiki/decisions/0003-repository-layer-tenant-isolation.md\",\"wiki/decisions/0004-pricing-model.md\",\"wiki/decisions/0005-verified-scope-hybrid.md\",\"wiki/decisions/0006-email-azure-communication-services.md\",\"wiki/decisions/0007-obfuscated-physical-schema.md\",\"wiki/decisions/0008-bff-proxy-session-auth.md\",\"wiki/decisions/0009-realtime-verification-orchestration.md\",\"wiki/decisions/0010-pc-cold-start-admin-provisioned.md\",\"wiki/decisions/0011-in-app-support-desk.md\",\"wiki/decisions/0012-assureworx-id-and-identity-resolution.md\",\"wiki/decisions/0013-login-email-change.md\",\"wiki/decisions/0014-didit-identity-verification.md\",\"wiki/decisions/0015-identity-reverification-triggers.md\",\"wiki/decisions/0016-derived-job-state.md\",\"wiki/decisions/0017-employee-deregistration.md\",\"wiki/decisions/0018-engagement-scoped-requirements.md\",\"wiki/decisions/0019-document-extraction-over-registry-verification.md\",\"wiki/decisions/0020-azure-document-intelligence-tier-3.md\",\"wiki/decisions/0021-sms-deferred-entra-acs-email.md\",\"wiki/decisions/0022-invitation-verification-template-snapshots.md\",\"wiki/decisions/0023-pay-per-verification.md\",\"wiki/decisions/0024-payment-provider-boundary.md\",\"wiki/decisions/0025-native-auth-bff-proxy.md\",\"wiki/decisions/0026-identity-check-diagnostics.md\",\"wiki/decisions/0027-data-retention-and-public-legal.md\",\"wiki/decisions/0028-transactional-sms-twilio.md\",\"wiki/decisions/0029-employer-sponsored-bulk-verification.md\",\"wiki/decisions/0030-employer-employee-check-selection.md\",\"wiki/decisions/0031-refund-reconciliation-schedule.md\",\"wiki/decisions/0032-di-auto-accept-and-pc-document-visibility.md\",\"wiki/decisions/index.md\",\"wiki/decisions/open-questions.md\",\"wiki/product/account-and-invite-journeys.md\",\"wiki/product/billing.md\",\"wiki/product/compliance-and-verification.md\",\"wiki/product/employee-verification-journey.md\",\"wiki/product/employer-pays-for-crew.md\",\"wiki/product/engagement-requirements.md\",\"wiki/product/entity-hierarchy.md\",\"wiki/product/identity-and-portability.md\",\"wiki/product/modules.md\",\"wiki/product/onboarding-and-request-chain.md\",\"wiki/product/pc-create-project-journey.md\",\"wiki/product/pc-project-request-journey.md\",\"wiki/product/roles-and-permissions.md\",\"wiki/product/trade-classifications.md\",\"wiki/runbooks/acs-email-dev.md\",\"wiki/runbooks/azure-dev-deploy.md\",\"wiki/runbooks/azure-test-deploy.md\",\"wiki/runbooks/ciam-company-branding.md\",\"wiki/runbooks/dev-custom-domains.md\",\"wiki/runbooks/didit-retention-30-days.md\",\"wiki/runbooks/entra-external-id-dev.md\",\"wiki/runbooks/entra-monitoring-and-cost-dev.md\",\"wiki/runbooks/privacy-counsel-brief.md\",\"wiki/runbooks/redis-and-payment-reliability.md\",\"wiki/runbooks/reset-test-identity.md\",\"wiki/runbooks/stripe-setup.md\",\"wiki/runbooks/vscode-postgres-setup.md\",\"wiki/runbooks/wallet-passes.md\",\"wiki/sources/assureworx-brand-guidelines.md\",\"wiki/sources/assureworx-platform-spec.md\",\"wiki/sources/assureworx-product-definition.md\"],\"Product &amp; Architecture Documentation \u2014 AGENTS.md\":[\"AGENTS.md\"],\"Product &amp; Architecture Documentation \u2014 CLAUDE.md\":[\"CLAUDE.md\"],\"Product &amp; Architecture Documentation \u2014 DESIGN.md\":[\"DESIGN.md\"],\"Product &amp; Architecture Documentation \u2014 PRODUCT.md\":[\"PRODUCT.md\"],\"Product &amp; Architecture Documentation \u2014 README.md\":[\"README.md\"],\"Product &amp; Architecture Documentation \u2014 Raw\":[\"Raw/ASSUREWORX_BRAND.md\",\"Raw/ASSUREWORX_PRODUCT.md\",\"Raw/assureworx-platform-spec.html\"],\"Product &amp; Architecture Documentation \u2014 backend\":[\"backend/README.md\"],\"Product &amp; Architecture Documentation \u2014 frontend\":[\"frontend/README.md\"],\"Product &amp; Architecture Documentation \u2014 architecture\":[\"wiki/architecture/azure-dev-inventory.md\",\"wiki/architecture/azure-test-inventory.md\",\"wiki/architecture/backend.md\",\"wiki/architecture/cicd-target-operating-model.md\",\"wiki/architecture/code-map.md\",\"wiki/architecture/data-classification-catalogue.md\",\"wiki/architecture/data-model.md\",\"wiki/architecture/document-extraction.md\",\"wiki/architecture/frontend.md\",\"wiki/architecture/full-codebase-audit-2026-07-28.md\",\"wiki/architecture/identity-verification.md\",\"wiki/architecture/implementation-review-2026-07-21.md\",\"wiki/architecture/infrastructure.md\",\"wiki/architecture/integrations.md\",\"wiki/architecture/page-inventory-2026-07-25.md\",\"wiki/architecture/payments-design-2026-08-01.md\",\"wiki/architecture/plan-review-2026-07-20.md\",\"wiki/architecture/platform-audit-2026-07-25.md\",\"wiki/architecture/prod-rendering-report-2026-08-01.md\",\"wiki/architecture/production-readiness-2026-07-21.md\",\"wiki/architecture/production-readiness-qa-audit-2026-08-01.md\",\"wiki/architecture/production-readiness-report-2026-08-01.md\",\"wiki/architecture/qa-audit-plan-2026-08-01.md\",\"wiki/architecture/realtime-verify.md\",\"wiki/architecture/rsc-performance-plan-2026-07-31.md\",\"wiki/architecture/schema-mapping.md\",\"wiki/architecture/security-audit-2026-07-25.md\",\"wiki/architecture/security-engagement-2026-08-04.md\",\"wiki/architecture/security.md\",\"wiki/architecture/stack.md\",\"wiki/architecture/ui-collection-antipattern-audit-2026-07-30.md\",\"wiki/architecture/ux-ui-audit-2026-07-24.md\"],\"Product &amp; Architecture Documentation \u2014 wiki\":[\"wiki/brand.md\",\"wiki/glossary.md\",\"wiki/index.md\",\"wiki/log.md\",\"wiki/overview.md\",\"wiki/ways-of-working.md\"],\"Product &amp; Architecture Documentation \u2014 decisions\":[\"wiki/decisions/0001-full-azure-stack.md\",\"wiki/decisions/0002-postgresql-flexible-server.md\",\"wiki/decisions/0003-repository-layer-tenant-isolation.md\",\"wiki/decisions/0004-pricing-model.md\",\"wiki/decisions/0005-verified-scope-hybrid.md\",\"wiki/decisions/0006-email-azure-communication-services.md\",\"wiki/decisions/0007-obfuscated-physical-schema.md\",\"wiki/decisions/0008-bff-proxy-session-auth.md\",\"wiki/decisions/0009-realtime-verification-orchestration.md\",\"wiki/decisions/0010-pc-cold-start-admin-provisioned.md\",\"wiki/decisions/0011-in-app-support-desk.md\",\"wiki/decisions/0012-assureworx-id-and-identity-resolution.md\",\"wiki/decisions/0013-login-email-change.md\",\"wiki/decisions/0014-didit-identity-verification.md\",\"wiki/decisions/0015-identity-reverification-triggers.md\",\"wiki/decisions/0016-derived-job-state.md\",\"wiki/decisions/0017-employee-deregistration.md\",\"wiki/decisions/0018-engagement-scoped-requirements.md\",\"wiki/decisions/0019-document-extraction-over-registry-verification.md\",\"wiki/decisions/0020-azure-document-intelligence-tier-3.md\",\"wiki/decisions/0021-sms-deferred-entra-acs-email.md\",\"wiki/decisions/0022-invitation-verification-template-snapshots.md\",\"wiki/decisions/0023-pay-per-verification.md\",\"wiki/decisions/0024-payment-provider-boundary.md\",\"wiki/decisions/0025-native-auth-bff-proxy.md\",\"wiki/decisions/0026-identity-check-diagnostics.md\",\"wiki/decisions/0027-data-retention-and-public-legal.md\",\"wiki/decisions/0028-transactional-sms-twilio.md\",\"wiki/decisions/0029-employer-sponsored-bulk-verification.md\",\"wiki/decisions/0030-employer-employee-check-selection.md\",\"wiki/decisions/0031-refund-reconciliation-schedule.md\",\"wiki/decisions/0032-di-auto-accept-and-pc-document-visibility.md\",\"wiki/decisions/index.md\",\"wiki/decisions/open-questions.md\"],\"Product &amp; Architecture Documentation \u2014 product\":[\"wiki/product/account-and-invite-journeys.md\",\"wiki/product/billing.md\",\"wiki/product/compliance-and-verification.md\",\"wiki/product/employee-verification-journey.md\",\"wiki/product/employer-pays-for-crew.md\",\"wiki/product/engagement-requirements.md\",\"wiki/product/entity-hierarchy.md\",\"wiki/product/identity-and-portability.md\",\"wiki/product/modules.md\",\"wiki/product/onboarding-and-request-chain.md\",\"wiki/product/pc-create-project-journey.md\",\"wiki/product/pc-project-request-journey.md\",\"wiki/product/roles-and-permissions.md\",\"wiki/product/trade-classifications.md\"],\"Product &amp; Architecture Documentation \u2014 runbooks\":[\"wiki/runbooks/acs-email-dev.md\",\"wiki/runbooks/azure-dev-deploy.md\",\"wiki/runbooks/azure-test-deploy.md\",\"wiki/runbooks/ciam-company-branding.md\",\"wiki/runbooks/dev-custom-domains.md\",\"wiki/runbooks/didit-retention-30-days.md\",\"wiki/runbooks/entra-external-id-dev.md\",\"wiki/runbooks/entra-monitoring-and-cost-dev.md\",\"wiki/runbooks/privacy-counsel-brief.md\",\"wiki/runbooks/redis-and-payment-reliability.md\",\"wiki/runbooks/reset-test-identity.md\",\"wiki/runbooks/stripe-setup.md\",\"wiki/runbooks/vscode-postgres-setup.md\",\"wiki/runbooks/wallet-passes.md\"],\"Product &amp; Architecture Documentation \u2014 sources\":[\"wiki/sources/assureworx-brand-guidelines.md\",\"wiki/sources/assureworx-platform-spec.md\",\"wiki/sources/assureworx-product-definition.md\"],\"Security &amp; Compliance Documentation\":[\"docs/legal/Assureworx-Privacy-Statement-2026-08-04.html\",\"docs/legal/Assureworx-Terms-of-Use-2026-08-04.html\",\"docs/legal/generate-director-pdfs.mjs\",\"docs/legal/print-styles.css\",\"frontend/src/app/(legal)/LegalNav.tsx\",\"frontend/src/app/(legal)/layout.tsx\",\"frontend/src/app/(legal)/legal.module.css\",\"frontend/src/app/(legal)/privacy/page.tsx\",\"frontend/src/app/(legal)/terms/page.tsx\",\"frontend/src/components/legal/LegalLinks.module.css\",\"frontend/src/components/legal/LegalLinks.tsx\",\"isms/AGENTS.md\",\"isms/Dashboard.md\",\"isms/README_OBSIDIAN.md\",\"isms/data-inventory.md\",\"isms/didit-posture.md\",\"isms/doc-intel-posture.md\",\"isms/environments.md\",\"isms/evidence-register.md\",\"isms/gap-analysis.md\",\"isms/getting-started.md\",\"isms/index.md\",\"isms/log.md\",\"isms/overview.md\",\"isms/risk-register.md\",\"isms/scope.md\",\"isms/soa.md\",\"isms/soc2-mapping.md\",\"isms/stripe-posture.md\",\"isms/archive/README.md\",\"isms/assurance/control-truth-register.json\",\"isms/assurance/index.md\",\"isms/evidence/README.md\",\"isms/exports/README.md\",\"isms/governance/approval-record.md\",\"isms/governance/document-control-and-approval-procedure.md\",\"isms/governance/document-register.md\",\"isms/governance/factual-assurance-procedure.md\",\"isms/governance/shareholder-brief.md\",\"isms/plans/01-governance.md\",\"isms/plans/02-access-control.md\",\"isms/plans/03-change-management.md\",\"isms/plans/04-environments.md\",\"isms/plans/05-logging-monitoring.md\",\"isms/plans/06-data-protection.md\",\"isms/plans/07-supplier-management.md\",\"isms/plans/08-resilience.md\",\"isms/plans/09-people.md\",\"isms/plans/10-audit-evidence.md\",\"isms/plans/index.md\",\"isms/policies/alert-triage.md\",\"isms/policies/data-classification-and-handling-policy.md\",\"isms/policies/index.md\",\"isms/policies/information-security-policy.md\",\"isms/templates/confidentiality-and-non-disclosure-agreement.md\",\"isms/templates/index.md\",\"isms/templates/personnel-offboarding-checklist.md\",\"isms/templates/personnel-onboarding-checklist.md\",\"isms/templates/policy-acknowledgement-register.md\",\"isms/templates/policy-acknowledgement.md\"],\"Security &amp; Compliance Documentation \u2014 legal\":[\"docs/legal/Assureworx-Privacy-Statement-2026-08-04.html\",\"docs/legal/Assureworx-Terms-of-Use-2026-08-04.html\",\"docs/legal/generate-director-pdfs.mjs\",\"docs/legal/print-styles.css\"],\"Security &amp; Compliance Documentation \u2014 src\":[\"frontend/src/app/(legal)/LegalNav.tsx\",\"frontend/src/app/(legal)/layout.tsx\",\"frontend/src/app/(legal)/legal.module.css\",\"frontend/src/app/(legal)/privacy/page.tsx\",\"frontend/src/app/(legal)/terms/page.tsx\",\"frontend/src/components/legal/LegalLinks.module.css\",\"frontend/src/components/legal/LegalLinks.tsx\"],\"Security &amp; Compliance Documentation \u2014 isms\":[\"isms/AGENTS.md\",\"isms/Dashboard.md\",\"isms/README_OBSIDIAN.md\",\"isms/data-inventory.md\",\"isms/didit-posture.md\",\"isms/doc-intel-posture.md\",\"isms/environments.md\",\"isms/evidence-register.md\",\"isms/gap-analysis.md\",\"isms/getting-started.md\",\"isms/index.md\",\"isms/log.md\",\"isms/overview.md\",\"isms/risk-register.md\",\"isms/scope.md\",\"isms/soa.md\",\"isms/soc2-mapping.md\",\"isms/stripe-posture.md\"],\"Security &amp; Compliance Documentation \u2014 archive\":[\"isms/archive/README.md\"],\"Security &amp; Compliance Documentation \u2014 assurance\":[\"isms/assurance/control-truth-register.json\",\"isms/assurance/index.md\"],\"Security &amp; Compliance Documentation \u2014 evidence\":[\"isms/evidence/README.md\"],\"Security &amp; Compliance Documentation \u2014 exports\":[\"isms/exports/README.md\"],\"Security &amp; Compliance Documentation \u2014 governance\":[\"isms/governance/approval-record.md\",\"isms/governance/document-control-and-approval-procedure.md\",\"isms/governance/document-register.md\",\"isms/governance/factual-assurance-procedure.md\",\"isms/governance/shareholder-brief.md\"],\"Security &amp; Compliance Documentation \u2014 plans\":[\"isms/plans/01-governance.md\",\"isms/plans/02-access-control.md\",\"isms/plans/03-change-management.md\",\"isms/plans/04-environments.md\",\"isms/plans/05-logging-monitoring.md\",\"isms/plans/06-data-protection.md\",\"isms/plans/07-supplier-management.md\",\"isms/plans/08-resilience.md\",\"isms/plans/09-people.md\",\"isms/plans/10-audit-evidence.md\",\"isms/plans/index.md\"],\"Security &amp; Compliance Documentation \u2014 policies\":[\"isms/policies/alert-triage.md\",\"isms/policies/data-classification-and-handling-policy.md\",\"isms/policies/index.md\",\"isms/policies/information-security-policy.md\"],\"Security &amp; Compliance Documentation \u2014 templates\":[\"isms/templates/confidentiality-and-non-disclosure-agreement.md\",\"isms/templates/index.md\",\"isms/templates/personnel-offboarding-checklist.md\",\"isms/templates/personnel-onboarding-checklist.md\",\"isms/templates/policy-acknowledgement-register.md\",\"isms/templates/policy-acknowledgement.md\"],\"Legacy &amp; Experimental Artifacts\":[\"LLoyd/invite.module.css\",\"LLoyd/page 2 (1).tsx\",\"LLoyd/page 2.tsx\",\"LLoyd/page 3.tsx\",\"LLoyd/page 4.tsx\",\"LLoyd/requests.module.css\",\"Untitled.base\"],\"Other\":[\"backend/src/modules/memberships/employee-checks.dbtest.ts\"]},\"moduleTree\":[{\"name\":\"Authentication &amp; Session Management\",\"slug\":\"authentication-session-management\",\"files\":[],\"children\":[{\"name\":\"Authentication &amp; Session Management \u2014 backend-src\",\"slug\":\"authentication-session-management-backend-src\",\"files\":[\"backend/src/modules/auth/index.ts\",\"backend/src/modules/auth/otp-email.ts\",\"backend/src/shared/auth/dev-auth-gate.ts\",\"backend/src/shared/auth/ensure-trade-profile.ts\",\"backend/src/shared/auth/entra.ts\",\"backend/src/shared/auth/graph-email.ts\",\"backend/src/shared/auth/link-actor.ts\",\"backend/src/shared/auth/native-auth.ts\",\"backend/src/shared/auth/otp-callback-auth.test.ts\",\"backend/src/shared/auth/otp-callback-auth.ts\",\"backend/src/shared/auth/session.ts\",\"backend/src/shared/middleware/require-auth.ts\",\"backend/src/shared/services/email-change.ts\"]},{\"name\":\"Authentication &amp; Session Management \u2014 frontend-src\",\"slug\":\"authentication-session-management-frontend-src\",\"files\":[\"frontend/src/app/auth/callback/AuthCallbackClient.tsx\",\"frontend/src/app/auth/callback/page.tsx\",\"frontend/src/app/auth/created/CreatedClient.tsx\",\"frontend/src/app/auth/created/page.tsx\",\"frontend/src/app/auth/credentials/CredentialsClient.tsx\",\"frontend/src/app/auth/credentials/page.tsx\",\"frontend/src/app/auth/email-change/[kind]/EmailChangeClient.tsx\",\"frontend/src/app/auth/email-change/[kind]/confirm.module.css\",\"frontend/src/app/auth/email-change/[kind]/page.tsx\",\"frontend/src/app/auth/invite/[token]/InviteClient.tsx\",\"frontend/src/app/auth/invite/[token]/invite.module.css\",\"frontend/src/app/auth/invite/[token]/page.tsx\",\"frontend/src/app/auth/login/LoginClient.tsx\",\"frontend/src/app/auth/login/login.module.css\",\"frontend/src/app/auth/login/page.tsx\",\"frontend/src/app/auth/register/RegisterClient.tsx\",\"frontend/src/app/auth/register/page.tsx\",\"frontend/src/app/auth/register/register.module.css\",\"frontend/src/app/auth/reset/ResetClient.tsx\",\"frontend/src/app/auth/reset/new-password/NewPasswordClient.tsx\",\"frontend/src/app/auth/reset/new-password/page.tsx\",\"frontend/src/app/auth/reset/page.tsx\",\"frontend/src/app/auth/verify-code/VerifyCodeClient.tsx\",\"frontend/src/app/auth/verify-code/page.tsx\",\"frontend/src/components/auth/AuthBrandShell.tsx\",\"frontend/src/components/auth/LoginReveal.module.css\",\"frontend/src/components/auth/LoginReveal.tsx\",\"frontend/src/lib/credential-setup.ts\",\"frontend/src/lib/entra.ts\",\"frontend/src/lib/login-reveal.ts\",\"frontend/src/lib/safe-return-to.ts\",\"frontend/src/lib/server-session.ts\",\"frontend/src/lib/session-core.ts\",\"frontend/src/lib/session.module.css\",\"frontend/src/lib/session.tsx\",\"frontend/src/middleware.ts\"]}]},{\"name\":\"Identity Verification &amp; Onboarding\",\"slug\":\"identity-verification-onboarding\",\"files\":[],\"children\":[{\"name\":\"Identity Verification &amp; Onboarding \u2014 backend-src\",\"slug\":\"identity-verification-onboarding-backend-src\",\"files\":[\"backend/src/modules/identity/consent-version.test.ts\",\"backend/src/modules/identity/consent.ts\",\"backend/src/modules/identity/didit-boundary.test.ts\",\"backend/src/modules/identity/didit.ts\",\"backend/src/modules/identity/index.ts\",\"backend/src/modules/identity/reconcile.ts\",\"backend/src/modules/identity/scheduler.ts\",\"backend/src/modules/identity/service.ts\",\"backend/src/modules/identity/types.ts\",\"backend/src/modules/onboarding/index.ts\",\"backend/src/modules/verification/index.ts\",\"backend/src/shared/services/identity-check.ts\",\"backend/src/shared/services/identity-consent.ts\",\"backend/src/shared/services/identity-currency.test.ts\",\"backend/src/shared/services/identity.ts\",\"backend/src/shared/services/image-bounds.ts\",\"backend/src/shared/services/verification-template.ts\",\"backend/src/shared/services/verified-status.ts\"]},{\"name\":\"Identity Verification &amp; Onboarding \u2014 frontend-src\",\"slug\":\"identity-verification-onboarding-frontend-src\",\"files\":[\"frontend/src/app/(app)/profile/ProfileClient.tsx\",\"frontend/src/app/(app)/profile/profile.module.css\",\"frontend/src/app/(app)/profile/page.tsx\",\"frontend/src/app/(app)/profile/documents/DocumentsClient.tsx\",\"frontend/src/app/(app)/profile/documents/documents.module.css\",\"frontend/src/app/(app)/profile/documents/page.tsx\",\"frontend/src/app/onboarding/abn/AbnClient.tsx\",\"frontend/src/app/onboarding/abn/abn.module.css\",\"frontend/src/app/onboarding/abn/page.tsx\",\"frontend/src/app/onboarding/documents/DocumentsClient.tsx\",\"frontend/src/app/onboarding/documents/documents.module.css\",\"frontend/src/app/onboarding/documents/page.tsx\",\"frontend/src/app/onboarding/identity/IdentityClient.tsx\",\"frontend/src/app/onboarding/identity/identity.module.css\",\"frontend/src/app/onboarding/identity/page.tsx\",\"frontend/src/app/onboarding/invite/[token]/OnboardingInviteClient.tsx\",\"frontend/src/app/onboarding/invite/[token]/invite.module.css\",\"frontend/src/app/onboarding/invite/[token]/page.tsx\",\"frontend/src/app/onboarding/layout.tsx\",\"frontend/src/app/onboarding/review/ReviewClient.tsx\",\"frontend/src/app/onboarding/review/review.module.css\",\"frontend/src/app/onboarding/review/page.tsx\",\"frontend/src/app/onboarding/trade-types/TradeTypesClient.tsx\",\"frontend/src/app/onboarding/trade-types/trade-types.module.css\",\"frontend/src/app/onboarding/trade-types/page.tsx\",\"frontend/src/app/verify/VerifyLandingClient.tsx\",\"frontend/src/app/verify/abr/VerifyAbrClient.tsx\",\"frontend/src/app/verify/abr/page.tsx\",\"frontend/src/app/verify/certificates/VerifyCertificatesClient.tsx\",\"frontend/src/app/verify/certificates/page.tsx\",\"frontend/src/app/verify/checks/VerifyChecksClient.tsx\",\"frontend/src/app/verify/checks/checks.module.css\",\"frontend/src/app/verify/checks/page.tsx\",\"frontend/src/app/verify/details/VerifyDetailsClient.tsx\",\"frontend/src/app/verify/details/details.module.css\",\"frontend/src/app/verify/details/page.tsx\",\"frontend/src/app/verify/i/[token]/VerifyInviteClient.tsx\",\"frontend/src/app/verify/i/[token]/invited.module.css\",\"frontend/src/app/verify/i/[token]/page.tsx\",\"frontend/src/app/verify/journey/VerifyJourneyClient.tsx\",\"frontend/src/app/verify/journey/journey.module.css\",\"frontend/src/app/verify/journey/page.tsx\",\"frontend/src/app/verify/layout.tsx\",\"frontend/src/app/verify/page.tsx\",\"frontend/src/app/verify/pay/VerifyPayClient.tsx\",\"frontend/src/app/verify/pay/pay.module.css\",\"frontend/src/app/verify/pay/page.tsx\",\"frontend/src/app/verify/photo/VerifyPhotoClient.tsx\",\"frontend/src/app/verify/photo/photo.module.css\",\"frontend/src/app/verify/photo/page.tsx\",\"frontend/src/app/verify/scan/VerifyScanClient.tsx\",\"frontend/src/app/verify/scan/scan.module.css\",\"frontend/src/app/verify/scan/page.tsx\",\"frontend/src/app/verify/staff/VerifyStaffClient.tsx\",\"frontend/src/app/verify/staff/staff.module.css\",\"frontend/src/app/verify/staff/page.tsx\",\"frontend/src/app/verify/trade/VerifyTradeClient.tsx\",\"frontend/src/app/verify/trade/page.tsx\",\"frontend/src/app/verify/verify.module.css\",\"frontend/src/components/domain/IdentityPanel.module.css\",\"frontend/src/components/domain/IdentityPanel.tsx\",\"frontend/src/components/domain/ProfileHeader.module.css\",\"frontend/src/components/domain/ProfileHeader.tsx\",\"frontend/src/components/domain/ProfileSheet.module.css\",\"frontend/src/components/domain/ProfileSheet.tsx\",\"frontend/src/components/domain/VerifiedSplit.module.css\",\"frontend/src/components/domain/VerifiedSplit.tsx\",\"frontend/src/components/onboarding/WizardShell.module.css\",\"frontend/src/components/onboarding/WizardShell.tsx\",\"frontend/src/components/verify/IdentityHandoff.module.css\",\"frontend/src/components/verify/IdentityHandoff.tsx\",\"frontend/src/components/verify/VerifyEvidenceStep.module.css\",\"frontend/src/components/verify/VerifyEvidenceStep.tsx\",\"frontend/src/components/verify/VerifyShell.module.css\",\"frontend/src/components/verify/VerifyShell.tsx\",\"frontend/src/lib/identity.ts\",\"frontend/src/lib/onboarding-errors.ts\",\"frontend/src/lib/use-onboarding-draft-owner.ts\",\"frontend/src/lib/verify-demo.ts\"]}]},{\"name\":\"Evidence, Checks &amp; Compliance\",\"slug\":\"evidence-checks-compliance\",\"files\":[],\"children\":[{\"name\":\"Evidence, Checks &amp; Compliance \u2014 backend-src\",\"slug\":\"evidence-checks-compliance-backend-src\",\"files\":[\"backend/src/modules/cases/index.ts\",\"backend/src/modules/checks/index.ts\",\"backend/src/shared/services/artifact-storage.ts\",\"backend/src/shared/services/checklist.ts\",\"backend/src/shared/services/evidence-action-reason.test.ts\",\"backend/src/shared/services/evidence.ts\",\"backend/src/shared/services/extraction/cloud-read.ts\",\"backend/src/shared/services/extraction/decide-status.test.ts\",\"backend/src/shared/services/extraction/decide-status.ts\",\"backend/src/shared/services/extraction/index.ts\",\"backend/src/shared/services/extraction/parse.ts\",\"backend/src/shared/services/extraction/preprocess.test.ts\",\"backend/src/shared/services/extraction/preprocess.ts\",\"backend/src/shared/services/extraction/present.ts\",\"backend/src/shared/services/extraction/preview-cache.ts\",\"backend/src/shared/services/extraction/rules.ts\",\"backend/src/shared/services/extraction/sources.ts\",\"backend/src/shared/services/extraction/types.ts\",\"backend/src/shared/services/extraction/validate.ts\",\"backend/src/shared/services/requirements.ts\"]},{\"name\":\"Evidence, Checks &amp; Compliance \u2014 frontend-src\",\"slug\":\"evidence-checks-compliance-frontend-src\",\"files\":[\"frontend/src/components/domain/CoreChecks.module.css\",\"frontend/src/components/domain/CoreChecks.tsx\",\"frontend/src/components/domain/DocumentRead.module.css\",\"frontend/src/components/domain/DocumentRead.tsx\",\"frontend/src/components/domain/DocumentScanOverlay.module.css\",\"frontend/src/components/domain/DocumentScanOverlay.tsx\",\"frontend/src/components/domain/EvidenceBoard.module.css\",\"frontend/src/components/domain/EvidenceBoard.tsx\",\"frontend/src/components/domain/EvidenceMeter.module.css\",\"frontend/src/components/domain/EvidenceMeter.tsx\",\"frontend/src/components/domain/EvidenceTable.module.css\",\"frontend/src/components/domain/EvidenceTable.tsx\",\"frontend/src/components/domain/EvidenceUploadForm.module.css\",\"frontend/src/components/domain/EvidenceUploadForm.tsx\",\"frontend/src/components/domain/StatusDonut.module.css\",\"frontend/src/components/domain/StatusDonut.tsx\",\"frontend/src/lib/check-labels.ts\",\"frontend/src/lib/evidence-groups.ts\",\"frontend/src/lib/use-extraction.ts\"]}]},{\"name\":\"Retention &amp; Data Governance\",\"slug\":\"retention-data-governance\",\"files\":[\"backend/src/modules/retention/closure.ts\",\"backend/src/modules/retention/erase.ts\",\"backend/src/modules/retention/retention.test.ts\",\"backend/src/modules/retention/scheduler.ts\",\"backend/src/modules/retention/sweep.ts\"]},{\"name\":\"Payments &amp; Billing\",\"slug\":\"payments-billing\",\"files\":[],\"children\":[{\"name\":\"Payments &amp; Billing \u2014 backend-src\",\"slug\":\"payments-billing-backend-src\",\"files\":[\"backend/src/modules/billing/index.ts\",\"backend/src/modules/payments/attempts.ts\",\"backend/src/modules/payments/boot.test.ts\",\"backend/src/modules/payments/bulk.dbtest.ts\",\"backend/src/modules/payments/cases.ts\",\"backend/src/modules/payments/checkout.ts\",\"backend/src/modules/payments/eligibility.ts\",\"backend/src/modules/payments/flow.dbtest.ts\",\"backend/src/modules/payments/inbox.ts\",\"backend/src/modules/payments/index.ts\",\"backend/src/modules/payments/ledger.ts\",\"backend/src/modules/payments/orders.ts\",\"backend/src/modules/payments/pricing.test.ts\",\"backend/src/modules/payments/pricing.ts\",\"backend/src/modules/payments/process-event.ts\",\"backend/src/modules/payments/provider/fake.ts\",\"backend/src/modules/payments/provider/index.ts\",\"backend/src/modules/payments/provider/provider.test.ts\",\"backend/src/modules/payments/provider/stripe.ts\",\"backend/src/modules/payments/provider/types.ts\",\"backend/src/modules/payments/reconcile.ts\",\"backend/src/modules/payments/refund.dbtest.ts\",\"backend/src/modules/payments/refunds.ts\",\"backend/src/modules/payments/reliability.test.ts\",\"backend/src/modules/payments/scheduler.ts\",\"backend/src/modules/payments/schema.dbtest.ts\",\"backend/src/modules/payments/state.test.ts\",\"backend/src/modules/payments/state.ts\",\"backend/src/modules/payments/thank-you-email.ts\",\"backend/src/modules/payments/views.ts\",\"backend/src/modules/payments/webhook.ts\",\"backend/src/shared/services/meter.ts\"]},{\"name\":\"Payments &amp; Billing \u2014 frontend-src\",\"slug\":\"payments-billing-frontend-src\",\"files\":[\"frontend/src/app/(app)/billing/BillingClient.tsx\",\"frontend/src/app/(app)/billing/billing.module.css\",\"frontend/src/app/(app)/billing/page.tsx\",\"frontend/src/app/(app)/payments/cancelled/page.tsx\",\"frontend/src/app/(app)/payments/return/page.tsx\",\"frontend/src/components/domain/FundingCallout.module.css\",\"frontend/src/components/domain/FundingCallout.tsx\"]}]},{\"name\":\"Wallet &amp; Digital Passes\",\"slug\":\"wallet-digital-passes\",\"files\":[],\"children\":[{\"name\":\"Wallet &amp; Digital Passes \u2014 backend-src\",\"slug\":\"wallet-digital-passes-backend-src\",\"files\":[\"backend/src/modules/wallet/apple-webservice.ts\",\"backend/src/modules/wallet/apple.ts\",\"backend/src/modules/wallet/config.ts\",\"backend/src/modules/wallet/gate.ts\",\"backend/src/modules/wallet/generate-assets.mjs\",\"backend/src/modules/wallet/generate-google-assets.mjs\",\"backend/src/modules/wallet/google.ts\",\"backend/src/modules/wallet/index.ts\",\"backend/src/modules/wallet/pass-id.ts\",\"backend/src/modules/wallet/pass-store.ts\",\"backend/src/modules/wallet/subject.ts\"]},{\"name\":\"Wallet &amp; Digital Passes \u2014 frontend-src\",\"slug\":\"wallet-digital-passes-frontend-src\",\"files\":[\"frontend/src/app/verify/pass/VerifyPassClient.tsx\",\"frontend/src/app/verify/pass/pass.module.css\",\"frontend/src/app/verify/pass/page.tsx\",\"frontend/src/components/pass/DigitalPass.module.css\",\"frontend/src/components/pass/DigitalPass.tsx\",\"frontend/src/components/pass/FaceCapture.module.css\",\"frontend/src/components/pass/FaceCapture.tsx\",\"frontend/src/components/pass/PassOverlay.module.css\",\"frontend/src/components/pass/PassOverlay.tsx\",\"frontend/src/components/pass/PassPhotoPanel.module.css\",\"frontend/src/components/pass/PassPhotoPanel.tsx\",\"frontend/src/components/pass/face-coach.ts\",\"frontend/src/components/pass/index.ts\",\"frontend/src/components/pass/pass-view.ts\",\"frontend/src/components/pass/use-pass.ts\"]}]},{\"name\":\"Projects, Sites &amp; Requests\",\"slug\":\"projects-sites-requests\",\"files\":[],\"children\":[{\"name\":\"Projects, Sites &amp; Requests \u2014 backend-src\",\"slug\":\"projects-sites-requests-backend-src\",\"files\":[\"backend/src/modules/nodes/index.ts\",\"backend/src/modules/requests/index.ts\",\"backend/src/shared/services/geocode.ts\"]},{\"name\":\"Projects, Sites &amp; Requests \u2014 frontend-public\",\"slug\":\"projects-sites-requests-frontend-public\",\"files\":[\"frontend/public/leaflet/leaflet.css\"]},{\"name\":\"Projects, Sites &amp; Requests \u2014 frontend-src\",\"slug\":\"projects-sites-requests-frontend-src\",\"files\":[\"frontend/src/app/(app)/projects/CreateProjectWizard.module.css\",\"frontend/src/app/(app)/projects/CreateProjectWizard.tsx\",\"frontend/src/app/(app)/projects/ProjectsClient.tsx\",\"frontend/src/app/(app)/projects/[id]/ProjectDetailClient.tsx\",\"frontend/src/app/(app)/projects/[id]/ProjectInviteComposer.tsx\",\"frontend/src/app/(app)/projects/[id]/page.tsx\",\"frontend/src/app/(app)/projects/[id]/project-detail.module.css\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/SiteDetailClient.tsx\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/page.tsx\",\"frontend/src/app/(app)/projects/[id]/sites/[sid]/site.module.css\",\"frontend/src/app/(app)/projects/page.tsx\",\"frontend/src/app/(app)/projects/projects.module.css\",\"frontend/src/app/(app)/requests/RequestsClient.tsx\",\"frontend/src/app/(app)/requests/page.tsx\",\"frontend/src/app/(app)/requests/requests.module.css\",\"frontend/src/components/domain/SiteMapBoard.module.css\",\"frontend/src/components/domain/SiteMapBoard.tsx\",\"frontend/src/components/domain/SiteMapBoardLazy.tsx\",\"frontend/src/lib/geo.ts\",\"frontend/src/lib/project-wizard-drafts.ts\"]}]},{\"name\":\"Workforce &amp; Team Management\",\"slug\":\"workforce-team-management\",\"files\":[],\"children\":[{\"name\":\"Workforce &amp; Team Management \u2014 backend-src\",\"slug\":\"workforce-team-management-backend-src\",\"files\":[\"backend/src/modules/memberships/index.ts\",\"backend/src/modules/training/index.ts\",\"backend/src/shared/services/employee-placement.ts\"]},{\"name\":\"Workforce &amp; Team Management \u2014 frontend-src\",\"slug\":\"workforce-team-management-frontend-src\",\"files\":[\"frontend/src/app/(app)/jobs/JobsClient.tsx\",\"frontend/src/app/(app)/jobs/jobs.module.css\",\"frontend/src/app/(app)/jobs/page.tsx\",\"frontend/src/app/(app)/team/EmployeeChecksPanel.tsx\",\"frontend/src/app/(app)/team/TeamClient.tsx\",\"frontend/src/app/(app)/team/page.tsx\",\"frontend/src/app/(app)/team/team.module.css\",\"frontend/src/app/(app)/training/page.tsx\",\"frontend/src/components/domain/EngagementList.module.css\",\"frontend/src/components/domain/EngagementList.tsx\",\"frontend/src/components/domain/SiteWorkforceManager.module.css\",\"frontend/src/components/domain/SiteWorkforceManager.tsx\",\"frontend/src/components/domain/WorkChecksBoard.module.css\",\"frontend/src/components/domain/WorkChecksBoard.tsx\",\"frontend/src/components/domain/WorkforceMetricRow.module.css\",\"frontend/src/components/domain/WorkforceMetricRow.tsx\",\"frontend/src/lib/flush-pending-staff.ts\"]}]},{\"name\":\"Trades &amp; Company Directory\",\"slug\":\"trades-company-directory\",\"files\":[],\"children\":[{\"name\":\"Trades &amp; Company Directory \u2014 backend-src\",\"slug\":\"trades-company-directory-backend-src\",\"files\":[\"backend/src/modules/directory/index.ts\",\"backend/src/modules/links/index.ts\",\"backend/src/modules/parties/index.ts\",\"backend/src/shared/services/abn-verifier.ts\",\"backend/src/shared/services/network.ts\",\"backend/src/shared/services/org-admin.ts\"]},{\"name\":\"Trades &amp; Company Directory \u2014 frontend-src\",\"slug\":\"trades-company-directory-frontend-src\",\"files\":[\"frontend/src/app/(app)/trades/TradesClient.tsx\",\"frontend/src/app/(app)/trades/[id]/CompanyProfileClient.tsx\",\"frontend/src/app/(app)/trades/[id]/page.tsx\",\"frontend/src/app/(app)/trades/individuals/[id]/IndividualProfileClient.tsx\",\"frontend/src/app/(app)/trades/individuals/[id]/page.tsx\",\"frontend/src/app/(app)/trades/page.tsx\",\"frontend/src/app/(app)/trades/trades.module.css\",\"frontend/src/components/domain/LineageSummary.module.css\",\"frontend/src/components/domain/LineageSummary.tsx\",\"frontend/src/components/domain/LineageTree.module.css\",\"frontend/src/components/domain/LineageTree.tsx\",\"frontend/src/components/domain/TradeTypeIcon.tsx\",\"frontend/src/components/domain/TradeTypeList.module.css\",\"frontend/src/components/domain/TradeTypeList.tsx\",\"frontend/src/lib/trade-pack.ts\"]}]},{\"name\":\"Notifications &amp; Communication\",\"slug\":\"notifications-communication\",\"files\":[\"backend/src/modules/email/webhook.ts\",\"backend/src/modules/notifications/index.ts\",\"backend/src/modules/sms/index.ts\",\"backend/src/modules/sms/scheduler.ts\",\"backend/src/modules/sms/webhook.ts\",\"backend/src/shared/services/email-brand.test.ts\",\"backend/src/shared/services/email-brand.ts\",\"backend/src/shared/services/email-delivery.ts\",\"backend/src/shared/services/email.ts\",\"backend/src/shared/services/invite-resend.test.ts\",\"backend/src/shared/services/invite-resend.ts\",\"backend/src/shared/services/sms/config.test.ts\",\"backend/src/shared/services/sms/index.ts\",\"backend/src/shared/services/sms/notify.ts\",\"backend/src/shared/services/sms/outbox.ts\",\"backend/src/shared/services/sms/phone.test.ts\",\"backend/src/shared/services/sms/phone.ts\",\"backend/src/shared/services/sms/provider.test.ts\",\"backend/src/shared/services/sms/provider.ts\",\"backend/src/shared/services/sms/queue.ts\",\"backend/src/shared/services/sms/reliability.test.ts\",\"backend/src/shared/services/sms/templates.test.ts\",\"backend/src/shared/services/sms/templates.ts\",\"frontend/src/lib/notification-href.ts\"]},{\"name\":\"Admin, Support &amp; Reporting\",\"slug\":\"admin-support-reporting\",\"files\":[],\"children\":[{\"name\":\"Admin, Support &amp; Reporting \u2014 backend-src\",\"slug\":\"admin-support-reporting-backend-src\",\"files\":[\"backend/src/modules/admin/index.ts\",\"backend/src/modules/assurance/scheduler.ts\",\"backend/src/modules/assurance/sweep.test.ts\",\"backend/src/modules/assurance/sweep.ts\",\"backend/src/modules/reporting/index.ts\",\"backend/src/modules/support/index.ts\",\"backend/src/modules/support/sla.ts\",\"backend/src/shared/services/audit-context.ts\",\"backend/src/shared/services/audit-query.test.ts\",\"backend/src/shared/services/audit-query.ts\",\"backend/src/shared/services/audit.ts\",\"backend/src/shared/services/schema-erd.ts\"]},{\"name\":\"Admin, Support &amp; Reporting \u2014 frontend-src\",\"slug\":\"admin-support-reporting-frontend-src\",\"files\":[\"frontend/src/app/(app)/admin/AdminClient.tsx\",\"frontend/src/app/(app)/admin/admin.module.css\",\"frontend/src/app/(app)/admin/page.tsx\",\"frontend/src/app/(app)/reports/page.tsx\",\"frontend/src/app/(app)/settings/SettingsClient.tsx\",\"frontend/src/app/(app)/settings/account/AccountClient.tsx\",\"frontend/src/app/(app)/settings/account/account.module.css\",\"frontend/src/app/(app)/settings/account/page.tsx\",\"frontend/src/app/(app)/settings/page.tsx\",\"frontend/src/app/(app)/settings/settings.module.css\",\"frontend/src/app/(app)/support/SupportClient.tsx\",\"frontend/src/app/(app)/support/page.tsx\",\"frontend/src/app/(app)/support/support.module.css\",\"frontend/src/components/admin/AzureLineageStudio.module.css\",\"frontend/src/components/admin/AzureLineageStudio.tsx\",\"frontend/src/components/admin/PaymentsConsole.tsx\",\"frontend/src/components/admin/SchemaErdStudio.module.css\",\"frontend/src/components/admin/SchemaErdStudio.tsx\",\"frontend/src/components/admin/azureLineageData.ts\"]}]},{\"name\":\"UI Components &amp; App Shell\",\"slug\":\"ui-components-app-shell\",\"files\":[\"frontend/src/app/(app)/dashboard/DashboardClient.tsx\",\"frontend/src/app/(app)/dashboard/dashboard.module.css\",\"frontend/src/app/(app)/dashboard/page.tsx\",\"frontend/src/app/(app)/error.tsx\",\"frontend/src/app/(app)/layout.tsx\",\"frontend/src/app/(app)/loading.tsx\",\"frontend/src/app/globals.css\",\"frontend/src/app/layout.tsx\",\"frontend/src/app/page.tsx\",\"frontend/src/components/shell/ActivityRail.module.css\",\"frontend/src/components/shell/ActivityRail.tsx\",\"frontend/src/components/shell/AppShell.module.css\",\"frontend/src/components/shell/AppShell.tsx\",\"frontend/src/components/shell/HistoryNav.tsx\",\"frontend/src/components/shell/NavIcon.tsx\",\"frontend/src/components/shell/Sidebar.module.css\",\"frontend/src/components/shell/Sidebar.tsx\",\"frontend/src/components/shell/TopBar.module.css\",\"frontend/src/components/shell/TopBar.tsx\",\"frontend/src/components/shell/topbar-title-slot.ts\",\"frontend/src/components/ui/Button.module.css\",\"frontend/src/components/ui/Button.tsx\",\"frontend/src/components/ui/ComingSoonPage.tsx\",\"frontend/src/components/ui/DraftStatus.module.css\",\"frontend/src/components/ui/DraftStatus.tsx\",\"frontend/src/components/ui/EmptyState.module.css\",\"frontend/src/components/ui/EmptyState.tsx\",\"frontend/src/components/ui/Field.module.css\",\"frontend/src/components/ui/Field.tsx\",\"frontend/src/components/ui/MetaIcon.tsx\",\"frontend/src/components/ui/Modal.module.css\",\"frontend/src/components/ui/Modal.tsx\",\"frontend/src/components/ui/PageHeader.tsx\",\"frontend/src/components/ui/Panel.module.css\",\"frontend/src/components/ui/Panel.tsx\",\"frontend/src/components/ui/PartyAvatar.module.css\",\"frontend/src/components/ui/PartyAvatar.tsx\",\"frontend/src/components/ui/PartyIdentity.module.css\",\"frontend/src/components/ui/PartyIdentity.tsx\",\"frontend/src/components/ui/Skeleton.module.css\",\"frontend/src/components/ui/Skeleton.tsx\",\"frontend/src/components/ui/StatusChip.module.css\",\"frontend/src/components/ui/StatusChip.tsx\",\"frontend/src/components/ui/Toast.module.css\",\"frontend/src/components/ui/Toast.tsx\",\"frontend/src/lib/copy.ts\",\"frontend/src/lib/format.ts\",\"frontend/src/lib/initials.ts\",\"frontend/src/lib/nav.ts\",\"frontend/src/lib/use-app-history.ts\",\"frontend/src/lib/use-before-unload.ts\",\"frontend/src/lib/use-media-query.ts\",\"frontend/src/lib/use-persistent-draft.ts\",\"frontend/src/lib/use-presence.ts\",\"frontend/src/styles/page.module.css\",\"frontend/src/styles/tokens.css\"]},{\"name\":\"API &amp; Backend Infrastructure\",\"slug\":\"api-backend-infrastructure\",\"files\":[],\"children\":[{\"name\":\"API &amp; Backend Infrastructure \u2014 backend-src\",\"slug\":\"api-backend-infrastructure-backend-src\",\"files\":[\"backend/src/app.ts\",\"backend/src/server.ts\",\"backend/src/seed.ts\",\"backend/src/shared/cache.ts\",\"backend/src/shared/codes.ts\",\"backend/src/shared/config.ts\",\"backend/src/shared/config-dev-auth.test.ts\",\"backend/src/shared/db/pool.ts\",\"backend/src/shared/http/envelope.ts\",\"backend/src/shared/http/errors.ts\",\"backend/src/shared/http/safe-return-to.ts\",\"backend/src/shared/jobs/handlers.ts\",\"backend/src/shared/jobs/queue.test.ts\",\"backend/src/shared/jobs/queue.ts\",\"backend/src/shared/middleware/csrf.ts\",\"backend/src/shared/middleware/rate-limit-policy.test.ts\",\"backend/src/shared/middleware/rate-limit.ts\",\"backend/src/shared/middleware/resolve-tenant-context.ts\",\"backend/src/shared/photo-url.ts\",\"backend/src/shared/redis.ts\",\"backend/src/shared/telemetry.ts\",\"backend/src/shared/services/idempotency.test.ts\",\"backend/src/shared/services/idempotency.ts\"]},{\"name\":\"API &amp; Backend Infrastructure \u2014 frontend-src\",\"slug\":\"api-backend-infrastructure-frontend-src\",\"files\":[\"frontend/src/lib/api.ts\",\"frontend/src/lib/load-app-page.ts\",\"frontend/src/lib/server-api.ts\",\"frontend/src/lib/use-api.ts\"]}]},{\"name\":\"Database Migrations &amp; Schema\",\"slug\":\"database-migrations-schema\",\"files\":[],\"children\":[{\"name\":\"Database Migrations &amp; Schema \u2014 src\",\"slug\":\"database-migrations-schema-src\",\"files\":[\"backend/src/shared/schema/tables.ts\"]},{\"name\":\"Database Migrations &amp; Schema \u2014 migrations\",\"slug\":\"database-migrations-schema-migrations\",\"files\":[\"db/migrations/001_core_orgs_and_people.sql\",\"db/migrations/002_enterprise_core.sql\",\"db/migrations/003_hardening.sql\",\"db/migrations/004_account_invites.sql\",\"db/migrations/005_support_requests.sql\",\"db/migrations/006_identity_portability.sql\",\"db/migrations/007_email_change.sql\",\"db/migrations/008_security_hardening.sql\",\"db/migrations/009_rls_identity_evidence.sql\",\"db/migrations/010_sync_link_readiness.sql\",\"db/migrations/011_identity_consent_and_meter.sql\",\"db/migrations/012_check_sequence.sql\",\"db/migrations/013_check_contact_email.sql\",\"db/migrations/014_rls_remaining_tables.sql\",\"db/migrations/015_trade_engagement_visibility.sql\",\"db/migrations/016_audit_enrichment.sql\",\"db/migrations/017_engagement_requirements.sql\",\"db/migrations/018_meta_pass_dl.sql\",\"db/migrations/019_meta_read_via_definer.sql\",\"db/migrations/020_perf_indexes.sql\",\"db/migrations/021_party_locality_and_seat_attrs.sql\",\"db/migrations/022_invitation_verification_templates.sql\",\"db/migrations/023_verification_cases.sql\",\"db/migrations/024_payment_orders.sql\",\"db/migrations/025_refunds_and_disputes.sql\",\"db/migrations/026_payment_gated_verification.sql\",\"db/migrations/027_pass_dl_hits_and_devices.sql\",\"db/migrations/028_rls_actor_email_meter_idem.sql\",\"db/migrations/029_check_diagnostics.sql\",\"db/migrations/030_retention_and_account_closure.sql\",\"db/migrations/031_rel_attrs_holder_erasure.sql\",\"db/migrations/032_sms_notifications.sql\",\"db/migrations/033_artifact_pc_ack.sql\",\"db/migrations/034_employee_check_templates.sql\",\"db/migrations/035_refund_retry_schedule.sql\",\"db/migrations/036_email_delivery_status.sql\",\"db/migrations/037_seat_token_nullable.sql\",\"db/migrations/038_abn_identity_not_upload_universal.sql\",\"db/migrations/039_identity_consent_currency_fn.sql\",\"db/migrations/040_identity_admin_party_ids_fn.sql\"]},{\"name\":\"Database Migrations &amp; Schema \u2014 shared-types\",\"slug\":\"database-migrations-schema-shared-types\",\"files\":[\"packages/shared-types/src/index.ts\"]}]},{\"name\":\"Infrastructure &amp; Deployment\",\"slug\":\"infrastructure-deployment\",\"files\":[\"backend/Dockerfile\",\"backend/eslint.config.mjs\",\"backend/package.json\",\"backend/tsconfig.json\",\"frontend/Dockerfile\",\"frontend/eslint.config.mjs\",\"frontend/gate-proxy.mjs\",\"frontend/next.config.ts\",\"frontend/package.json\",\"frontend/tsconfig.json\",\"package.json\",\"packages/shared-types/package.json\",\"packages/shared-types/tsconfig.json\"]},{\"name\":\"Development Tooling &amp; Scripts\",\"slug\":\"development-tooling-scripts\",\"files\":[],\"children\":[{\"name\":\"Development Tooling &amp; Scripts \u2014 backend-scripts\",\"slug\":\"development-tooling-scripts-backend-scripts\",\"files\":[\"backend/scripts/ensure-google-wallet-class.mts\",\"backend/scripts/mint-invite.mjs\",\"backend/scripts/probe-cloud-read.ts\",\"backend/scripts/probe-engagement-requirements.ts\",\"backend/scripts/probe-extraction.ts\",\"backend/scripts/probe-google-wallet.mts\",\"backend/scripts/probe-schema-erd.ts\",\"backend/scripts/probe-webhook.mjs\",\"backend/scripts/purge-party.mjs\",\"backend/scripts/verify-identity.ts\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 backend-src\",\"slug\":\"development-tooling-scripts-backend-src\",\"files\":[\"backend/src/modules/_module.template.md\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 frontend-public\",\"slug\":\"development-tooling-scripts-frontend-public\",\"files\":[\"frontend/public/strip_text.py\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 scripts\",\"slug\":\"development-tooling-scripts-scripts\",\"files\":[\"scripts/apply-004.mjs\",\"scripts/apply-ciam-branding.ps1\",\"scripts/bind-dev-domains.ps1\",\"scripts/bind-test-domains.ps1\",\"scripts/bootstrap-platform-admin.mjs\",\"scripts/build-isms-starter-docs.py\",\"scripts/cancel-shutdown.bat\",\"scripts/deploy-dev.bat\",\"scripts/deploy-dev.ps1\",\"scripts/deploy-test.ps1\",\"scripts/enable-ciam-mfa-dev.ps1\",\"scripts/enable-ciam-native-auth.ps1\",\"scripts/enable-ciam-otp-email.ps1\",\"scripts/entra-app-reg-test.ps1\",\"scripts/entra-app-reg.ps1\",\"scripts/force-shutdown-10min.bat\",\"scripts/free-ports.ps1\",\"scripts/gen-data-catalogue.mjs\",\"scripts/isms-progress.mjs\",\"scripts/isms-truth-check.mjs\",\"scripts/migrate.mjs\",\"scripts/package-for-laptop.bat\",\"scripts/pg-ssl.mjs\",\"scripts/provision-pc.mjs\",\"scripts/provision-redis-dev.ps1\",\"scripts/reset-test-identity.bat\",\"scripts/reset-test-identity.mjs\",\"scripts/reset-test-identity.ps1\",\"scripts/run-mobile-demo.bat\",\"scripts/secure-local-infra.ps1\",\"scripts/slice-tradie-icons.py\",\"scripts/start-dev.bat\",\"scripts/switch-acs-sender-info.ps1\",\"scripts/wipe-test-app-data.mjs\",\"scripts/wipe-tradie.bat\",\"scripts/wire-acs-managed-identity.ps1\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 scripts-backup\",\"slug\":\"development-tooling-scripts-scripts-backup\",\"files\":[\"scripts/backup/backup-to-d-drive.bat\",\"scripts/backup/backup-to-e-drive.bat\",\"scripts/backup/backup-to-folder.bat\",\"scripts/backup/sync-docs-to-e-drive.bat\",\"scripts/backup/sync-frontend-backend-to-wiki-raw.bat\"]},{\"name\":\"Development Tooling &amp; Scripts \u2014 scripts-db-lifecycle\",\"slug\":\"development-tooling-scripts-scripts-db-lifecycle\",\"files\":[\"scripts/db-lifecycle/README.md\",\"scripts/db-lifecycle/_common.ps1\",\"scripts/db-lifecycle/backup-dev.bat\",\"scripts/db-lifecycle/backup-dev.ps1\",\"scripts/db-lifecycle/backup-local.bat\",\"scripts/db-lifecycle/backup-local.ps1\",\"scripts/db-lifecycle/ensure-founder.mjs\",\"scripts/db-lifecycle/inventory.mjs\",\"scripts/db-lifecycle/restore-to-local.bat\",\"scripts/db-lifecycle/restore-to-local.ps1\",\"scripts/db-lifecycle/wipe-local.bat\",\"scripts/db-lifecycle/wipe-local.ps1\"]}]},{\"name\":\"Performance &amp; Reliability Testing\",\"slug\":\"performance-reliability-testing\",\"files\":[],\"children\":[{\"name\":\"Performance &amp; Reliability Testing \u2014 helpers\",\"slug\":\"performance-reliability-testing-helpers\",\"files\":[\"e2e/helpers/flows.ts\",\"e2e/helpers/instrument.ts\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 e2e\",\"slug\":\"performance-reliability-testing-e2e\",\"files\":[\"e2e/package.json\",\"e2e/playwright.config.ts\",\"e2e/tsconfig.json\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 tests\",\"slug\":\"performance-reliability-testing-tests\",\"files\":[\"e2e/tests/deep-diagnostics.spec.ts\",\"e2e/tests/stability.spec.ts\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 src\",\"slug\":\"performance-reliability-testing-src\",\"files\":[\"frontend/src/components/dev/PerfProfiler.tsx\",\"frontend/src/components/dev/PerfRecorder.module.css\",\"frontend/src/components/dev/PerfRecorder.tsx\",\"frontend/src/components/dev/PerfRecorderHost.tsx\",\"frontend/src/components/dev/PerfSection.tsx\",\"frontend/src/lib/perf/findings.ts\",\"frontend/src/lib/perf/index.ts\",\"frontend/src/lib/perf/localhost-stress.ts\",\"frontend/src/lib/perf/post-fix-scenario.ts\",\"frontend/src/lib/perf/prod-scenarios.ts\",\"frontend/src/lib/perf/selftest.ts\",\"frontend/src/lib/perf/store.ts\",\"frontend/src/lib/perf/types.ts\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 loadtests\",\"slug\":\"performance-reliability-testing-loadtests\",\"files\":[\"loadtests/README.md\",\"loadtests/RESULTS.md\",\"loadtests/assurance-fix-evidence-20260806-191548.txt\",\"loadtests/azure-aca-metrics.json\",\"loadtests/azure-health-burst.txt\",\"loadtests/azure-health-probe.txt\",\"loadtests/azure-pg-metrics.json\",\"loadtests/baseline-stdout.txt\",\"loadtests/load-stdout.txt\",\"loadtests/pentest-curl-proofs.mjs\",\"loadtests/pentest-curl-proofs.txt\",\"loadtests/pentest-deep-probes.mjs\",\"loadtests/pentest-evidence.json\",\"loadtests/pentest-jwt-forge-proof.txt\",\"loadtests/pentest-jwt-pg-chain-proof.json\",\"loadtests/pentest-jwt-pg-chain.mjs\",\"loadtests/pentest-live-probes.mjs\",\"loadtests/pentest-redis-sqlmap.mjs\",\"loadtests/pentest-route-extract.mjs\",\"loadtests/pentest-routes.json\",\"loadtests/results-baseline-1785793598363.json\",\"loadtests/results-baseline-1785794235448.json\",\"loadtests/results-load-1785793776827.json\",\"loadtests/results-load-1785793998515.json\",\"loadtests/results-load-1785794174371.json\",\"loadtests/results-load-1785794406385.json\",\"loadtests/results-load-1785794745183.json\",\"loadtests/results-stress-1785795073564.json\",\"loadtests/results-stress-1785795296871.json\",\"loadtests/sqlmap-abn.txt\",\"loadtests/stress-window-end.txt\",\"loadtests/stress-window-start.txt\",\"loadtests/test-window-start.txt\",\"loadtests/user-journey.js\"]},{\"name\":\"Performance &amp; Reliability Testing \u2014 zap\",\"slug\":\"performance-reliability-testing-zap\",\"files\":[\"loadtests/zap/zap-baseline-stdout.txt\",\"loadtests/zap/zap-baseline.html\",\"loadtests/zap/zap-baseline.json\",\"loadtests/zap/zap.yaml\"]}]},{\"name\":\"Product &amp; Architecture Documentation\",\"slug\":\"product-architecture-documentation\",\"files\":[],\"children\":[{\"name\":\"Product &amp; Architecture Documentation \u2014 AGENTS.md\",\"slug\":\"product-architecture-documentation-agents-md\",\"files\":[\"AGENTS.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 CLAUDE.md\",\"slug\":\"product-architecture-documentation-claude-md\",\"files\":[\"CLAUDE.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 DESIGN.md\",\"slug\":\"product-architecture-documentation-design-md\",\"files\":[\"DESIGN.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 PRODUCT.md\",\"slug\":\"product-architecture-documentation-product-md\",\"files\":[\"PRODUCT.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 README.md\",\"slug\":\"product-architecture-documentation-readme-md\",\"files\":[\"README.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 Raw\",\"slug\":\"product-architecture-documentation-raw\",\"files\":[\"Raw/ASSUREWORX_BRAND.md\",\"Raw/ASSUREWORX_PRODUCT.md\",\"Raw/assureworx-platform-spec.html\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 backend\",\"slug\":\"product-architecture-documentation-backend\",\"files\":[\"backend/README.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 frontend\",\"slug\":\"product-architecture-documentation-frontend\",\"files\":[\"frontend/README.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 architecture\",\"slug\":\"product-architecture-documentation-architecture\",\"files\":[\"wiki/architecture/azure-dev-inventory.md\",\"wiki/architecture/azure-test-inventory.md\",\"wiki/architecture/backend.md\",\"wiki/architecture/cicd-target-operating-model.md\",\"wiki/architecture/code-map.md\",\"wiki/architecture/data-classification-catalogue.md\",\"wiki/architecture/data-model.md\",\"wiki/architecture/document-extraction.md\",\"wiki/architecture/frontend.md\",\"wiki/architecture/full-codebase-audit-2026-07-28.md\",\"wiki/architecture/identity-verification.md\",\"wiki/architecture/implementation-review-2026-07-21.md\",\"wiki/architecture/infrastructure.md\",\"wiki/architecture/integrations.md\",\"wiki/architecture/page-inventory-2026-07-25.md\",\"wiki/architecture/payments-design-2026-08-01.md\",\"wiki/architecture/plan-review-2026-07-20.md\",\"wiki/architecture/platform-audit-2026-07-25.md\",\"wiki/architecture/prod-rendering-report-2026-08-01.md\",\"wiki/architecture/production-readiness-2026-07-21.md\",\"wiki/architecture/production-readiness-qa-audit-2026-08-01.md\",\"wiki/architecture/production-readiness-report-2026-08-01.md\",\"wiki/architecture/qa-audit-plan-2026-08-01.md\",\"wiki/architecture/realtime-verify.md\",\"wiki/architecture/rsc-performance-plan-2026-07-31.md\",\"wiki/architecture/schema-mapping.md\",\"wiki/architecture/security-audit-2026-07-25.md\",\"wiki/architecture/security-engagement-2026-08-04.md\",\"wiki/architecture/security.md\",\"wiki/architecture/stack.md\",\"wiki/architecture/ui-collection-antipattern-audit-2026-07-30.md\",\"wiki/architecture/ux-ui-audit-2026-07-24.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 wiki\",\"slug\":\"product-architecture-documentation-wiki\",\"files\":[\"wiki/brand.md\",\"wiki/glossary.md\",\"wiki/index.md\",\"wiki/log.md\",\"wiki/overview.md\",\"wiki/ways-of-working.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 decisions\",\"slug\":\"product-architecture-documentation-decisions\",\"files\":[\"wiki/decisions/0001-full-azure-stack.md\",\"wiki/decisions/0002-postgresql-flexible-server.md\",\"wiki/decisions/0003-repository-layer-tenant-isolation.md\",\"wiki/decisions/0004-pricing-model.md\",\"wiki/decisions/0005-verified-scope-hybrid.md\",\"wiki/decisions/0006-email-azure-communication-services.md\",\"wiki/decisions/0007-obfuscated-physical-schema.md\",\"wiki/decisions/0008-bff-proxy-session-auth.md\",\"wiki/decisions/0009-realtime-verification-orchestration.md\",\"wiki/decisions/0010-pc-cold-start-admin-provisioned.md\",\"wiki/decisions/0011-in-app-support-desk.md\",\"wiki/decisions/0012-assureworx-id-and-identity-resolution.md\",\"wiki/decisions/0013-login-email-change.md\",\"wiki/decisions/0014-didit-identity-verification.md\",\"wiki/decisions/0015-identity-reverification-triggers.md\",\"wiki/decisions/0016-derived-job-state.md\",\"wiki/decisions/0017-employee-deregistration.md\",\"wiki/decisions/0018-engagement-scoped-requirements.md\",\"wiki/decisions/0019-document-extraction-over-registry-verification.md\",\"wiki/decisions/0020-azure-document-intelligence-tier-3.md\",\"wiki/decisions/0021-sms-deferred-entra-acs-email.md\",\"wiki/decisions/0022-invitation-verification-template-snapshots.md\",\"wiki/decisions/0023-pay-per-verification.md\",\"wiki/decisions/0024-payment-provider-boundary.md\",\"wiki/decisions/0025-native-auth-bff-proxy.md\",\"wiki/decisions/0026-identity-check-diagnostics.md\",\"wiki/decisions/0027-data-retention-and-public-legal.md\",\"wiki/decisions/0028-transactional-sms-twilio.md\",\"wiki/decisions/0029-employer-sponsored-bulk-verification.md\",\"wiki/decisions/0030-employer-employee-check-selection.md\",\"wiki/decisions/0031-refund-reconciliation-schedule.md\",\"wiki/decisions/0032-di-auto-accept-and-pc-document-visibility.md\",\"wiki/decisions/index.md\",\"wiki/decisions/open-questions.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 product\",\"slug\":\"product-architecture-documentation-product\",\"files\":[\"wiki/product/account-and-invite-journeys.md\",\"wiki/product/billing.md\",\"wiki/product/compliance-and-verification.md\",\"wiki/product/employee-verification-journey.md\",\"wiki/product/employer-pays-for-crew.md\",\"wiki/product/engagement-requirements.md\",\"wiki/product/entity-hierarchy.md\",\"wiki/product/identity-and-portability.md\",\"wiki/product/modules.md\",\"wiki/product/onboarding-and-request-chain.md\",\"wiki/product/pc-create-project-journey.md\",\"wiki/product/pc-project-request-journey.md\",\"wiki/product/roles-and-permissions.md\",\"wiki/product/trade-classifications.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 runbooks\",\"slug\":\"product-architecture-documentation-runbooks\",\"files\":[\"wiki/runbooks/acs-email-dev.md\",\"wiki/runbooks/azure-dev-deploy.md\",\"wiki/runbooks/azure-test-deploy.md\",\"wiki/runbooks/ciam-company-branding.md\",\"wiki/runbooks/dev-custom-domains.md\",\"wiki/runbooks/didit-retention-30-days.md\",\"wiki/runbooks/entra-external-id-dev.md\",\"wiki/runbooks/entra-monitoring-and-cost-dev.md\",\"wiki/runbooks/privacy-counsel-brief.md\",\"wiki/runbooks/redis-and-payment-reliability.md\",\"wiki/runbooks/reset-test-identity.md\",\"wiki/runbooks/stripe-setup.md\",\"wiki/runbooks/vscode-postgres-setup.md\",\"wiki/runbooks/wallet-passes.md\"]},{\"name\":\"Product &amp; Architecture Documentation \u2014 sources\",\"slug\":\"product-architecture-documentation-sources\",\"files\":[\"wiki/sources/assureworx-brand-guidelines.md\",\"wiki/sources/assureworx-platform-spec.md\",\"wiki/sources/assureworx-product-definition.md\"]}]},{\"name\":\"Security &amp; Compliance Documentation\",\"slug\":\"security-compliance-documentation\",\"files\":[],\"children\":[{\"name\":\"Security &amp; Compliance Documentation \u2014 legal\",\"slug\":\"security-compliance-documentation-legal\",\"files\":[\"docs/legal/Assureworx-Privacy-Statement-2026-08-04.html\",\"docs/legal/Assureworx-Terms-of-Use-2026-08-04.html\",\"docs/legal/generate-director-pdfs.mjs\",\"docs/legal/print-styles.css\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 src\",\"slug\":\"security-compliance-documentation-src\",\"files\":[\"frontend/src/app/(legal)/LegalNav.tsx\",\"frontend/src/app/(legal)/layout.tsx\",\"frontend/src/app/(legal)/legal.module.css\",\"frontend/src/app/(legal)/privacy/page.tsx\",\"frontend/src/app/(legal)/terms/page.tsx\",\"frontend/src/components/legal/LegalLinks.module.css\",\"frontend/src/components/legal/LegalLinks.tsx\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 isms\",\"slug\":\"security-compliance-documentation-isms\",\"files\":[\"isms/AGENTS.md\",\"isms/Dashboard.md\",\"isms/README_OBSIDIAN.md\",\"isms/data-inventory.md\",\"isms/didit-posture.md\",\"isms/doc-intel-posture.md\",\"isms/environments.md\",\"isms/evidence-register.md\",\"isms/gap-analysis.md\",\"isms/getting-started.md\",\"isms/index.md\",\"isms/log.md\",\"isms/overview.md\",\"isms/risk-register.md\",\"isms/scope.md\",\"isms/soa.md\",\"isms/soc2-mapping.md\",\"isms/stripe-posture.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 archive\",\"slug\":\"security-compliance-documentation-archive\",\"files\":[\"isms/archive/README.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 assurance\",\"slug\":\"security-compliance-documentation-assurance\",\"files\":[\"isms/assurance/control-truth-register.json\",\"isms/assurance/index.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 evidence\",\"slug\":\"security-compliance-documentation-evidence\",\"files\":[\"isms/evidence/README.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 exports\",\"slug\":\"security-compliance-documentation-exports\",\"files\":[\"isms/exports/README.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 governance\",\"slug\":\"security-compliance-documentation-governance\",\"files\":[\"isms/governance/approval-record.md\",\"isms/governance/document-control-and-approval-procedure.md\",\"isms/governance/document-register.md\",\"isms/governance/factual-assurance-procedure.md\",\"isms/governance/shareholder-brief.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 plans\",\"slug\":\"security-compliance-documentation-plans\",\"files\":[\"isms/plans/01-governance.md\",\"isms/plans/02-access-control.md\",\"isms/plans/03-change-management.md\",\"isms/plans/04-environments.md\",\"isms/plans/05-logging-monitoring.md\",\"isms/plans/06-data-protection.md\",\"isms/plans/07-supplier-management.md\",\"isms/plans/08-resilience.md\",\"isms/plans/09-people.md\",\"isms/plans/10-audit-evidence.md\",\"isms/plans/index.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 policies\",\"slug\":\"security-compliance-documentation-policies\",\"files\":[\"isms/policies/alert-triage.md\",\"isms/policies/data-classification-and-handling-policy.md\",\"isms/policies/index.md\",\"isms/policies/information-security-policy.md\"]},{\"name\":\"Security &amp; Compliance Documentation \u2014 templates\",\"slug\":\"security-compliance-documentation-templates\",\"files\":[\"isms/templates/confidentiality-and-non-disclosure-agreement.md\",\"isms/templates/index.md\",\"isms/templates/personnel-offboarding-checklist.md\",\"isms/templates/personnel-onboarding-checklist.md\",\"isms/templates/policy-acknowledgement-register.md\",\"isms/templates/policy-acknowledgement.md\"]}]},{\"name\":\"Legacy &amp; Experimental Artifacts\",\"slug\":\"legacy-experimental-artifacts\",\"files\":[\"LLoyd/invite.module.css\",\"LLoyd/page 2 (1).tsx\",\"LLoyd/page 2.tsx\",\"LLoyd/page 3.tsx\",\"LLoyd/page 4.tsx\",\"LLoyd/requests.module.css\",\"Untitled.base\"]},{\"name\":\"Other\",\"slug\":\"other\",\"files\":[\"backend/src/modules/memberships/employee-checks.dbtest.ts\"]}]};\n\n(function() {\n  var activePage = 'overview';\n\n  document.addEventListener('DOMContentLoaded', function() {\n    mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose' });\n    renderMeta();\n    renderNav();\n    document.getElementById('menu-toggle').addEventListener('click', function() {\n      document.getElementById('sidebar').classList.toggle('open');\n    });\n    if (location.hash &amp;&amp; location.hash.length &gt; 1) {\n      activePage = decodeURIComponent(location.hash.slice(1));\n    }\n    navigateTo(activePage);\n  });\n\n  function renderMeta() {\n    if (!META) return;\n    var el = document.getElementById('meta-info');\n    var parts = [];\n    if (META.generatedAt) {\n      parts.push(new Date(META.generatedAt).toLocaleDateString());\n    }\n    if (META.model) parts.push(META.model);\n    if (META.fromCommit) parts.push(META.fromCommit.slice(0, 8));\n    el.textContent = parts.join(' \\u00b7 ');\n  }\n\n  function renderNav() {\n    var container = document.getElementById('nav-tree');\n    var html = '\n';\n    html += 'Overview';\n    html += '';\n    if (TREE.length &gt; 0) {\n      html += '\nModules';\n      html += buildNavTree(TREE);\n    }\n    container.innerHTML = html;\n    container.addEventListener('click', function(e) {\n      var target = e.target;\n      while (target &amp;&amp; !target.dataset.page) { target = target.parentElement; }\n      if (target &amp;&amp; target.dataset.page) {\n        e.preventDefault();\n        navigateTo(target.dataset.page);\n      }\n    });\n  }\n\n  function buildNavTree(nodes) {\n    var html = '';\n    for (var i = 0; i &lt; nodes.length; i++) {\n      var node = nodes[i];\n      html += '\n';\n      html += '' + escH(node.name) + '';\n      if (node.children &amp;&amp; node.children.length &gt; 0) {\n        html += '\n' + buildNavTree(node.children) + '';\n      }\n      html += '';\n    }\n    return html;\n  }\n\n  function escH(s) {\n    var d = document.createElement('div');\n    d.textContent = s;\n    return d.innerHTML;\n  }\n\n  function navigateTo(page) {\n    activePage = page;\n    location.hash = encodeURIComponent(page);\n\n    var items = document.querySelectorAll('.nav-item');\n    for (var i = 0; i &lt; items.length; i++) {\n      if (items[i].dataset.page === page) {\n        items[i].classList.add('active');\n      } else {\n        items[i].classList.remove('active');\n      }\n    }\n\n    var contentEl = document.getElementById('content');\n    var md = PAGES[page];\n\n    if (!md) {\n      contentEl.innerHTML = '\n\nPage not found\n' + escH(page) + '.md does not exist.';\n      return;\n    }\n\n    contentEl.innerHTML = marked.parse(md);\n\n    // Rewrite .md links to hash navigation\n    var links = contentEl.querySelectorAll('a[href]');\n    for (var i = 0; i &lt; links.length; i++) {\n      var href = links[i].getAttribute('href');\n      if (href &amp;&amp; href.endsWith('.md') &amp;&amp; href.indexOf('://') === -1) {\n        var slug = href.replace(/\\.md$/, '');\n        links[i].setAttribute('href', '#' + encodeURIComponent(slug));\n        (function(s) {\n          links[i].addEventListener('click', function(e) {\n            e.preventDefault();\n            navigateTo(s);\n          });\n        })(slug);\n      }\n    }\n\n    // Convert mermaid code blocks into mermaid divs\n    var mermaidBlocks = contentEl.querySelectorAll('pre code.language-mermaid');\n    for (var i = 0; i &lt; mermaidBlocks.length; i++) {\n      var pre = mermaidBlocks[i].parentElement;\n      var div = document.createElement('div');\n      div.className = 'mermaid';\n      div.textContent = mermaidBlocks[i].textContent;\n      pre.parentNode.replaceChild(div, pre);\n    }\n    try { mermaid.run({ querySelector: '.mermaid' }); } catch(e) {}\n\n    window.scrollTo(0, 0);\n    document.getElementById('sidebar').classList.remove('open');\n  }\n})();\n\n\n\n", "creation_timestamp": "2026-08-08T04:17:22.797532Z"}