← stitchkitDocumentation

0159 — A replacement says so, and a teardown is announced

Decision

ApplicationSnapshot carries restarting: readonly string[] — the subtree a restart is replacing at this instant, empty between restarts — and ApplicationStatusProjection carries restarting: number.

WatchHub.close() announces phase: 'unavailable', reason: 'source-unavailable' to every subscriber before it drops its sources.

Why the snapshot and not the lifecycle

The obvious shape was a restarting member of ApplicationLifecycle, and it is wrong for a mechanical reason rather than a stylistic one: acquire() refuses a lease unless isReady() holds, and isReady() requires lifecycle === 'ready'. A restarting lifecycle would therefore close admission for the whole graph while one leaf is replaced — contradicting the single promise a subtree restart exists to make, and doing it in the one place ADR 0154 argues hardest.

A ManagedResourceState member fails differently and more quietly: the start loop overwrites the record with starting, so the marker would describe the closing half of the window and vanish for the rest. It would also be invisible where it is needed, because projectApplicationStatus counts ready, degraded and state === 'failed' and would not count a new member at all.

A field costs one write, covers close, start and activate, widens no enum, and breaks no reader of either enum.

And the same defect arrived through the other door anyway

The first implementation of this ADR also marked the affected records stopping before the close sweep, so the closing half of the window would be visible per resource. That reintroduced the exact failure the paragraph above rejects the lifecycle member for — because isReady() has two gates, not one: the lifecycle, and every record’s state. Measured on a two-resource graph, restarting one leaf:

before with the marking
admission.acquire() lease granted null
snapshot.ready true false
snapshot.health healthy unhealthy

The marking is gone. restarting is the signal; nothing about a restart changes what the rest of the graph may accept.

The lesson is not “check isReady()”. It is that this ADR wrote down a mechanism, correctly, and then the implementation walked into it through the other of its two doors — and the test did not catch it because it used a single-resource graph, where “the application went unready” and “the resource being replaced went unready” are the same observation.

Why a probe gets a count and a snapshot gets the ids

getSnapshot() already names every resource and its dependsOn edges — it is the application’s dependency graph, and it is not published. The status projection is meant to be mounted publicly, so it gets the number. Zero versus non-zero is the whole question a probe has: is this resource missing because it broke, or because we are replacing it right now.

Why the hub says something

close() used to clear its sources without a word. Every subscriber kept the last value it was sent, at phase live, forever — a stale value standing as current, which is the state ADR 0153 spends most of its length arguing against.

It survived because the hub used to close when the process did: the socket died with it and the client recovered through onConnectionChange. A subtree restart closes the hub while the connections are still up, which turns the impossible case into the ordinary one.

unavailable / source-unavailable is the pair the client already publishes on a dropped connection, so the browser needs no new branch. closed would need one, and would tell a live page to stop retrying.

A subscriber’s failure is its own

Every call into a subscriber — the replay a late open gets, the value broadcast, every state announcement — goes through one isolating helper. They were direct calls, and a subscriber that threw took out whatever the hub was in the middle of: the rest of a broadcast never heard it, and on the teardown path their unsubscribes never ran either. The kernel already isolates its own snapshot listeners for exactly this reason.

Rejected: telling a dependant its dependency was replaced

The tempting follow-on is a hook letting a resource survive a dependency’s replacement instead of being restarted with it. It does not work, and the reason is worth writing down.

A watch hub’s read, invalidatedBy and subscribe are closures the application supplies. If read captured the old handle, telling the hub changes nothing — the closure still points at the dead thing. An application that writes read as a late lookup already survives, today, with no framework change at all.

And it would break what ADR 0154 rests on: context.use() reads the published value at call time, and there is no channel to push a new handle into a resource that is not itself being restarted. Adding one means a new ManagedResource method — precisely the per-consumer burden ADR 0154 refused.

What already exists is the topic bus: the restarted resource announces, and every source whose invalidatedBy names that topic re-reads.