You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Put identity checks, sequencing and cleanup inside the functions that own the work. Callers should be able to retire a daemon or finish a session resource with one operation, without repeating the safety rules.
Priority: registration and the daemon lock first, session lifetimes second. The payoff is correctness and fewer caller obligations. Changing classes to factories is optional.
Status: completed and landed.#3186, #3135, #3140, #3144, #3152, #3155 and #3127 are merged; inclusion is verified in main at 2212eacf5be976acc78378a7e65539be5fc81489. The implementation record retains helpers, diagrams, exact-head gates, review and native evidence.
The contract below preserves the accepted baseline and inventories. Physical CoreDevice recording-health verification was not run because Xcode signing was unavailable; review accepted this recorded risk. Request-scoped timeout recovery remains separate in #3177.
Baseline: main at c237027737, verified 2026-10-02. The cited source files are unchanged from e4716e6. Recheck the inventories against each implementation base.
Make each function own the whole operation
The repeated problem is an address passed separately from the resource: registration paths plus a process identity, or a session name plus a record. Bind them at the owning operation.
Process proof, stop policy, lock acquisition, metadata cleanup and result
Daemon lifecycle
Registration functions bound to its acquired lock
Publication, shutdown reports, own metadata retirement and release
Routes and session lifecycle
Recording, resource and retirement functions taking SessionRef
Lifetime check, latest record, field transition and artifact address
Device-claim recovery
Artifact path functions
Validation and path layout, without constructing a session store
flowchart TD
C["Client lifecycle"] --> R["Shared registration jobs"]
D["Daemon lifecycle"] --> R
R --> H["host-kit process identity and lock"]
Q["Routes and session lifecycle"] --> J["Recording and resource jobs"]
J --> S["One session lifetime owner"]
J --> A["Artifact paths and journal operations"]
O["Device-claim recovery"] --> A
Loading
Use ordinary functions and composition. Keep private state where ownership needs it, in a small class or factory. Keep adapters that translate records or restrict authority; replacing one broad store with several broad objects does not reduce caller obligations.
Retire a daemon safely
Deepen the existing registration module into a shared root module, such as src/daemon-registration.ts. It already distinguishes matching, replaced, unproven, absent, unreadable and ownerless records. Client runtime imports from daemon internals are forbidden, so this implementation must sit above both.
Move takeover, replay shutdown, failed-startup cleanup and timeout reset to the shared operations. Timeout reset starts a fallback stop without awaiting it, then removes metadata. Awaiting the existing stop function is also insufficient: it can decline signaling or exhaust its exit wait.
Finalize the observation, private-directory capability, partial-failure details and error mapping before implementation. absent means metadata is absent under the lock; it does not prove process exit or authorize directory removal.
The function owns signaling and configured TERM/KILL wait budgets:
Verify identity, send TERM, wait, escalate to KILL for the same lifetime, await exit
Abandoned-registration recovery
No stop mode
Prove abandonment; never signal a live process
No caller pre-signals or starts a fire-and-forget fallback.
Use one retirement sequence
Confirm exit. Validate the observed process lifetime before signaling. Proven death skips signaling. Missing process start time, unreadable liveness, failed signaling without independent exit proof, or exhausted waits retain metadata and the owned directory.
Acquire the startup lock. Use a bounded wait; host-kit alone reclaims proven dead claims. Lock release by a still-running daemon is insufficient.
Re-read and retire under the lock. Remove daemon.json only if it still names the confirmed dead identity. Retain replaced, unreadable or unproven metadata.
Release this acquisition. Report success only when the postconditions are confirmed. Keep typed partial outcomes for metadata or release failures.
Abandoned-registration recovery uses the same protected core. It never rediscovers and stops a live winner. Even an absent result requires acquisition and inspection under the lock.
sequenceDiagram
participant C as Client retirement job
participant P as Observed daemon lifetime
participant L as Shared registration lock
participant M as Daemon metadata
C->>P: Verified stop and awaited exit
P-->>C: Confirmed termination
C->>L: Acquire; host-kit recovers dead claim
L-->>C: Held acquisition
C->>M: Read current registration
alt Record names the dead identity
C->>M: Remove while lock is held
else Replaced or unproven
Note over C,M: Retain current metadata
end
C->>L: Release this acquisition
Loading
Process identity permits signaling and matching a record. Lock possession prevents concurrent mutation. Both are required; checking identity and then unlinking without the lock leaves a race.
Bind daemon writes to its acquired lock
Daemon-side registration functions close over the actual acquisition. They assert it is still held immediately before each metadata/report mutation. Never reconstruct release authority from a path or PID.
Report clearing happens before daemon.json exists, so metadata ownership cannot authorize it. A released or lost acquisition cannot write or clear a report. Keep report readers, contents and best-effort reporting, and preserve primary errors plus normalized hint, diagnosticId, logPath and typed details.
Retire only the private replay directory you own
The directory capability comes from private mkdtemp creation and binds to its startup/daemon attempt. Before deletion:
Confirm that daemon exited and all owned startup attempts completed.
Acquire the registration lock inside the directory.
Inspect repair evidence and retain held repair sessions, active close-less replay sessions and unrecovered repair-commit-failure evidence.
Remove the eligible directory while holding the lock, then release.
Use the same lock for exclusion. This authority does not permit recursive deletion of a shared state directory. The immediate defect is deleting a private replay directory beneath its still-running daemon; normal successor occupancy has not been established.
Relaunch startup after a transient holder releases
Failed-startup cleanup stays bound to its own attempt and cannot stop or remove a winning contender. The existing startup wait handles another daemon as holder; the replacement must also handle a client holding the lock during retirement:
A child that loses nonblocking acquisition reports a dedicated lock-busy disposition through a shared named exit code and monitored launch result. Generic early exits, clean exits and stderr text are not contention proof.
Join the losing child. Wait while inspection says held/publishing, regardless of the holder's role. Unknown inspection retains state and reports diagnostics.
Attach if a winner publishes a usable registration, preserving version, code-signature and policy admission.
If the claim is released or proven dead without a usable registration, launch a fresh attempt. Only acquisition reclaims dead claims.
Keep the same startup deadline and existing bounded attempt budget across relaunches. Join every loser; exhaustion returns a typed failure and leaves the winner untouched.
Do not add daemon/retirement roles to host-kit's generic owner record.
After migration, delete raw info/lock removal exports, cleanupStaleDaemonLockIfSafe and recoverDaemonLockHolder. Every metadata/report mutation belongs to a held registration owner; every lock reclaim belongs to host-kit acquisition.
A second contender unlinks the first contender's incomplete JSON publication.
Two contenders read an abandoned claim; the second unlinks the first's replacement claim.
These are demonstrated unsafe interleavings, not frequency measurements. Use one host-kit process lock for the daemon's whole lifetime, with acquisition identity for reclaim/release.
A pause beyond publication grace cannot let an old claimant overwrite a successor. A reclaim mutex cannot be stolen solely because its live holder paused. Specify and prove the algorithm.
Nonblocking startup acquisition
Make one real attempt with typed acquired/busy/unproven outcomes and no polling or sleep. timeoutMs: 0 makes zero attempts in the existing loop. Bounded client acquisition uses the same protected reclaim mechanism.
Read-only holder inspection
Report held, publishing, absent and unreadable/unknown claims, with available liveness facts. A claim may precede metadata. Keep parsing/layout private; inspection does not authorize unlinking.
Lock ownership assertion
Supply an acquisition-bound assertion for registration/report mutations alongside the release function.
Unknown-liveness handling
Retain the claim and emit a typed reason plus an operator hint: restore inspection or stop a verified owner, then retry. Manual recovery requires external confirmation that all users of the state directory stopped. No automatic age-only or forced-delete fallback.
isAgentDeviceDaemonProcess(...) === false also covers missing start time, missing command and unreadable identity. Reclaiming on that value is unsafe. Blocking uncertain holders with diagnostics is a deliberate behavior change.
Remove the duplicate server lock type and client lock parser at cutover. Lock version has no reader and startedAt is parsed but unused; do not copy them into host-kit. Preserve daemon.json version/code-signature negotiation.
Cutover is settled in ADR 0030. Hardened daemon acquisition uses a directory at the same daemon.lock path used by the legacy exclusive file. It refuses an existing legacy file; legacy acquisition cannot unlink the directory. Real-child tests cover already-running and concurrently-starting legacy daemons. Mixed host-kit protocol access remains unsupported: stop legacy users before upgrading, and keep a single deployed version or separate environments. An uncertain mutation guard is retained; external confirmation that all users stopped is required for manual recovery.
The proven host-kit primitive, inspection/assertion APIs and cutover are integrated in the registration stack.
Keep session work tied to one lifetime
SessionRef captures a stored address and a mutable record. Lookup/list/find return fresh wrappers; rebuilding a record does not retarget an older wrapper.
Keep three concepts distinct:
Concept
Meaning
Address
The store key, such as cwd:<hash>:default; use it for paths, journals and store operations
Lifetime
One occupant of that address, from publication to retirement
Record
That lifetime's session state, which can be rebuilt
Repeated successful open rebuilds the same lifetime. Keep its resources, recording handle, actions, creation time, claims and idle-expiry handling. Preserve record construction and the second-open script-authoring abort rule, which differs from screen recording. Delete followed by publication at the same address creates a new lifetime. No inventoried caller needs a separate occupied-address/new-lifetime replace operation.
Store one stable entry per lifetime
Replace the existing map with stable entries. Each ref carries the entry's opaque identity and its captured record; .session stays a captured record, not a live getter.
Keep entry contents and construction private. This is the canonical map, with no second registry or generated generations.
The store owns three operations:
Publish: refuse an occupied address and honor daemon shutdown/admission.
Update: require the captured lifetime; derive from its latest record; never insert a missing entry.
Retire: check that lifetime before removing the record and its runtime hints.
stateDiagram-v2
[*] --> Draft
Draft --> Live: publish at the existing adoption point
Draft --> [*]: preparation or adoption fails
Live --> Live: rebuild record or reopen app
Live --> Retired: guarded deletion
Retired --> [*]
note right of Retired
Address reuse creates a new lifetime.
Old work gains no authority over it.
end note
Loading
Put lifetime checks inside session operations
Recording, resource finish and teardown accept one ref. They resolve the latest matching record, run the owning field transition and use ref.address internally.
Recording owns recording transitions and journal append.
Resource functions bind their capture read/write/path adapters internally.
Retirement keeps hint cleanup, repair outcomes and idle markers tied to the captured lifetime.
Old work cannot gain authority by looking up its address again. Audit refs held by request binding, lock policy, replay, inventory and asynchronous cleanup. Read projections may intentionally use the captured record; durable writers resolve the matching current record inside their owning functions.
Cleaning up an old captured resource can continue after retirement. Guard the store mutation separately: clearing a slot requires both the lifetime and matching resource identity. Preserve durable resource fences. If required capture adoption loses its lifetime, run existing failed-adoption disposal/recovery; do not report success or overwrite successor evidence.
Keep publication points unchanged:
Session path
Publication point
Record-only capture
After successful adoption
Sessionless snapshot
After capture
Provisional open
Existing before-dispatch point
Shutdown can overlap canceled handlers and timed-out cleanup. Updates cannot resurrect a retired entry. An unpublished draft also needs the existing shutdown/admission owner's check before first publication; entry identity alone cannot prevent that late publication.
Preserve execution locks, idle-reaper lock order, shutdown order, wire errors, hints and artifact naming. Keep close and teardown sequencing separate: platform close, browser cleanup and materialized paths differ.
Keep field ownership enforceable
Use one lifetime-checked update for published-record replacement. Field owners pass explicit patches; the store merges them into the latest matching record:
updateSession(ref,{appLogFailure: undefined});
Keep this operation private to the owner/adapters. Domain callers use functions such as clearSessionAppLogFailure(ref). Holding a ref does not authorize every field. Existing owner-controlled in-place transitions remain in their domain functions and resolve the matching latest record.
Check top-level update keys against SESSION_STATE_FIELD_OWNERS. Accept an explicit patch literal or inline synchronous callback returning explicit named keys. Reject computed keys, patch spreads and opaque patch variables.
Allow whole-SessionState spreads from an existing record only inside session-store.ts and declared draft constructors for open, sessionless snapshot and record-only capture. Existing-session branches use patches. Nested value spreads, such as a lease, remain allowed.
A callback reads the latest record when derivation needs it. Keep it synchronous and non-reentrant: one ownership check is enough. Do not add asynchronous callbacks or general value tracing.
Migrate sessionSlot.replace whole-session copies to explicit slot patches through a ref-bound capability. Capture-kit supplies the resource transition; the daemon adapter supplies its named field update. Do not bypass the update with a constructed whole record.
R7 fixtures must reject the existing app-log spread, unauthorized failure patches, computed keys, spread patches and opaque patches; pass the declared owner's explicit patch; and retain direct-assignment checks.
Migrate the callers together
There are 19 production session-record set sites: 16 in the daemon and three in capture-kit. Seven re-set the same object, eight rebuild it, and four mix creation with rebuilding. There are six direct delete callers. Exclude tests, runtime hints and internal Map operations.
Preserve platform/resource teardown, provider/lease/claim handling and error reporting before guarded record/hint removal
The two Map.delete operations inside SessionStore.delete remove hints and the record; they are not extra callers.
Pair all seven re-set removals with their owner-write migration. A stale re-set loses an intervening rebuild. Removing it alone loses the later mutation on a detached object instead. Route the write through the latest matching record, remove the re-set, and prove both updates survive. Keep all seven in the session migration.
The boundary changes are:
Capture-kit:DurableCaptureSessionStore is only a narrowed type over the full store. Replace that dependency with a ref-bound read/write/path adapter that reports retired/resource-changed outcomes. Give drafts a separately admitted publication capability at the existing adoption point.
Late updates: remove get(address) ?? oldRecord from perf and teardown:34,211,226. It can reinsert a deleted record or adopt a successor. App-log patches must also preserve intervening resource updates.
Lease expiry:teardown and deletion use session.name instead of the lookup's stored params.sessionName. Use ref.address for paths, slots and deletion; public name stays for display. Test a scoped leased address plus a separate slot under its public name. This is a helper-contract defect; production reachability with unequal names remains unproved.
Remove unrestricted upsert and resolveStoredSessionName after migration. Its object-identity scan and public-name fallback must not survive as compatibility. A rebuilt cwd-scoped default must use cwd:<hash>:default for journals, without creating a phantom public-name record.
Reduce the store interface in this change. Convert it to a factory only if that helps the resulting composition. Keep runtime projection, ref-frame authorization and capture merging adapters that still own useful behavior.
Land independent cleanups
These do not gate registration or include the seven re-set removals:
Move canonical session-directory/app-log path functions into src/daemon/session-artifact-paths.ts. Device-claim recovery constructs a store only for these paths; remove that dependency while preserving validation, stored addresses, layout and errors.
Replace SessionStore.expandHome with host-kit's expandSessionPath and remove the forwarder.
Optionally replace SessionScriptWriter with writeSessionScript(sessionsDir, session, options). It stores one directory and its only production user is SessionStore. Keep formatting, no-clobber/force behavior, commit transitions, diagnostics, failure routing and public-name-based default filenames together.
Prove the behavior
Use deterministic interleavings and real temporary filesystem/process tests for exclusion and exit guarantees. A Map-only test cannot prove filesystem exclusion.
Registration regression checklist
Scenario
Required result
Partial publication or two stale-lock reclaimers
At most one owner succeeds; loser cannot remove winner
Claimant/reclaimer pauses beyond grace
Cannot overwrite/release successor or lose reclaim mutex merely through age
Free, held and unproven nonblocking acquisition
One real attempt; no polling/sleep; typed outcome
Old daemon running or starting concurrently
Enforced cutover policy; no split exclusion or legacy stray reclamation
Replaced/unreadable metadata or unproved process start time
Retain/refuse; no unproved-owner signaling or destructive cleanup
Unknown lock identity/liveness
Retain state; typed reason and operator hint; no age-only fallback
Force reset versus graceful takeover
Operation owns verified KILL-first versus TERM-then-KILL and awaits exit; callers do not pre-signal
Signal failure or exhausted exit wait
Keep metadata/private directory unless independent death is proved
Losing startup attempt
Cleanup cannot stop/remove winner
Startup meets a client-held retirement lock
Typed busy; join loser; wait regardless of role; attach or relaunch with original deadline/attempt bounds
Non-contention early exit
Existing failure policy; no false busy classification or winner cleanup
Winner publishes after old daemon exits
Retirement re-reads under the lock and retains winner metadata
Startup clears report before metadata publication
Held acquisition authorizes clearing
Released/lost acquisition mutates report
Refused; successor report remains intact
Guarded private replay-directory deletion
Confirm exit and completion of owned startups; retain repair evidence; release does not recreate path
Regression tests must fail when the relevant ownership check is removed or cleanup is made unconditional. Plant the concrete forbidden app-log spread for R7. Colocate tests with source and retain existing tests for pure moves.
Each slice runs focused checks and pnpm check:affected --run on its exact head, plus the full deterministic gate for a broad refactor as repository guidance requires. Preserve CI-owned provider/native/device checks. Report production additions/deletions separately from tests and moves, and name the mechanism or caller obligation removed.
Migrate all 19 set sites, six delete callers, recording/disposal inputs and runtime/capture adapters. Pair the seven re-set removals with owner writes; preserve adoption timing and resource fences. Remove upsert/reverse lookup and shrink the store interface.
Complete the regression checklists and record exact-head validation, including real shutdown/cancellation and late cleanup.
Related work: #3102 already adds daemon metadata publication/removal fencing; deepen it. Coordinate timeout reset with #3105 and report mutations with #3104. Preserve the #2833 idle-reaper lock seam/order. This follows #3069 without reopening it or closing related issues by association.
The implementation stack is fully landed. The implementation record preserves the original branch order, exact-head validation, completed review findings and the physical recording-health limitation. Request-scoped timeout cleanup is tracked in #3177; it is outside the retirement-ownership contract completed here.
Out of scope: a generic request executor, selector-observation aggregation, global immutable-state migration, actors/manager frameworks and promised line-count/performance gains. Request finalization and diagnostics may be separate bounded follow-ups; they do not gate this work.
Put identity checks, sequencing and cleanup inside the functions that own the work. Callers should be able to retire a daemon or finish a session resource with one operation, without repeating the safety rules.
Priority: registration and the daemon lock first, session lifetimes second. The payoff is correctness and fewer caller obligations. Changing classes to factories is optional.
Status: completed and landed. #3186, #3135, #3140, #3144, #3152, #3155 and #3127 are merged; inclusion is verified in main at
2212eacf5be976acc78378a7e65539be5fc81489. The implementation record retains helpers, diagrams, exact-head gates, review and native evidence.The contract below preserves the accepted baseline and inventories. Physical CoreDevice recording-health verification was not run because Xcode signing was unavailable; review accepted this recorded risk. Request-scoped timeout recovery remains separate in #3177.
Baseline: main at c237027737, verified 2026-10-02. The cited source files are unchanged from e4716e6. Recheck the inventories against each implementation base.
Make each function own the whole operation
The repeated problem is an address passed separately from the resource: registration paths plus a process identity, or a session name plus a record. Bind them at the owning operation.
stopAndRetireDaemon/recoverAbandonedDaemonRegistrationSessionRefflowchart TD C["Client lifecycle"] --> R["Shared registration jobs"] D["Daemon lifecycle"] --> R R --> H["host-kit process identity and lock"] Q["Routes and session lifecycle"] --> J["Recording and resource jobs"] J --> S["One session lifetime owner"] J --> A["Artifact paths and journal operations"] O["Device-claim recovery"] --> AUse ordinary functions and composition. Keep private state where ownership needs it, in a small class or factory. Keep adapters that translate records or restrict authority; replacing one broad store with several broad objects does not reduce caller obligations.
Retire a daemon safely
Deepen the existing registration module into a shared root module, such as
src/daemon-registration.ts. It already distinguishes matching, replaced, unproven, absent, unreadable and ownerless records. Client runtime imports from daemon internals are forbidden, so this implementation must sit above both.Move takeover, replay shutdown, failed-startup cleanup and timeout reset to the shared operations. Timeout reset starts a fallback stop without awaiting it, then removes metadata. Awaiting the existing stop function is also insufficient: it can decline signaling or exhaust its exit wait.
Return proof and cleanup outcomes
The proposed boundary is:
Finalize the observation, private-directory capability, partial-failure details and error mapping before implementation.
absentmeans metadata is absent under the lock; it does not prove process exit or authorize directory removal.The function owns signaling and configured TERM/KILL wait budgets:
forcegracefulNo caller pre-signals or starts a fire-and-forget fallback.
Use one retirement sequence
daemon.jsononly if it still names the confirmed dead identity. Retain replaced, unreadable or unproven metadata.Abandoned-registration recovery uses the same protected core. It never rediscovers and stops a live winner. Even an
absentresult requires acquisition and inspection under the lock.sequenceDiagram participant C as Client retirement job participant P as Observed daemon lifetime participant L as Shared registration lock participant M as Daemon metadata C->>P: Verified stop and awaited exit P-->>C: Confirmed termination C->>L: Acquire; host-kit recovers dead claim L-->>C: Held acquisition C->>M: Read current registration alt Record names the dead identity C->>M: Remove while lock is held else Replaced or unproven Note over C,M: Retain current metadata end C->>L: Release this acquisitionProcess identity permits signaling and matching a record. Lock possession prevents concurrent mutation. Both are required; checking identity and then unlinking without the lock leaves a race.
Bind daemon writes to its acquired lock
Daemon-side registration functions close over the actual acquisition. They assert it is still held immediately before each metadata/report mutation. Never reconstruct release authority from a path or PID.
Preserve these sequences:
Report clearing happens before
daemon.jsonexists, so metadata ownership cannot authorize it. A released or lost acquisition cannot write or clear a report. Keep report readers, contents and best-effort reporting, and preserve primary errors plus normalizedhint,diagnosticId,logPathand typed details.Retire only the private replay directory you own
The directory capability comes from private
mkdtempcreation and binds to its startup/daemon attempt. Before deletion:Use the same lock for exclusion. This authority does not permit recursive deletion of a shared state directory. The immediate defect is deleting a private replay directory beneath its still-running daemon; normal successor occupancy has not been established.
Host-kit release already handles a missing owner file as
not-ownerwithout recreating the path. Add a regression test after guarded directory removal; no new release behavior is needed.Relaunch startup after a transient holder releases
Failed-startup cleanup stays bound to its own attempt and cannot stop or remove a winning contender. The existing startup wait handles another daemon as holder; the replacement must also handle a client holding the lock during retirement:
lock-busydisposition through a shared named exit code and monitored launch result. Generic early exits, clean exits and stderr text are not contention proof.Do not add daemon/retirement roles to host-kit's generic owner record.
After migration, delete raw info/lock removal exports,
cleanupStaleDaemonLockIfSafeandrecoverDaemonLockHolder. Every metadata/report mutation belongs to a held registration owner; every lock reclaim belongs to host-kit acquisition.Replace the daemon lock
The bespoke daemon lock permits both contenders to succeed:
These are demonstrated unsafe interleavings, not frequency measurements. Use one host-kit process lock for the daemon's whole lifetime, with acquisition identity for reclaim/release.
The host-kit primitive needs:
timeoutMs: 0makes zero attempts in the existing loop. Bounded client acquisition uses the same protected reclaim mechanism.isAgentDeviceDaemonProcess(...) === falsealso covers missing start time, missing command and unreadable identity. Reclaiming on that value is unsafe. Blocking uncertain holders with diagnostics is a deliberate behavior change.Remove the duplicate server lock type and client lock parser at cutover. Lock
versionhas no reader andstartedAtis parsed but unused; do not copy them into host-kit. Preservedaemon.jsonversion/code-signature negotiation.Cutover is settled in ADR 0030. Hardened daemon acquisition uses a directory at the same
daemon.lockpath used by the legacy exclusive file. It refuses an existing legacy file; legacy acquisition cannot unlink the directory. Real-child tests cover already-running and concurrently-starting legacy daemons. Mixed host-kit protocol access remains unsupported: stop legacy users before upgrading, and keep a single deployed version or separate environments. An uncertain mutation guard is retained; external confirmation that all users stopped is required for manual recovery.The proven host-kit primitive, inspection/assertion APIs and cutover are integrated in the registration stack.
Keep session work tied to one lifetime
SessionRefcaptures a stored address and a mutable record. Lookup/list/find return fresh wrappers; rebuilding a record does not retarget an older wrapper.Keep three concepts distinct:
cwd:<hash>:default; use it for paths, journals and store operationsRepeated successful open rebuilds the same lifetime. Keep its resources, recording handle, actions, creation time, claims and idle-expiry handling. Preserve record construction and the second-open script-authoring abort rule, which differs from screen recording. Delete followed by publication at the same address creates a new lifetime. No inventoried caller needs a separate occupied-address/new-lifetime
replaceoperation.Store one stable entry per lifetime
Replace the existing map with stable entries. Each ref carries the entry's opaque identity and its captured record;
.sessionstays a captured record, not a live getter.Keep entry contents and construction private. This is the canonical map, with no second registry or generated generations.
The store owns three operations:
stateDiagram-v2 [*] --> Draft Draft --> Live: publish at the existing adoption point Draft --> [*]: preparation or adoption fails Live --> Live: rebuild record or reopen app Live --> Retired: guarded deletion Retired --> [*] note right of Retired Address reuse creates a new lifetime. Old work gains no authority over it. end notePut lifetime checks inside session operations
Recording, resource finish and teardown accept one ref. They resolve the latest matching record, run the owning field transition and use
ref.addressinternally.Old work cannot gain authority by looking up its address again. Audit refs held by request binding, lock policy, replay, inventory and asynchronous cleanup. Read projections may intentionally use the captured record; durable writers resolve the matching current record inside their owning functions.
Cleaning up an old captured resource can continue after retirement. Guard the store mutation separately: clearing a slot requires both the lifetime and matching resource identity. Preserve durable resource fences. If required capture adoption loses its lifetime, run existing failed-adoption disposal/recovery; do not report success or overwrite successor evidence.
Keep publication points unchanged:
Shutdown can overlap canceled handlers and timed-out cleanup. Updates cannot resurrect a retired entry. An unpublished draft also needs the existing shutdown/admission owner's check before first publication; entry identity alone cannot prevent that late publication.
Preserve execution locks, idle-reaper lock order, shutdown order, wire errors, hints and artifact naming. Keep close and teardown sequencing separate: platform close, browser cleanup and materialized paths differ.
Keep field ownership enforceable
Use one lifetime-checked update for published-record replacement. Field owners pass explicit patches; the store merges them into the latest matching record:
Keep this operation private to the owner/adapters. Domain callers use functions such as
clearSessionAppLogFailure(ref). Holding a ref does not authorize every field. Existing owner-controlled in-place transitions remain in their domain functions and resolve the matching latest record.The R7 scanner checks assignments but misses whole-record spread overrides.
appLogFailureis declared store-established, yet the app-log owner writes it through spreads. Declareapp-log-session-resource.tsas its owner and keep resource-slot transitions in their field owner.Extend R7 with two syntax rules:
SESSION_STATE_FIELD_OWNERS. Accept an explicit patch literal or inline synchronous callback returning explicit named keys. Reject computed keys, patch spreads and opaque patch variables.SessionStatespreads from an existing record only insidesession-store.tsand declared draft constructors for open, sessionless snapshot and record-only capture. Existing-session branches use patches. Nested value spreads, such as a lease, remain allowed.A callback reads the latest record when derivation needs it. Keep it synchronous and non-reentrant: one ownership check is enough. Do not add asynchronous callbacks or general value tracing.
Migrate
sessionSlot.replacewhole-session copies to explicit slot patches through a ref-bound capability. Capture-kit supplies the resource transition; the daemon adapter supplies its named field update. Do not bypass the update with a constructed whole record.R7 fixtures must reject the existing app-log spread, unauthorized failure patches, computed keys, spread patches and opaque patches; pass the declared owner's explicit patch; and retain direct-assignment checks.
Migrate the callers together
There are 19 production session-record set sites: 16 in the daemon and three in capture-kit. Seven re-set the same object, eight rebuild it, and four mix creation with rebuilding. There are six direct delete callers. Exclude tests, runtime hints and internal Map operations.
Set inventory: all 19 sites and their migration
Delete inventory: all six callers and the policies to preserve
The two
Map.deleteoperations insideSessionStore.deleteremove hints and the record; they are not extra callers.Pair all seven re-set removals with their owner-write migration. A stale re-set loses an intervening rebuild. Removing it alone loses the later mutation on a detached object instead. Route the write through the latest matching record, remove the re-set, and prove both updates survive. Keep all seven in the session migration.
The boundary changes are:
DurableCaptureSessionStoreis only a narrowed type over the full store. Replace that dependency with a ref-bound read/write/path adapter that reports retired/resource-changed outcomes. Give drafts a separately admitted publication capability at the existing adoption point.createDaemonRuntimeSessionStoregetters and its three write projections to the captured lifetime. Screenshot's no-op setter is a read projection, not a twentieth set site.get(address) ?? oldRecordfrom perf and teardown:34,211,226. It can reinsert a deleted record or adopt a successor. App-log patches must also preserve intervening resource updates.session.nameinstead of the lookup's storedparams.sessionName. Useref.addressfor paths, slots and deletion; public name stays for display. Test a scoped leased address plus a separate slot under its public name. This is a helper-contract defect; production reachability with unequal names remains unproved.Remove unrestricted upsert and
resolveStoredSessionNameafter migration. Its object-identity scan and public-name fallback must not survive as compatibility. A rebuilt cwd-scopeddefaultmust usecwd:<hash>:defaultfor journals, without creating a phantom public-name record.Reduce the store interface in this change. Convert it to a factory only if that helps the resulting composition. Keep runtime projection, ref-frame authorization and capture merging adapters that still own useful behavior.
Land independent cleanups
These do not gate registration or include the seven re-set removals:
src/daemon/session-artifact-paths.ts. Device-claim recovery constructs a store only for these paths; remove that dependency while preserving validation, stored addresses, layout and errors.SessionStore.expandHomewith host-kit'sexpandSessionPathand remove the forwarder.SessionScriptWriterwithwriteSessionScript(sessionsDir, session, options). It stores one directory and its only production user is SessionStore. Keep formatting, no-clobber/force behavior, commit transitions, diagnostics, failure routing and public-name-based default filenames together.Prove the behavior
Use deterministic interleavings and real temporary filesystem/process tests for exclusion and exit guarantees. A Map-only test cannot prove filesystem exclusion.
Registration regression checklist
Session regression checklist
Prove reachable schedules separately from interface hazards. Request locks serialize ordinary open/close. Shutdown is an exception: session teardown takes no execution locks, server close forces closure after five seconds, and socket closure cancels without joining handlers. Exercise that real transport path and a precise handler continuation; existing open cancellation checks can block a particular interleaving.
Regression tests must fail when the relevant ownership check is removed or cleanup is made unconditional. Plant the concrete forbidden app-log spread for R7. Colocate tests with source and retain existing tests for pure moves.
Each slice runs focused checks and
pnpm check:affected --runon its exact head, plus the full deterministic gate for a broad refactor as repository guidance requires. Preserve CI-owned provider/native/device checks. Report production additions/deletions separately from tests and moves, and name the mechanism or caller obligation removed.Deliver in dependency order
Related work: #3102 already adds daemon metadata publication/removal fencing; deepen it. Coordinate timeout reset with #3105 and report mutations with #3104. Preserve the #2833 idle-reaper lock seam/order. This follows #3069 without reopening it or closing related issues by association.
The implementation stack is fully landed. The implementation record preserves the original branch order, exact-head validation, completed review findings and the physical recording-health limitation. Request-scoped timeout cleanup is tracked in #3177; it is outside the retirement-ownership contract completed here.
Out of scope: a generic request executor, selector-observation aggregation, global immutable-state migration, actors/manager frameworks and promised line-count/performance gains. Request finalization and diagnostics may be separate bounded follow-ups; they do not gate this work.