---
name: audit-dev-pwa
description: Audit the PWA / offline surface of a generated client app — tenant-isolated service worker markers, manifest icon chain, per-page mobile metadata, offline READ degradation, offline WRITE outbox wiring, the end-to-end IVersionedEntity rowversion chain, the registry-layout fail-closed gate and the precache cap vs built chunks (DEV-PWA-001..012)
group: D
phase: devFrontend
kind: audit
audit_only: true
section_label: 'PWA / offline'
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# audit-dev-pwa — PWA / Offline Conformity Audit

## Context

You are auditing the PWA / offline surface of a generated SmartStack client
app. The generators (`frontend-pwa`, `scaffold-routes`, `scaffold-component`,
`scaffold-api-client`, `scaffold-entity` + `scaffold-business`) emit a
tightly-coupled chain; this pack verifies every link statically, in < 1 s,
with zero LLM variance.

**The tenant/account-isolation stake.** The service worker caches API GET
responses on the DEVICE. Without the sacred cache-key markers
(`cacheKeyWillBeUsed` → `buildApiCacheKey` in `src/pwa/cacheKey.ts`, folding
`X-Tenant-Slug` / `Accept-Language` / `X-User-Id` into `__ss_tenant` /
`__ss_lang` / `__ss_user` key discriminators), one tenant's — or one
account's — cached data is served to the next on a shared device — a data
leak the backend RBAC can never see. `PURGE_API_CACHE` drops the whole API
cache on login/logout (the app-side `purgeApiCache()` caller ships in the
package). The `/api/auth/` exclusion and the `/^\/hubs\//` bypass keep
authentication and SignalR out of the cache; `SKIP_WAITING` is the update
channel `setServiceWorkerUpdater` relies on. These markers are
non-negotiable — DEV-PWA-003 blocks on any loss.

**The offline write stake.** An offline-WRITE resource replays queued
mutations when the network returns. Without the `IVersionedEntity` rowversion
chain (DEV-PWA-009) a stale replay silently overwrites a newer concurrent
edit instead of surfacing a real 409; without the outbox wiring
(DEV-PWA-007/008) the optimistic 202 the user saw is never delivered at all.

The audit reads (all statically, READ-ONLY — it never edits project files):

- `src/extensions/*Registry.ts` — `PageRegistry.register('<key>', <Var>, { mobile: … })`
  metadata + the lazy-import bindings that resolve each key to its page file.
- `vite.config.ts` — `VitePWA(` + `strategies: 'injectManifest'` + manifest icon refs.
- `src/pwa/sw.ts` + `src/pwa/cacheKey.ts` — the sacred markers, matched on the
  CONCATENATION of the two sources (and the `@customised` head marker on each).
- `src/main.tsx` — `registerSW(`, `setServiceWorkerUpdater(`,
  `import './extensions/outbox.generated'`, `initOutbox()` (textual order matters).
- `index.html` — `apple-touch-icon`, `viewport-fit=cover`.
- `src/features/**/outbox/*Outbox.ts` + `src/extensions/outbox.generated.ts` —
  the offline-WRITE spec modules and their registration.
- Page files + `src/features/**/{services,hooks,types}` — offline degradation,
  overlay wiring, `rowVersion` echo.
- Backend roots (via `lib/detector.ts` `findSmartStackStructure`) — Domain
  entity, EF configuration, update command/DTO.

**Page classification** (SSOT `lib/pwa-meta.ts`): a registration with
`offlineCapable: true` is an offline page. It is WRITE iff an outbox module
exists whose `<NAME>_RESOURCE` equals the registration key's section root
(key with any trailing `.detail` / `.edit` / `.create` view suffix stripped);
otherwise it is READ. Write-ness is a data-layer fact — never registry metadata.

**No-PWA apps**: when the project has no PWA at all (no `VitePWA(`, no
`mobile:` registry metadata, no outbox modules) the pack reports `ok` with a
note and exits 0 — it is always safe to run on a non-PWA app.

## When to run

- **From `/pwa`**: the orchestrator runs this pack as its final step, after the
  scaffolders. That is the only automated caller today.
