Skip to content

fix(web): worker update consent, keel.web strict, and the write boundary as a module - #555

Merged
eaitbrahim merged 2 commits into
mainfrom
chore-pwa-arch
Aug 26, 2026
Merged

fix(web): worker update consent, keel.web strict, and the write boundary as a module#555
eaitbrahim merged 2 commits into
mainfrom
chore-pwa-arch

Conversation

@eaitbrahim

@eaitbrahim eaitbrahim commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Three skills run against keel/web: pwa-best-practices, python-pro, improve-codebase-architecture.
Two real bugs in last week's code, one Any leak, and one module split — with the architecture
chosen to match youperiod.app, which the design spec
already names as its reference.

1. The worker took over a page it did not render

install called skipWaiting() unconditionally and activate called clients.claim(). The
argument for that was written into the module: the version-keyed cache makes it safe — new worker,
new cache, old one deleted. Sound about caches, silent about the page already on screen.
skipWaiting + claim takes over a document rendered by the old build and answers its later
requests from the new build's cache.

keel had a second line of defence that made it hard to notice — a new build means a restarted
process, a new session token, and a 403 the banner reports — so the window was narrow and loud
rather than wide and quiet. A hazard covered by an unrelated layer is not a hazard dealt with.

The worker now waits; the footer offers "A newer build is ready — reload"; only a click sends
SKIP_WAITING, and the reload waits for controllerchange so the takeover lands first. The offer
is in the footer, not the engine banner — that banner is the page's one aria-live region and
answers "is keel running".

Driving that fix found a second bug in it: watchForUpdate returned early with no controller,
so on a first visit — the one load where there reliably is none — the updatefound listener was
never attached. The controller check belongs on the offer, not the watching.

Also checked from the same list: id was changed /static// in #540, which orphans installs.
No release has ever shipped the manifest, so it orphaned none. Now that one has, it's frozen.

2. keel/web is --strict, and was two errors away

Both errors were one defect: three-argument type() returns plain type, leaking an Any out of a
function annotated -> KeelServer, under a # type: ignore[return-value] that did not name the
error mypy was reporting
(no-any-return) — a suppression that silences nothing while looking
like it does. Rewritten as nested subclasses; keel.web.* is now gated strict, which is where it
matters most since all of it is reachable from a socket.

test_strictly_typed_packages_ship_a_py_typed_marker caught the consequence, which is the pin
working: PEP 561's marker is a promise to whoever installs a distribution, and keel.web is an
internal subpackage with no installer. A marker at keel/py.typed would promise a guarantee for
keel.*, still ignore_errors — what that test's own docstring warns against. It now reads
distribution roots, with a mutation guard so the filter can't quietly empty its own parametrise
list.

3. The write boundary is a module

The spec's reference client is five modules with one job each: main.js attaches event listeners,
data-manager.js owns the storage boundary behind a get/set pair. keel was built to that shape
api.js the single fetch wrapper, render.js the only view builder, format.js the helpers,
js/external/ deliberately empty where the reference admits three vetted libraries.

Then #540 gave the browser a write surface and left all of it in main.js: the token, the submit
handler, the outcome memory, the sentence-picking. That's the module the reference keeps emptiest —
the read path had a boundary and the write path did not.

js/actions.js is keel's data-manager.js. Callers need a key and the operator's answers; they no
longer need to know about the HMAC token, that it arrives on /api/setup alone, that changed
distinguishes "done" from "already done" rather than signalling failure, or which field carries the
sentence. main.js's listener is now what that file is for: read the form, show the answer, repaint.

The token is pinned by where it's held, not where the word appears. api.js names it because
it's the parameter it puts in a header — the only module allowed to open a connection — but keeps
nothing. Two assertions had to be rewritten before they were true rather than merely red: the header
name is a string literal that _code_only blanks, and actions.js names the header in a comment
listing what it hides, so the honest check is behavioural — the boundary assembles no request at all.

And a bug the move removed rather than fixed: the outcome memory was a Map<string, Node>, and
a DOM node lives in one place at a time, so every restore had to cloneNode or the second rebuild
moved the only copy out of the map's reach. It's a Map<string, string> now; that bug can no longer
be written.

Verification

ruff, mypy and the full suite green (4271 passed, 3 skipped), 9 new tests.

In a real Chromium — none of the below is provable by reading files:

step result
first visit worker controls, no offer (a first install is not an update)
simulated upgrade new cache built, worker waiting, page still on the old build
the offer appears in the footer
clicking it controller swaps, page reloads, view renders
steady state one cache, nothing waiting, no offer
write path after the extraction configdatabase on a fresh deployment, real files written, idempotent re-run reports "already done — nothing to change", outcomes survive a poll

On the audit scripts

audit-service-worker.ts still reports three failures, and all three are one blind spot:
DEFAULT_SOURCE_DIRS is ["app","src","components","lib","pages"], so it never sees
keel/web/static/js/. It reports "no registration" and "no update UI" for things that exist and are
verified above; --root keel/web/static doesn't help, since js/ isn't in that list either. The
checklist earned its place — sw-update-ux-missing was read straight out of sw.js and was real —
but the scripts can't verify a fix in a repo laid out this way.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2

eaitbrahim and others added 2 commits August 25, 2026 19:55
…ed, and keel.web is strict

Two defects in code shipped last week, both found by running a checklist and a
type checker against it rather than by reading it again.

── THE WORKER TOOK OVER A PAGE IT DID NOT RENDER ──────────────────────────────

