# PWA and offline

> ## ✅ WIRED END TO END, `As of 2026-08`
>
> **`x dev`, the container and `x build --target static` all emit `manifest.webmanifest`, `sw.js`
> and `x-sw-register.js`**, plus the `<head>` that names all three. The manifest reader is
> [`packages/cli/src/pwa-artifacts.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/pwa-artifacts.ts)
> and the worker's is
> [`packages/cli/src/sw-artifacts.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/sw-artifacts.ts).
>
> This box said the opposite until 2026-08-27 — `generateServiceWorker`, `buildPrecacheManifest`,
> `offlineFallbackSource`, `backgroundSyncSource` and `pushSource` had **zero callers** outside
> `@ultimat3/pwa` itself, so `pwa.offline`, `pwa.backgroundSync`, `pwa.push` and every route's own
> `offline:` field were declarations with no build behind them ([#390](https://github.com/developerz-ai/ultimate/issues/390),
> [#362](https://github.com/developerz-ai/ultimate/issues/362)).
>
> **What unblocked it was the browser check, not the emitter.** A bad `sw.js` is **sticky** — a
> manifest a browser dislikes is ignored, a worker that caches wrong keeps serving wrong bytes until
> the user clears site data — so the rule was: no worker until something can drive a real one.
> `packages/cli/e2e/service-worker.e2e.test.ts` is that check. It registers the emitted file in a
> real Chrome, waits for it to take control, takes the network away, and asserts that a runtime
> route with nothing cached renders the **offline document**. It runs on every push.
>
> **`pwa.offline` is now a block, not a string** — `{ fallback, image, font, neverCache }`, and
> `fallback` is **required** once `enabled` is true. See [Upgrading](/upgrading) for the one-line
> edit; the old `offline: 'runtime'` was an app-wide default for a field `defineRoute` makes
> required on every route, so it defaulted nothing and was read by nobody.
>
> **`pwa.push` is wired too, `As of 26.1.0`** ([#710](https://github.com/developerz-ai/ultimate/issues/710)):
> a `pwa.vapid` key, a VAPID pair the boot resolves (or refuses), the worker's push handler, a
> subscription action pair and a notify channel — [Push](#push).

`sw.js` is a build artifact, generated from the route table and written by `x build`. Hand-editing one is **not** caught, and it does not survive either: `As of 2026-08` `sw.js` carries no checksum, so the next build overwrites the edit in silence — no warning, no diff, nothing in `--json`. `X_SW_HAND_EDITED` is a **reserved** name — nothing raises it, and `x errors explain X_SW_HAND_EDITED` refuses it ([Error codes → Not thrown yet](/error-codes#not-thrown-yet)).

`As of 2026-08`. Stable API — semver from here ([Upgrading](/upgrading)).

## Why generated

A service worker is a cache-policy compiler whose input is already declared on every route: render mode, offline strategy, asset graph. Hand-writing it duplicates that information, and the duplicate drifts. Every notorious PWA bug — the page serving last month's HTML, the chunk 404 after deploy, the user stuck on a version until they clear site data — is a service worker that disagreed with the app.

The edit an agent should make is the route's `offline` field, and `x build` reads it, `As of 2026-08`. Hand-editing the emitted `sw.js` is still not caught, per the reserved-code note above.

## Derived from the route

```ts
export const config = defineRoute({
  render:     'isr',                  // static | isr | ssr | stream
  revalidate: { tags: [tag.post] },
  prerender:  () => db.posts.slugs(),
  offline:    'precache',             // precache | runtime | network-only
  hydrate:    'visible',              // idle | visible | interaction | never
  budget:     { js: '40kb' },
  meta:       ({ post }) => ({ title: post.title, description: post.excerpt,
                               og: { image: post.cover }, ld: ld.Article(post) }),
});
```

### Precache set

| Included | Source |
|---|---|
| Every route with `offline: 'precache'` | route table + its `prerender()` URLs |
| The island chunks those routes boot, every surface stylesheet, the framework scripts | rendered documents / declared islands, `As of 22.3.3` |
| Fonts, icons, and `priority` images they reference | asset graph |
| The offline fallback route | required (see below) |

Excluded always: `api/` responses and every personal route (a policy, a `stream`, a `no-store`/`private` cache) — even with `offline: 'precache'`, `As of 22.3.3`.

Total precache size is a **warning, not a budget, `As of 2026-08`**: `buildPrecacheManifest` puts `DEFAULT_PRECACHE_WARN_BYTES` overruns on `PrecacheManifest.warnings`, and **nothing reads that field** — no `x verify` step loads a precache manifest, because nothing builds one. Designed as a budget; not one today.

### Runtime strategy from render mode

`MODE_STRATEGY` in `packages/pwa/src/strategies.ts`, read by `strategyFor()` and keyed on
`Record<RenderMode, StrategyName>` — a mode with no row and a row for a mode that does not exist
are both compile errors, `As of 2026-08`.

| `render` | Strategy | Rationale |
|---|---|---|
| `static` | `network-first` | the document names this deploy's hashed assets; the precached copy is the offline answer |
| `isr` | `network-first` | the server's ISR cache is already stale-while-revalidate; a second, older copy in the browser only delays a deploy |
| `ssr` | `network-first` | caching a per-request render is a correctness bug; the offline fallback answers on failure |
| `stream` | `network-first` | the shell names this deploy's chunks; the cached copy is the offline answer |

**Every document is network-first, `As of 22.3.2`.** Every rule the worker routes is a document —
content-hashed chunks get none and live in the browser's HTTP cache (`immutable`). Until 22.3.2
`static` was `cache-first` and `isr`/`stream` `stale-while-revalidate`, so an online navigation got
the HTML the old worker held, naming the old deploy's CSS and islands, until the visitor pressed
Shift+F5. `offline: 'precache'` still precaches the document: precache decides what answers
offline, not what answers while the network does. A per-route `strategy` can still ask for
`cache-first` or `stale-while-revalidate`.

**Navigation preload**, `As of 2026-09`: on `activate` the worker enables
`registration.navigationPreload`, so the browser sends a navigation's request while the worker is
still starting, and every strategy answers from `event.preloadResponse` before it would fetch. Worker
start-up is off the critical path of every navigation; a browser without the API, or a preload that
resolves empty, is the worker's own fetch as before. The preload is the browser's request and
carries no `X-Ultimate-Build`, so skew is read off the answer: another build's document posts
`AppUpdateAvailable` and the worker stops stamping its id — what a `409` did, in one round trip.

**The install fill is throttled**, `As of 2026-09`: `PRECACHE_CONCURRENCY` (4) entries in flight,
never every entry at once — the fill runs on the first visit, on the connection the first click needs.
An entry whose answer carries another build's `X-Ultimate-Build` is not stored, `As of 2026-10-02`:
mid-rollout an old pod's document was the new worker's offline copy. An unstamped answer (a static
host) is stored; a skipped entry is fetched when asked for.

**A rule matches the pathname the browser sends**, `As of 2026-10-02`: a literal segment is
percent-encoded the way `url.pathname` spells it (`/precios-españa`, `/a b` were never handled), and a
catch-all owns its bare prefix — `/docs/*path` matches `/docs`, which the static export writes.

**A new worker takes over at once**, `As of 22.3.2`: the install block calls `self.skipWaiting()`, so
a deploy's worker activates when its precache is filled rather than when every tab of the origin
has closed. It never reloads a page — the open tab keeps what it has, gets `AppUpdateAvailable`,
and its next navigation is answered with the new HTML. `x-sw-register.js` also calls
`registration.update()` when a hidden tab becomes visible again, at most once per five minutes, so
a tab left open for days finds the new worker before its next click. A browser still running a
22.3.1 worker converges with no user action: its next navigation fetches the new `sw.js`, which
skips waiting and claims the tab — that one navigation is still the old worker's cached copy, and
the one after it is fresh.

**`immutable` is for a content-hashed URL only**, `As of 22.3.2`. `/x-sw-register.js` (was
`max-age=3600`) and the `/icons/*` matrix (was `immutable`, under names that are sizes, not bytes)
answer `public, max-age=0, must-revalidate` with a strong ETag, and a 304 when unchanged — so a new
register script or a new `icon.png` reaches every client on its next load. `sw.js` stays
`private, max-age=0`; `manifest.webmanifest` and `/favicon.ico` keep their hour.

**There is no per-mode `offline` default** — `offline` is required by `defineRoute`'s type and again
at runtime (`X_ROUTE_OFFLINE_MISSING`). It is read *before* the mode: `offline: 'network-only'` is a
declaration that this URL is never answered from a cache, and `strategyFor` returns `network-only`
without consulting the table. A per-route `strategy` overrides both.

Overriding `offline` is allowed. Contradictions are **not** rejected `As of 2026-08`: `offline: 'precache'` on a `render: 'ssr'` route is accepted, and `X_SW_UNCACHEABLE` is a reserved name nothing raises ([Error codes → Not thrown yet](/error-codes#not-thrown-yet)). The scope half *is* enforced — `X_SW_SCOPE_INVALID`, when the service-worker scope cannot serve the routes it precaches. Until the coherence check ships, review the pairing yourself: a per-request render has no cacheable body, so `precache` on `ssr` means the shell is served stale.

**A page rendered for someone is never cached**, `As of 22.3.3` (a security fix). A route is
*personal* when it has a `policy`, is `render: 'stream'`, or declares `cache: 'no-store'` /
`{ mode: 'no-store' | 'private' }` (`RouteDescriptor.personal`). Its rule is `network-only` in every
locale and it is never precached, whatever its `offline` says; a per-route `strategy` is still the
override. And whatever the rule, the worker **stores no response** that is `cache-control: private`
or `no-store`, or carries `x-ultimate-scope`, in any cache. From 21.0.0 to 22.3.2 such a response
was kept in a per-member partition for offline, so after sign-out an offline navigation on a shared
device showed the previous member's data. Offline, a personal page is the offline document.

An offline-first app can take 21.0.0's mode back, explicitly: `pwa.offline.personalPages:
'last-member'` routes a personal page by its render mode again and keeps the most recent member's
own copy in a per-principal partition that only the offline path reads (never answered online,
never precached — one declaring `offline: 'precache'` lands in the partition too, `As of 2026-10-02`;
it got a `precache` rule and was never kept). Declare it only with a sign-out that clears it — `clear-pages` below, or
`signOutHeaders()`' `Clear-Site-Data`. `examples/dummy` does, for its offline feed.

| Message | Sent by | Worker does |
|---|---|---|
| `{ type: 'clear-pages' }` | the page, on sign-out | deletes every build's pages cache (`x-pages-*`, and any 22.3.2 partition), then answers the posting window `{ type: 'pages-cleared' }` |

```ts
// sign-out, before navigating away — CLEAR_PAGES_MESSAGE from @ultimat3/pwa
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-pages' });
```

The precache is untouched: marketing pages still answer offline. `Clear-Site-Data: "storage"` on
the sign-out response empties Cache Storage too, and is the belt to this message's braces.

**The English site is offline too**, `As of 22.3.3`. The server strips a non-default locale's prefix
before it matches (`/en/precios` is `/precios` in English), and the worker matches pathnames, so it
now gets every route once per routed locale — same strategy, same cache, its own precache revision.
An offline navigation under `/en/` gets `/en/offline` when that is precached, else the default one.

**A client-router visit is a navigation**, `As of 2026-10-05`. A `navigation.client` link fetches
its page (`x-ultimate-navigation: soft`), which is no browser navigation, so offline it got an empty
`503` and the tab landed on a `blob:` URL. The worker now answers it with the offline document; a
`prefetch` still gets the `503`, so the offline page is never cached as a later click's answer.

**Islands are precached only for precached pages**, `As of 22.3.3`. Every island chunk was precached,
so a first anonymous visit downloaded every island the app has (~1 MB on one adopter). Now a chunk
is precached only when a precached page (or the offline document) boots it — the static export reads
the rendered documents, the container each route's declared `island()`s. Every other chunk is cached
the first time a page asks for it: `/islands/` is a `cache-first` runtime rule.

**The installing page is cached too**, `As of 2026-09-22` (21.0.0). The page that registers the worker loaded before the worker controlled it, so no strategy saw it, and an `offline: 'runtime'` route was unavailable offline until a second online visit. On `activate`, the worker now runs each open window's URL through its route's own strategy, the same rule every later visit follows. It is best effort: a failure costs the warm-up only.

Mutations are never cached. **Offline writes go through the tier-3 mutator queue ([Realtime](/realtime)), not through Background Sync guesswork** — a durable queue with a declared `conflict` strategy per mutator, replayed on reconnect and rebased against server truth. Background Sync, when enabled, is only a wake-up trigger for that queue; it is never the queue. **One outbox, and it is realtime's**, in the page's IndexedDB, keyed by principal. On a `sync` event the worker posts `OUTBOX_DRAIN_MESSAGE` (`'x-outbox-drain'`, from `@ultimat3/core`) to every open tab and sends nothing itself. With no tab open the queue waits on disk for the next page load. `As of 2026-09-22` (21.0.0) both halves are in the tree: the page's outbox listens for the message. Until 21.0.0 the worker POSTed `/_x/outbox/flush`, a route nothing ever mounted.

### Manifest, icons, splash

From `app.config.ts` plus **one** source icon (SVG or >=1024px PNG). **Emitted at `/manifest.webmanifest` by `x dev`, by the container and into the static export**, `As of 2026-08` — `packages/cli/src/pwa-artifacts.ts` composes `pwa.name` and `pwa.colors` with `planIcons`' own list, so the manifest names exactly the icons `/icons/*` serves. `defineConfig` refuses `pwa.enabled: true` without a name and both schemes' colours, so `assertValid`'s two refusals now arrive at boot rather than at build:

| Generated | Detail |
|---|---|
| `manifest.webmanifest` | the DEFAULT locale's: `lang` is `defaultLocale` (was a hardcoded `en` until 22.3.3), `start_url` `/`, `id` `pwa.id` or `/`, theme + background from `pwa.colors` |
| `/<locale>/manifest.webmanifest` | one per other routed locale, `As of 22.3.3`: its own `lang`, `start_url: '/en/'`, the SAME `id` (one installed app), its text and its shortcut URLs in that locale. Each document links its own locale's manifest |
| Icons | 192/256/384/512 + maskable variants + `apple-touch-icon` |
| Favicons | `.ico` + SVG |
| `description`, `categories`, `shortcuts`, `screenshots` | from `pwa.*`, `As of 22.3.3` — below |

```ts
// app.config.ts — every member optional; text is a string or one per locale
pwa: {
  enabled: true, name: 'Acme', offline: { fallback: '/offline' }, colors: { … },
  id: '/',                                     // default: '/'
  description: { 'es-co': '…', en: '…' },
  categories: ['business', 'productivity'],
  shortcuts: [{                                // url: the DEFAULT locale's path; en gets /en/panel
    name: { 'es-co': 'Panel', en: 'Dashboard' }, shortName?: …, description?: …,
    url: '/panel', icons?: [{ src: '/assets/…png', sizes: '96x96', type: 'image/png' }],
  }],
  screenshots: [{                              // src: one path, or one per locale
    src: { 'es-co': '/assets/…-es.png', en: '/assets/…-en.png' },
    sizes: '1280x800', type: 'image/png', formFactor: 'wide', label?: { … },
  }],
}
```

`defineConfig` refuses a relative `id`, shortcut `url` or screenshot `src` — a browser drops those
in silence. A record missing a locale falls back to the default locale's entry.

A screenshot or shortcut-icon `src` under `/assets/` is a file in `apps/web/site/assets/`, and the
manifest names its **content-hashed** URL (`/assets/pwa/wide-es-co.1a2b3c4d.png`), exactly as
`asset()` does in a page — the site serves an asset only there (`As of 22.4`). A missing file is
`X_ASSET_MISSING` at boot; any other `src` is published as written.

No icon-generator service, no 30-file `public/` directory to maintain. Theme and background colours come from semantic [theme](/theming) tokens, never a raw hex.

### Offline fallback is required in the type

```ts
// app.config.ts — omitting `offline.fallback` is a compile error
pwa: {
  offline: { fallback: '/offline' },   // required
}
```

`x new` scaffolds `/offline` in `site/` — 0kb JS, works from cache, shows the queued-mutation count. A PWA without a fallback shows the browser's dinosaur, which reads as "the app is broken", not "you are offline". Making it a required field means no app ships without one.

## Version skew is what actually breaks PWAs

Not caching strategy. **Skew**: a client running build `A` requesting an asset from build `B`.

```
user opens app (build A) → keeps tab open 3 days → you deploy 6 times
  → user clicks a route → lazy chunk from build A → 404 → white screen
```

The client is not broken and the server is not broken; they disagree about which version exists. Mitigations, all mandatory:

| # | Mechanism | Detail |
|---|---|---|
| 1 | **Immutable build ID per deploy** | content-hash of the build, stamped into `sw.js`, the HTML, every asset path, and `x.manifest.json`. Never a timestamp, never `latest` |
| 2 | **Client sends its build ID on every request** | `X-Ultimate-Build` header on RPC, query, and WS handshake. The server can answer "you are stale" instead of guessing |
| 3 | **N-deploy asset retention** | `assets: { retain: { disk, builds } }` in `app.config.ts`: every web pod publishes its hashed files to that shared disk before it reports ready, and answers an earlier build's `/islands`, `/styles` and assets from it on a miss. Unset (the default), a pod serves only what it built. A **count of deploys, with no time component**. Proven in a browser `As of 2026-11`: a tab opened on build 0 survives six deploys that each change an island, and its first click boots build 0's chunk with a 200 and no 404 ([`six-deploys.e2e.test.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/e2e/six-deploys.e2e.test.ts)); without `retain` the same click is a 404. `retentionPlan` was removed in 30.0.0: it kept nothing on its own, because no boot called it |
| 4 | **`AppUpdateAvailable` signal, not a 404** | a Solid signal flips when the server reports a newer build. The app renders its own "Update available — reload" affordance. **No forced navigation, no lost form state, no dinosaur** |
| 5 | **Forced reload after a grace period** | **Not a capability of this package.** Deleted in 9.0.0 — `updateSignal`/`updatePolicy` computed a deadline nothing acted on, and `pwa` (tier 4) sits above the two runtimes that hold both build ids. The service worker posts `{ type: 'AppUpdateAvailable', to }`; act on it yourself, or read `useConnection().updateAvailable` |
| 6 | **Skew is observable** | the `/_x` live panel reports the build-ID distribution of connected clients, so "how many users are three deploys behind" has an answer. `x status --json` would report it outside dev and is **planned**, not shipped |
| 7 | **Build ID scopes the SW cache** | preview/branch builds get their own cache namespace and SW scope, so a preview can never poison prod caches |

Server behaviour on a stale build ID:

| Request type | Response |
|---|---|
| Asset still within retention | serve it |
| Asset outside retention | `410 Gone` + `X-Ultimate-Build-Current`, SW serves the fallback and flips `AppUpdateAvailable` |
| Action / query | executed if the contract is compatible; `X_BUILD_SKEW` with a `fix:` line if the input schema changed incompatibly |
| WS handshake | accepted, then an `update-available` frame carrying the server's `buildId` → signal flips; the socket is **not** killed |

`410 Gone` rather than `404`: a 404 is indistinguishable from a typo, and a client cannot act on it. `410` plus `X-Ultimate-Build-Current` is an instruction.

## Opt-in capabilities

Off by default. Each flag adds permission prompts, review burden, or platform surface, so none is implicit.

```ts
// app.config.ts: the two capabilities an app can switch on, both booleans
pwa: {
  enabled: true,
  offline: { fallback: '/offline' },
  backgroundSync: true,
  push: true, // Web Push — requires vapid; the key pair is env (`x vapid create`), see below
  vapid: { subject: 'mailto:ops@example.com' },
}
```

`badging`, `shareTarget` and `fileHandlers` are `CapabilityFlags` on `@ultimat3/pwa`'s
`generateServiceWorker`, **not** `app.config.ts` keys, `As of 2026-09-22`. `x build` passes only
`backgroundSync` and `push` through (`packages/cli/src/sw-artifacts.ts`). This block showed all five
as config objects (`{ enabled, queues }`, `{ enabled, vapid }`, …), and no such shape was ever
accepted.

| Flag | Generates | Cost of enabling |
|---|---|---|
| `push` | the SW `push` and `notificationclick` handlers, `<meta name="x-push-key">` on every document, and the push runtime every role's boot installs — on a served boot (`x dev`, the container). A static export serves no subscribe action, so it emits no handler and files a `serviceWorkerWarnings` entry (`cli.build.pushUnwired`). A tap opens a same-origin URL only; any other opens the app root | notification permission prompt (from a click); a VAPID key pair (`x vapid create`) |
| `backgroundSync` | a SW `sync` listener that posts `OUTBOX_DRAIN_MESSAGE` to every open tab, and the page registration with an `online` fallback where the Background Sync API is missing | replay must be idempotent. By design each queued write carries an idempotency key, so `action`'s idempotency store answers a replay rather than applying it twice. The queue is IndexedDB-backed, per principal |
| `badging` | badge update from a live query — **only alongside `push`** `As of 2026-08-20`: the badge call is emitted inside the push block, so `badging: true` on its own changes nothing while `capabilities.badging` still reports `true` | Chromium-only surface |
| `shareTarget` | the `share_target` manifest member, from `generateWebManifest`'s `shareTarget` input — nothing else: no route, no policy, no worker code. `x build` passes neither the flag nor the input, so no app's manifest carries it, `As of 2026-10-02` | the app serves the target route itself and must treat its payload as untrusted |
| `fileHandlers` | manifest entry + route | OS-level file association |
| ~~`periodicSync`~~ | **not built, and the declarations are deleted** `As of 2026-08-20`. There was never a `periodicsync` listener, never a `periodicSync.register` call, and no `CAPABILITIES` flag to gate one — `PERIODIC_SYNC_TAG` and `periodicMinIntervalMs` described a feature that did not exist | — |

All of them are `route` / `action` / `job` primitives underneath ([The eight primitives](/the-eight-primitives)) — a push send would be a job, a share target is a route the app serves. No PWA-specific concept escapes into the app's mental model.

### Push

**Web Push, end to end, with no dependency** — RFC 8291 message encryption and RFC 8292 (VAPID,
ES256) signing on WebCrypto. `As of 26.1.0`.

| Piece | What | Where |
|---|---|---|
| config | `pwa: { push: true, vapid: { subject: 'mailto:ops@example.com' } }` — a `subject` push services accept (`mailto:` or `https:`), required with `push`, refused without it | [Configuration](/configuration) |
| key pair | `ULTIMATE_VAPID_PUBLIC_KEY` + `ULTIMATE_VAPID_PRIVATE_KEY`, never config. `x vapid create` mints a P-256 pair and seals BOTH into `secrets.enc.json` in one write; `x vapid show` names the pair in force. Locally, neither set signs with a published development pair; a deployed boot refuses it, half a pair, or two halves that do not sign for each other (`X_PWA_VAPID_KEY_MISSING`, `X_PWA_VAPID_KEY_INVALID`) — at boot, before any service starts | [CLI reference](/cli-reference#x-vapid) |
| subscriptions | `x_push_subscriptions`, a framework table every boot applies; keyed by endpoint (a browser subscribed by a second person is theirs now), with the locale and zone it subscribed in | `@ultimat3/pwa/schema` |
| subscribe | `pushSubscribe({ permission })` / `pushUnsubscribe({ permission })` — factories returning real actions an app exports from its own actions module: route, OpenAPI, typed client, contract tests, the one policy path. Stored against `ctx.actor.id`; an agent is refused | `apps/web/app/<slice>/actions.ts` |
| the button | `subscribeToPush({ save: client.subscribePush })` from `@ultimat3/pwa/client` (an import-free subpath an island can afford), on a click; answers `subscribed`, `denied`, `unsupported` or `unconfigured` | an island |
| send | `pushChannel({ pusher: webPush(), message })` in a notifier — every device of the recipient, each in its own locale; or `pushToActor(id, notification)` directly | [Notify](/notify#push) |
| the device | the emitted `push` handler shows `title`/`body`/`tag`/`actions` from the body; `notificationclick` focuses or opens the URL, same origin only | `sw.js` |

Push strings are catalog KEYS (`titleKey`, `bodyKey`), rendered server-side per subscription locale —
the sending server has no request to read a language off. One message is one 4096-byte record
(`X_PWA_PUSH_PAYLOAD_TOO_LARGE` past 3993 bytes of notification): send a path to the content, never
the content. `ttlSeconds` (default a day), `urgency` and `topic` (the push service's collapse key)
travel as RFC 8030 headers.

## What is checked, and where

`x verify` has **no PWA step** — its 21 steps are typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, exercise, e2e, eval, drift, contract-diff, budgets, seo, i18n, policy, manifest, roadmap. The two PWA checks that ship run in **`x doctor`** ([`packages/cli/src/cmd-doctor.ts:129`](https://github.com/developerz-ai/ultimate/blob/main/packages/cli/src/cmd-doctor.ts)), and report `@ultimat3/pwa`'s own codes rather than CLI twins of them.

| Check | Where | Fails on | `As of 2026-08` |
|---|---|---|---|
| Source icon present | `x doctor` | the 1024×1024 source PNG is missing, so install icons and OG images cannot be generated (`X_PWA_ICON_MISSING`) | **shipped** |
| Offline fallback route present | `x doctor` | no `site/` route serves the configured `pwa.offline.fallback`, so an offline navigation lands on the browser error page (`X_PWA_NO_OFFLINE_FALLBACK`). Judged against the route table (`describePages()`), never against a filename — it named `apps/web/app/offline.tsx`, which is not a route file at all, so the check was red for every app the framework ever produced and no invocation could clear it. `site/` only: `app/` allows `stream \| ssr`, only a `static` route is prerendered, and the worker precaches a rendered document — an `app/` fallback has none | **shipped** |
| SW scope | `@ultimat3/pwa` | the service-worker scope cannot serve the routes it precaches (`X_SW_SCOPE_INVALID`) | **shipped** |
| SW checksum | — | nothing computes one; `X_SW_HAND_EDITED` is reserved | **not built** |
| Strategy coherence | — | an `offline` value contradicting the route's `render` is accepted; `X_SW_UNCACHEABLE` is reserved | **not built** |
| Precache budget | — | no step reads precache bytes | **not built** |
| Retention config | `@ultimat3/core` | `defineConfig` refuses an `assets.retain` of the wrong shape or with `builds` under 1 (`X_CONFIG_INVALID`); the web role checks `disk` against the app's `defineStorage({ disks })` when it boots | **shipped** |
| Build ID present | `@ultimat3/pwa` | `generateServiceWorker` was handed an empty build id, so caches cannot be keyed and skew cannot be detected (`assertBuildId`) | **shipped** |
| Build ID *shape* | — | only emptiness is rejected. A timestamp or `latest` is accepted, and both defeat the immutable-namespace guarantee | **not built** |
| Offline fallback declared | `@ultimat3/pwa` | no `offline` block, or one with no `fallback` route — `generateServiceWorker` refuses with `X_PWA_NO_OFFLINE_FALLBACK` rather than building a worker that falls through to the browser error page | **shipped** |

`x test e2e` additionally asserts SW install, offline fallback rendering, and the version-skew reload path in a real browser. See [Testing](/testing).

## Rules

- Never hand-edit `sw.js`. Change the route, rebuild. **Convention, not a rule** — nothing checks it `As of 2026-08`.
- Never cache an authenticated response without an explicit `offline` field on the route.
- Never use a timestamp or `latest` as a build ID. **Convention, not a rule** — `assertBuildId` rejects only an empty one.
- Never force-reload a user without a grace period, except on a `--critical` deploy.
- Never cache a mutation. Offline writes are the tier-3 mutator queue's job.
- Never ship a PWA without an `offline.fallback` route — `generateServiceWorker` refuses (`X_PWA_NO_OFFLINE_FALLBACK`). A runtime refusal at build time, not a type error: `OfflineConfig` is passed as a `Partial`.