- ⚠ **NOT wired into `/ba-develop`.** Neither `ba-develop/references/gates.md`
  nor `phases-detail.md` invokes this pack, so a `/ba-develop` run that emits
  offline pages does **not** verify them. Until the gate exists, run `/pwa`
  (which ends here) or invoke this pack manually after the Frontend phase.
  Do not describe it as an automatic promotion gate.
- **Manually**: when offline behaviour misbehaves in a generated app
  (stale cross-tenant data, writes lost offline, install banner broken).

## Rules

### DEV-PWA-001 — Service worker present + registered
- **Severity**: err, ok otherwise
- Check: when `vite.config.ts` contains `VitePWA(`, require
  `strategies: 'injectManifest'`, `src/pwa/sw.ts` on disk, and `main.tsx`
  carrying both `registerSW(` and `setServiceWorkerUpdater(`. **Inverse**: any
  registry registration carries `mobile:` metadata but vite.config has no
  `VitePWA(` → err (the PWA was never scaffolded; the metadata is dead weight
  and every "offline" page silently stays online-only).
- **ok**: label=`DEV_PWA_001_ok`
- **err**: label=`DEV_PWA_001_err`, params=`{ missing }` or `{ reason, mobileKeys, viteConfig }`
- **fixSkill**: `frontend-pwa`, **fixPhaseKey**: `frontend`, autoFixable: true
- **solution** (mandatory on err): "Re-run the frontend-pwa scaffolder — it
  emits the VitePWA injectManifest config, src/pwa/sw.ts and the main.tsx
  registerSW( / setServiceWorkerUpdater( wiring."

### DEV-PWA-002 — Manifest icons resolvable + iOS install meta
- **Severity**: err, ok otherwise. Only applies when `VitePWA(` is present
  (ok with a note otherwise).
- Check: every `/icons/*.png` path referenced in `vite.config.ts` must exist
  under `public/` (e.g. `/icons/pwa-192.png` → `public/icons/pwa-192.png`);
  `index.html` must carry `apple-touch-icon` and `viewport-fit=cover`
  (iOS home-screen icon + safe-area).
- **ok**: label=`DEV_PWA_002_ok`
- **err**: label=`DEV_PWA_002_err`, params=`{ missingIcons, missingHtml }`
- **fixSkill**: `frontend-pwa`, **fixPhaseKey**: `frontend`, autoFixable: true
- **solution** (mandatory on err): "Re-run the frontend-pwa scaffolder — it
  copies the icon set into public/icons/ and patches index.html with the
  apple-touch-icon link and viewport-fit=cover."

### DEV-PWA-003 — All sacred service-worker markers present
- **Severity**: err, ok otherwise. Only runs when `src/pwa/sw.ts` exists
  (DEV-PWA-001 owns the missing-file case).
- Check: the CONCATENATION of `src/pwa/sw.ts` + `src/pwa/cacheKey.ts` must
  contain EVERY sacred marker verbatim (12) —
  `cacheKeyWillBeUsed`, `X-Tenant-Slug`, `Accept-Language`, `X-User-Id`,
  `__ss_tenant`, `__ss_lang`, `__ss_user`, `/^\/api\//`, `/^\/hubs\//`,
  `/api/auth/`, `SKIP_WAITING`, `PURGE_API_CACHE`.
  Each missing marker is listed in `params.missingMarkers`. A missing
  `src/pwa/cacheKey.ts` (`params.cacheKeyExists: false`) means the client is
  still on the pre-split template (pre-a6b9f2a5: no per-account `__ss_user`
  key, no login/logout purge) — the re-run emits sw.ts + cacheKey.ts +
  swMessages.ts. When either file head carries `@customised` the finding is
  STILL err, but the solution says "restore the markers by hand — auto-fix
  disabled on customised files" (the scaffolder must never overwrite a
  customised worker).
- **ok**: label=`DEV_PWA_003_ok`
- **err**: label=`DEV_PWA_003_err`,
  params=`{ missingMarkers, customised, cacheKeyExists }`
- **fixSkill**: `frontend-pwa`, **fixPhaseKey**: `frontend`,
  autoFixable: true **unless** `@customised` (then manual restore)
- **solution** (mandatory on err): "Tenant/lang/user cache isolation, the
  PURGE_API_CACHE purge, the auth/hubs exclusions or the SKIP_WAITING update
  channel are broken. Re-run the frontend-pwa scaffolder to regenerate the SW
  graph — or, on a @customised worker, restore the markers by hand."

### DEV-PWA-004 — Every registration declares mobile metadata
- **Severity**: warn, ok otherwise
- Check: every `PageRegistry.register` in the generated `*Registry.ts` files
  carries a `{ mobile: … }` third argument. scaffold-routes emits an explicit
  `desktop-only` when the pagespec refuses mobile, so a 2-arg registration
  means "never considered" — list the uncovered keys.
- **ok**: label=`DEV_PWA_004_ok`, params=`{ covered }`
- **warn**: label=`DEV_PWA_004_warn`, params=`{ uncovered }`
- **fixSkill**: `frontend-routes`, **fixPhaseKey**: `frontend`, autoFixable: true
- **solution** (mandatory on warn): "Re-run scaffold-routes with the pagespec
  pwa declaration — an explicit desktop-only IS emitted so 'considered and
  refused' is distinguishable from 'never considered'."

### DEV-PWA-005 — Offline pages read through the standard api client
- **Severity**: warn; **err when the feature folder is missing entirely**;
  ok otherwise
- Check: for each registration with `offlineCapable: true` (deduped by
  section root), the entity's feature folder
  `src/features/**/{entityLower}/services/` must exist (WRITE resources are
  anchored by their outbox module's folder; READ resources match the section
  segment against the feature folder name, singular/plural tolerant) and its
  service must perform GET reads via the standard api client (`api.get(` or
  `apiClient.get(`). The service worker only caches GETs going through the
  shared client (tenant/lang headers) — reads made another way are not
  available offline.
- **ok**: label=`DEV_PWA_005_ok`
- **err**: label=`DEV_PWA_005_err`, params=`{ key, expected }`
- **warn**: label=`DEV_PWA_005_warn`, params=`{ key, servicesDir }`
- **fixSkill**: `frontend-api-client`, **fixPhaseKey**: `frontend`, autoFixable: false
- **solution** (mandatory on err/warn): "Re-run scaffold-api-client for the
  entity so its service reads through the shared api client."

### DEV-PWA-006 — Offline-READ pages degrade their mutations
- **Severity**: err, ok otherwise
- Check: for each offline-READ page (offlineCapable + NO matching outbox
  module), the page file must reference `useOnlineStatus` and gate its
  mutation controls with `disabled={!isOnline}` (the form submit's
  `disabled={isPending || !isOnline}` variant counts). Pages are resolved
  from the registry lazy-import bindings; an unresolvable page is itself err.
- **ok**: label=`DEV_PWA_006_ok`
- **err**: label=`DEV_PWA_006_err`, params=`{ key, file, missing }`
- **fixSkill**: `frontend-component`, **fixPhaseKey**: `frontend`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-component with the pwa
  offline declaration ('read') — mutation controls must be disabled offline,
  otherwise the user submits into the void and loses the input."

### DEV-PWA-007 — Outbox module integrity per offline-WRITE resource
- **Severity**: err; warn on `idempotencyKey:`; ok otherwise
- Check: each `src/features/**/outbox/*Outbox.ts` must export
  `const <NAME>_RESOURCE = '<app.module.section>'` matching an
  `offlineCapable` registration's section root, export
  `function register<Entity>Outbox()`, contain three
  `OutboxRegistry.register(` calls (create/update/delete floor), and be
  imported + called in `src/extensions/outbox.generated.ts`. A hardcoded
  `idempotencyKey:` in the module → warn (stability review — the runtime
  derives idempotency; a fixed key risks deduplicating distinct queued writes).
- **ok**: label=`DEV_PWA_007_ok`
- **err**: label=`DEV_PWA_007_err`, params=`{ resource, file, problems }`
- **warn**: label=`DEV_PWA_007_warn`, params=`{ resource, file, note }`
- **fixSkill**: `frontend-api-client`, **fixPhaseKey**: `frontend`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-api-client with
  pwa.offline 'write' for the entity — it emits the 3 mutation specs and
  registers the module in outbox.generated.ts."

### DEV-PWA-008 — main.tsx bootstraps the offline runtime in order
- **Severity**: err, ok otherwise. Only applies when any outbox module or
  `VitePWA(` exists (ok with a note otherwise).
- Check: `main.tsx` must carry `import './extensions/outbox.generated'`,
  call `initOutbox()` textually AFTER that import (specs must be registered
  before the queue starts, or every captured write is undeliverable), and
  carry `registerSW(`.
- **ok**: label=`DEV_PWA_008_ok`
- **err**: label=`DEV_PWA_008_err`, params=`{ missing }`
- **fixSkill**: `frontend-pwa`, **fixPhaseKey**: `frontend`, autoFixable: true
- **solution** (mandatory on err): "Re-run the frontend-pwa scaffolder to
  rewire main.tsx — outbox.generated side-effect import first, then
  initOutbox(), with registerSW( present."

### DEV-PWA-009 — Offline-WRITE entity versioned end-to-end
- **Severity**: err, ok otherwise
- Check: for each offline-WRITE entity (from the outbox module's
  `register<Entity>Outbox` name), the whole rowversion chain must hold —
  the Domain entity file implements `IVersionedEntity` and carries
  `RowVersion`; an EF configuration (`<Entity>Configuration.cs`) contains
  `.IsRowVersion()`; the update command/DTO (`Update<Entity>*.cs`) carries
  `RowVersion`; the frontend feature `types/index.ts` carries `rowVersion`.
  Backend roots are resolved via `lib/detector.ts` `findSmartStackStructure`
  (project-root fallback). When every leg passes, the rule stays **ok** but
  emits a NOTE in params: the migration-snapshot side (RowVersion column
  actually migrated) cannot be verified statically — confirm via `/efcore
  status`. **Never run migrations from this audit.**
- **ok**: label=`DEV_PWA_009_ok`, params=`{ entity, resource, note }`
- **err**: label=`DEV_PWA_009_err`, params=`{ entity, resource, missingLegs }`
- **fixSkill**: `scaffold-entity·scaffold-business`, **fixPhaseKey**:
  `entities`, autoFixable: partial (backend legs regenerate; the migration
  itself goes through /efcore, human-gated)
- **solution** (mandatory on err): "Without the rowversion chain a stale
  offline replay silently overwrites a newer concurrent edit instead of a
  real 409. Re-run scaffold-entity + scaffold-business with versioned: true,
  and scaffold-api-client with versioned: true for the frontend types."

### DEV-PWA-010 — Offline-WRITE pages stay enabled + surface the queue
- **Severity**: err, ok otherwise
- Check: for each offline-WRITE page — the list/detail pages mount
  `<OutboxStatusChip` (the form never does, per scaffold-component), and NO
  write page contains `disabled={!isOnline}` (the outbox captures mutations
  with an optimistic 202 — disabling them defeats the write story). Per write
  resource, the feature's list/detail hooks (`src/features/**/hooks/use*.ts`)
  must call `useOutboxOverlay` so queued writes are visible in the UI.
- **ok**: label=`DEV_PWA_010_ok`
- **err**: label=`DEV_PWA_010_err`, params=`{ key, file, problems }` or `{ key, hooksDir, problems }`
- **fixSkill**: `frontend-api-client·frontend-component`, **fixPhaseKey**:
  `frontend`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-component with
  pwa.offline 'write' (chip mounted, controls enabled) and
  scaffold-api-client for the useOutboxOverlay hook folding."

### DEV-PWA-011 — Registry layout recognized, non-empty scan (fail-closed)
- **Severity**: err, ok otherwise. Runs FIRST — on an unrecognized layout every
  other rule's verdict is computed over a partial surface.
- Check (two legs, via `lib/registry-index` — the scan reads EVERY registry
  shape: canonical per-module files, the legacy MCP-era monolith registering
  pages inline, static imports):
  1. **layout** — `componentRegistry.generated.ts` registers componentKeys of
     its OWN (`legacy-monolith`, or `mixed` when per-module files coexist).
     The /pwa scaffolding chain fail-closes on this layout
     (`registry.legacy-monolith` guard in `aggregate-component-registry` +
     `scaffold-routes`).
  2. **zero-scan** — pagespecs declare `pwa` metadata
     (`lib/pwa-meta.scanPagespecPwaDeclarations`) but ZERO registrations were
     scanned: a green verdict computed over nothing is forbidden.
- **ok**: label=`DEV_PWA_011_ok`, params=`{ layout, registrations }`
- **err**: label=`DEV_PWA_011_err`, params=`{ layout, registrations }` or `{ pagespecPwaCount, registrations }`
- **fixSkill**: `frontend-routes`, **fixPhaseKey**: `frontend`, autoFixable: **false**
- **solution** (mandatory on err): "Run split-component-registry
  (frontend-routes skill, routing-identical) to migrate the monolith, then
  re-run /pwa. NEVER re-run scaffold-routes or aggregate-component-registry on
  this layout."

### DEV-PWA-012 — Precache cap vs built chunks
- **Severity**: err when an oversized chunk exists and sw.ts has NO runtime
  CacheFirst asset net (blank screen offline); warn when the net covers it
  (first offline visit still not guaranteed); ok otherwise. dist/ absent =
  **ok with an explicit skip note** ("build not verified") — chunk sizes only
  exist after `npm run build`, never a guessed warn.
- Check: parse `maximumFileSizeToCacheInBytes` from vite.config
  (`N * 1024 * 1024` or byte literal; absent = the 2 MiB workbox default),
  stat `<outDir>/assets/*.{js,css}`, flag files above the cap;
  `hasRuntimeNet` = sw.ts contains a `CacheFirst` runtime route.
- **ok**: label=`DEV_PWA_012_ok`, params=`{ capBytes, capSource, checked, hasRuntimeNet }` (or `{ note }` on skip)
- **warn/err**: label=`DEV_PWA_012_warn|err`, params=`{ capBytes, capSource, oversized, hasRuntimeNet }`
- **fixSkill**: `frontend-pwa`, **fixPhaseKey**: `frontend`,
  autoFixable: true on err (re-running frontend-pwa restores the net), false
  on warn (splitting chunks with manualChunks is a manual step)
- **solution** (mandatory): "Split the oversized chunk(s)
  (vite build.rollupOptions.output.manualChunks) rather than raising the cap;
  on err additionally re-run the frontend-pwa scaffolder for the CacheFirst net."

## Output contract

The CLI emits a JSON envelope identical to `audit-dev-wire`:

```json
{
  "success": true,
  "command": "audit-dev-pwa",
  "data": { "mode": "audit", "rulesRun": ["DEV-PWA-001", "…"], "valid": false, "pwaDetected": true },
  "report": { /* full AuditReport with findings, counts, byRule, markdown */ },
  "auditReport": {
    "scope": "devPwa",
    "applicationCode": "client",
    "moduleCode": "hrm",
    "findings": [
      {
        "dimension": "pwa",
        "code": "DEV-PWA-003",
        "severity": "err",
        "label": "DEV_PWA_003_err",
        "params": { "missingMarkers": "X-Tenant-Slug,__ss_tenant", "customised": false },
        "solution": "src/pwa/sw.ts lost sacred markers — …",
        "fixSkill": "frontend-pwa",
        "fixPhaseKey": "frontend"
      }
    ]
  },
  "errors": [],
  "warnings": [],
  "nextSteps": ["1 ERROR finding(s) — blocking the PWA gate of /ba-develop."]
}
```

Exit code: `0` when no `err` findings (including the no-PWA skip), `1` otherwise.

## CLI invocation

```bash
npx --prefer-offline tsx skills/development/audit-dev-pwa/cli/audit-dev-pwa/index.ts \
  --project-path "<repo-root>"      \
  --web-root     "<web-root>"       \
  --app-code     "<APP>"            \
  --module-code  "<MODULE>"         \
  --mode audit

# --spec alternative (for orchestrators driving the /pwa chain, which speaks
# --spec everywhere else). Explicit flags override the spec; `appCode` is
# accepted as an alias of `applicationCode`:
npx --prefer-offline tsx skills/development/audit-dev-pwa/cli/audit-dev-pwa/index.ts \
  --spec '{"projectPath":"<repo-root>","applicationCode":"<APP>"}'
```

`--web-root` (absolute, or relative to `--project-path`) defaults to the
detector's web folder. `--app-code` / `--module-code` are OPTIONAL scope
filters (first / second componentKey segment) — omitted = whole project.
The Markdown report is written to `_audit/dev-pwa-<module|all>.md`
(`--no-write-report` for stdout only). The CLI is read-only over the project
sources; `--mode apply` is a stub — fixes always route to the fixSkill.

## Auto-healing mapping

This audit emits failure kinds the `/ba-develop` orchestrator's auto-healing
protocol picks up. The audit CLI never fixes anything itself — the FIX is
re-running the fixSkill:

| Finding | Failure kind | Auto-fix |
|---------|--------------|----------|
| `DEV-PWA-001 err` | `pwa.sw-chain-incomplete` | Re-invoke the `frontend-pwa` scaffolder (VitePWA config + sw.ts + main.tsx wiring) |
| `DEV-PWA-002 err` | `pwa.manifest-broken` | Re-invoke `frontend-pwa` (icons into public/icons/ + index.html meta) |
| `DEV-PWA-003 err` (not customised) | `pwa.sw-markers-lost` | Re-invoke `frontend-pwa` (regenerates sw.ts + cacheKey.ts + swMessages.ts with every sacred marker) |
| `DEV-PWA-003 err` (`@customised`) | `pwa.sw-markers-lost-customised` | HALT auto-fix — restore the markers by hand (never overwrite a customised SW graph) |
| `DEV-PWA-004 warn` | `pwa.mobile-meta-uncovered` | Re-invoke `scaffold-routes` with the pagespec pwa declaration (fixSkill `frontend-routes`) |
| `DEV-PWA-005 err/warn` | `pwa.read-path-nonstandard` | Re-invoke `scaffold-api-client` for the entity (fixSkill `frontend-api-client`; no mechanical auto-fix) |
| `DEV-PWA-006 err` | `pwa.read-page-not-degraded` | Re-invoke `scaffold-component` with `pwa.offline: 'read'` (fixSkill `frontend-component`) |
| `DEV-PWA-007 err` | `pwa.outbox-incomplete` | Re-invoke `scaffold-api-client` with `pwa.offline: 'write'` (fixSkill `frontend-api-client`) |
| `DEV-PWA-008 err` | `pwa.bootstrap-misordered` | Re-invoke `frontend-pwa` (rewires main.tsx: outbox import → initOutbox → registerSW) |
| `DEV-PWA-009 err` | `pwa.write-entity-unversioned` | Re-invoke `scaffold-entity` + `scaffold-business` with `versioned: true` (+ `scaffold-api-client` for the frontend types); the migration itself goes through /efcore — human-gated, NEVER run by the healer |
| `DEV-PWA-010 err` | `pwa.write-page-contradiction` | Re-invoke `scaffold-component` (`pwa.offline: 'write'`) and/or `scaffold-api-client` (overlay hooks) |
| `DEV-PWA-011 err` | `registry.legacy-monolith` | HALT auto-fix — run `split-component-registry` (frontend-routes skill); NEVER re-run `scaffold-routes`/`aggregate-component-registry` on a legacy/mixed layout |
| `DEV-PWA-012 err` | `pwa.precache-cap-regression` | Re-invoke `frontend-pwa` (sw.ts CacheFirst asset net); splitting the chunk (manualChunks) stays a manual step |