`install` called `skipWaiting()` unconditionally, and `activate` called
`clients.claim()`. The argument for that -- written into the module -- was that
the version-keyed cache made it safe: new worker, new cache, old one deleted.
That reasoning is sound about CACHES and silent about the page already on screen.
`skipWaiting` + `claim` takes over a document parsed and rendered by the OLD
build's JavaScript and answers its later requests from the NEW build's cache. One
page, two builds, nothing on screen to say so.

keel had a second line of defence that made it hard to notice: a new build means
the process restarted, which means a new session token, which means every
`/api/*` call from the old page is a 403 the banner reports. The window was
narrow and loud rather than wide and quiet. It was still a window, and a hazard
covered by an unrelated layer is not a hazard that has been dealt with.

Now the new worker installs its cache and STAYS WAITING. `main.js` notices, the
footer offers "A newer build is ready -- reload", and only a click sends
`SKIP_WAITING`. The reload waits for `controllerchange` rather than firing
straight after the message, because the takeover has to land first or the reload
fetches the old build again and leaves the offer standing.

The offer is in the footer beside the build line, NOT in the engine banner: that
banner is the page's one `aria-live` region and it answers "is keel running", so
an upgrade notice there would interrupt a screen reader mid-table to say
something that can wait indefinitely.

── AND A SECOND BUG, FOUND BY DRIVING THE FIRST FIX ───────────────────────────

The first spelling of `watchForUpdate` returned early when there was no
controller -- meaning that on a FIRST visit, the one load where there reliably is
none, the `updatefound` listener was never attached at all. The controller check
belongs on the OFFER (do not announce a first install as an update), never on the
watching. Found in a browser, not in review.

── `keel/web` IS `--strict`, AND WAS TWO ERRORS AWAY ──────────────────────────

Measured rather than assumed: `mypy --strict keel/web` reported two errors, both
in `build_server`, and both the same defect. Three-argument `type()` is declared
to return plain `type`, so it produced an `Any` that flowed out of a function
annotated `-> KeelServer` -- under a `# type: ignore[return-value]` that did not
name the error mypy was actually reporting (`no-any-return`), a suppression that
silences nothing while looking like it silences something.

Rewritten as nested subclasses: the types are checked rather than asserted, and
`keel.web.*` now carries the strict flag block. It is the layer where that
matters most -- everything under it is reachable from a socket.

`test_strictly_typed_packages_ship_a_py_typed_marker` caught the consequence
immediately, which is the pin working: PEP 561's marker is a promise to whoever
INSTALLS a distribution, and `keel.web` is an internal subpackage with no
installer of its own. The rule now reads distribution ROOTS, with a mutation
guard so the filter cannot quietly empty the list it parametrises over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2
…ient's own shape

`keel/web/static/js/actions.js`: this client's `data-manager.js`.

── WHOSE SHAPE, AND WHY IT IS THE RIGHT ONE TO COPY ───────────────────────────

The design spec's reference implementation is youperiod.app, and `js/external/`'s
README already names it. Its client is five modules with one job each: `main.js`
attaches event listeners, `data-manager.js` owns the storage boundary behind a
`get`/`set` pair, `utils.js` holds the helpers. keel's client was built to that
shape -- `api.js` the single `fetch` wrapper, `render.js` the only view builder,
`format.js` the helpers, `js/external/` deliberately EMPTY where the reference
admits three vetted libraries.

Then #540 gave the browser a write surface and put all of it in `main.js`: the
session write token, the submit handler, the memory of what each action reported,
and the choice of which server field to show. That is the module the reference
keeps emptiest, and it had quietly grown a second job -- the read path had a
boundary module and the write path did not.

── WHAT THE BOUNDARY HIDES ────────────────────────────────────────────────────

A caller needs an action's key and the operator's answers. It no longer needs to
know that a write carries a session-scoped HMAC token beside `X-Keel-Client`,
that the token arrives on `/api/setup` and nowhere else, that the result
distinguishes "done" from "already done" through a `changed` flag rather than an
error, or that the sentence comes from `data.message.display` on success and
`error.detail` on refusal.

`main.js`'s listener is now what that file is for: read the form, show the
answer, repaint.

── THE TOKEN HAS ONE HOME, AND THE TEST SAYS SO IN THOSE TERMS ────────────────

Pinned as "where is it HELD", not "where does the word appear". `api.js` names it
because it is the parameter it puts in a header -- that module is the only one
allowed to open a connection -- but it takes it as an argument and keeps nothing.
`actions.js` is the one module with a variable holding it between calls. A
credential that acquires a second home is one the next thing added there will
read.

Two assertions had to be rewritten before they were true rather than merely red:
the header name is a string literal, so `_code_only` (which blanks literals)
cannot see it, and `actions.js` NAMES the header in a comment listing what it
hides -- so the honest check is behavioural, that the boundary assembles no
request at all.

── A BUG THE MOVE REMOVED RATHER THAN FIXED ───────────────────────────────────

The outcome memory was a `Map<string, Node>`, and a DOM node can be in one place
in a document at a time -- so every restore had to `cloneNode` or the second
rebuild would move the only copy out of the map's reach. It is a
`Map<string, string>` now. The bug that invited is not fixed; it can no longer be
written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2
@eaitbrahim eaitbrahim changed the title fix(web): the worker no longer takes over a page the old build rendered, and keel.web is strict fix(web): worker update consent, keel.web strict, and the write boundary as a module Aug 26, 2026
@eaitbrahim
eaitbrahim merged commit 0d11c36 into main Aug 26, 2026
0 of 3 checks passed
@eaitbrahim
eaitbrahim deleted the chore-pwa-arch branch August 26, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant