Skip to content

feat(home): dmon-home foundations — macOS host, supervision, gateway client - #109

Merged
ThatRendle merged 73 commits into
mainfrom
change/dmon-home-foundations
Aug 10, 2026
Merged

feat(home): dmon-home foundations — macOS host, supervision, gateway client#109
ThatRendle merged 73 commits into
mainfrom
change/dmon-home-foundations

Conversation

@ThatRendle

Copy link
Copy Markdown
Contributor

Implements the dmon-home-foundations OpenSpec change: the home/ bucket and the
dmon-home macOS host, from ADR-037 through to a working text turn over the gateway.

All 38 tasks ticked; all 10 sections closed with a supervisor approval.

What lands

  • home/ bucket + ADR-037 — a first-class monorepo bucket, an XcodeGen-generated
    arm64-only unsandboxed .app, and the local Swift packages Supervisor,
    GatewayClient, Power, DeviceKeys.
  • Supervision — children as descriptors, reattach-before-spawn, bounded health checks,
    crash backoff, dependency-ordered start/shutdown, process-group kill for spawned children
    while adopted ones outlive the host. Dmon.Network is the one child started here.
  • Gateway client — transport behind a protocol with an in-memory conformer, the gw
    frame codec, wire-version checking, device-key auth with the secret in the Keychain, the
    create→attach handshake, reattach-from-lastSeq, and turn submission with incremental
    rendering. Portable to iOS, enforced by a build.
  • UI — a text input and transcript that render a streamed reply, refuse submission when
    unattached, and surface child health and connection state so a failed turn is attributable.
  • CI — a macOS job for the home/ packages behind its own home/** filter, plus the
    iOS portability gate.
  • Protocol docs/spec — stale annotations corrected in ControlFrames.cs, the
    client-facing guide realigned to the ack and dedup contract, and the gateway close-code
    contract given a standing requirement.

daemon/Daemon.App (dmonium) is untouched and still shipping — verified over the whole
change range, its targets run green (72 tests), its CI job and release artifact unchanged.
Its retirement at parity is a later change (ADR-037 D3).

Not verified by this PR

  • Live CI — this run is the first. dorny/paths-filter gating and whether
    macos-latest carries an iOS SDK for generic/platform=iOS can only be proven here.
  • Two live human verifications owed since §7 and needing a person at a keyboard: a
    Keychain round trip across a process restart, and whether Dmon.Network accepts a
    credential this client provisioned.
  • release.yml's packaging step — unmodified and statically checked; gh release create cannot run locally.

Known gap, deliberately deferred

home/App/DmonHomeApp/ is compiled by no automated job — make dmon-home builds
home/Sources/* only, and make dmon-home-app needs xcodegen, which the runners do not
ship. A follow-up change closes it.

Change: dmon-home-foundations

🤖 Generated with Claude Code

ThatRendle and others added 30 commits August 2, 2026 11:08
Establish `home/` as a first-class top-level monorepo bucket holding the
`dmon-home` macOS host, and record the four decisions that govern it:

- D1 `home/` is a new bucket, carrying no .NET project and no `.slnx`
- D2 `dmon-home` is an ADR-012 gateway client, not an ADR-003 stdio host,
  reaching a *configured* gateway endpoint (loopback only in the current
  co-located deployment — PRD §7.4), with the transport behind a
  swappable Swift protocol
- D3 `dmon-home` supersedes `dmonium`; the new product is
  `ai.daemonic.dmon-home`, and `daemon/Daemon.App` keeps building and
  shipping until a later change retires it at parity
- D4 STT/TTS host in a Python/mlx sidecar; Silero VAD stays host-side

Amends ADR-025 (D2; D10 prospectively) and ADR-028 (D1, D2; D6
prospectively). Adds the ADR-037 summary row to the `adr-index` skill,
plus a row for ADR-036 which the index had omitted, and corrects the
accepted-ADR count in `CLAUDE.md` and the skill frontmatter from a stale
35 (the file count) to 32.

Also tracks `home/PRD.md`, the requirements source for this change and
the bucket's first member.

Tasks: 1.1, 1.2, 1.3
Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section review of ac23f64..HEAD raised three documentation-level
blockers, all cross-artifact drift a per-block review cannot see.

1. The mid-flight D2 correction (loopback endpoint -> configured
   endpoint, after the Product Owner flagged a possible split-machine
   topology) had reached design.md, ADR-037 and the adr-index row, but
   never proposal.md, which still claimed a loopback WebSocket and a
   stdio-core fallback. The proposal is the artifact read first and the
   one that survives into the archive.

2. The monorepo-layout delta described `home/` as "an app artifact,
   independently versioned", while ADR-037 marks the artifact-source
   amendments prospective and states dmon-home has no release artifact
   by design. Archiving that would have contradicted the standing
   package-publishing spec and licensed an artifact that does not exist.

3. ADR-037 D1, the dmon-home spec and the monorepo-layout scenario all
   enumerated the bucket's contents without admitting home/PRD.md, the
   one file actually in it -- while invoking the "memberless role bucket
   has no directory" rule in the same breath.

Also: drop the "this change's ..." referent from ADR-037 and the index
row, since a binding document must not point at a change directory that
archives away; correct the design.md speech-sidecar cross-reference from
D4 to D7; and add the Power package omitted from the proposal's Impact.

No task numbers and no boxes ticked -- every box in section 1 was
already ticked, so this DEVLOG entry is the record.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The supervisor approved section 1 and named one last instance of the
D2 drift class: the dmon-home-gateway-client transport-abstraction
requirement still gave "so a direct stdio core can be substituted" as
the reason the abstraction exists, which the corrected D2 downgrades to
contingent on co-location.

It suggested folding this into task 6.1. Fixing it now instead: the
same defect has already been chased through design.md, ADR-037, the
adr-index row and proposal.md because each sweep stopped short, and
this delta syncs into a standing spec at archive. The requirement
clause is unchanged; only the trailing rationale moves, leading with
the in-memory-conformer testability payoff and generalising to "an
alternative transport".

Also records the section close and the supervisor's parked notes in
DEVLOG.md, including a new precedent to watch: ADR-037 D1 is the first
case of a non-project file counting as a bucket member under ADR-025's
memberless-bucket rule.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First Swift code in the home/ bucket. XcodeGen manifest, three library
targets with tests, a thin app shell, and make targets that build and
test them headlessly.

- home/project.yml generates DmonHomeApp (ai.daemonic.dmon-home,
  macOS 14+). The .xcodeproj is gitignored; the manifest is the source
  of truth and regeneration from it alone is verified.
- home/Package.swift declares Supervisor, GatewayClient and Power as
  library targets with matching test targets, on swift-tools-version
  6.0 with the v6 language mode set explicitly on all six.
- make dmon-home / dmon-home-test, mirroring daemon-app.

Swift 6 rather than dmonium's 5.9 is deliberate (design D14): PRD §4.3
allows @unchecked Sendable and nonisolated(unsafe) only at the audio
ring-buffer boundary and treats them as suppressed diagnostics
anywhere else, which is unenforceable without strict concurrency
checking. It earned its keep immediately -- Power was first drafted
with NSLock + @unchecked Sendable and rewritten as an actor.

Each target is seeded with a small type its own later section needs and
will not delete, rather than a stub: Power.ActivityAssertion (task
5.2), Supervisor.ChildID/ChildHealth (4.1/4.3), GatewayClient
.WireVersion (6.4). AudioEngine, Speech and Directedness are not
created -- they belong to the change that first puts code in them.

Tests use swift-testing while daemon/Daemon.App stays on XCTest, a
divergence accepted deliberately and recorded in design D14: the cost
is bounded because ADR-037 D3 retires dmonium at parity.

Tasks: 2.1, 2.2, 2.3
Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ceholder

Section-2 supervisor review raised two blockers.

Nothing tracked compiled the app target. `make dmon-home` builds only
home/Sources/*, and home/App/DmonHomeApp/ sits outside Package.swift's
target paths, so both app sources were compiled by no make target --
while xcodegen and xcodebuild appeared nowhere in the Makefile or CI.
A fresh clone could not build the app, task 3.3's human-verification
recipe had no referent, and section 10's CI would have left
ContentView.swift free to break with every gate green.

Adds `make dmon-home-app`, which generates the project from
home/project.yml and builds it, with the xcodegen prerequisite recorded
at the point of use. Output is pinned to home/.build-xcode via
-derivedDataPath, so the bundle lands at a stable, repo-relative
home/.build-xcode/Build/Products/Release/DmonHomeApp.app instead of a
DerivedData path hashed from the checkout's location -- task 3.3 is
executed by hand by the Product Owner, and PRD §1 asks for the
lower-friction option.

Removes ContentView's placeholderActivityAssertion. It proved less than
its neighbours (an unused stored property draws no Swift diagnostic,
whereas wireVersion and placeholderChildHealth are read in body), and
it modelled the wrong owner: an actor held on a SwiftUI View struct,
which SwiftUI recreates on invalidation, invites binding the assertion
to view lifetime when the spec requires gateway lifetime. Task 5.2 owns
that wiring. Three dead imports in DmonHomeApp.swift go with it.

design.md D3 reworded to "four modules" so it stops contradicting D14,
and D14 now states the swift-testing decision does not depend on
dmonium's retirement schedule.

No task numbers and no boxes ticked -- section 2's boxes were already
ticked, so the DEVLOG is the record.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Supervisor approved 7737035..HEAD. Records the section-2 review and
carries its notes into the section 3 and 10 briefs: the project.yml
Info.plist strategy that makes 3.1 a one-line change, the rule that
changing -derivedDataPath or -configuration means updating task 3.3's
recipe in the same block, the ad-hoc-signing consequence for 3.2's
status UI, and that GitHub macOS runners need brew install xcodegen
before 10.1 can call make dmon-home-app.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Makes the app a bundle that can actually prompt for the microphone, so
the Product Owner's manual TCC verification (task 3.3) has something to
verify. No capture, no audio engine -- only the permission query, the
request, and the display.

- project.yml gains INFOPLIST_KEY_NSMicrophoneUsageDescription in the
  same settings.base block as the other Info.plist keys, so
  GENERATE_INFOPLIST_FILE stays the single source of truth. A
  checked-in Info.plist would have split it.
- MicrophoneAuthorizationModel wraps AVCaptureDevice's authorisation
  query and request, surfacing all four states distinctly: denied and
  restricted need different guidance, and notDetermined is the only
  state where requesting does anything.
- The request is triggered explicitly from the UI and never at launch;
  task 3.3 depends on the human invoking it deliberately.

App Sandbox needed nothing added: with no entitlements file and no
CODE_SIGN_ENTITLEMENTS the entitlement is absent, which the spec
accepts as "absent or false". Verified against the built bundle rather
than inferred -- codesign -d --entitlements shows only get-task-allow,
and plutil -p confirms the usage string is present.

The model is @mainactor because Swift 6 strict concurrency rejected an
un-isolated @observable class mutated from a Task in a View. Isolating
the type is the fix; no escape hatch was used.

Authorisation lives in the app target rather than a package: TCC is an
application-level concern keyed to the bundle, PRD §2.3 scopes
AudioEngine to Core Audio I/O and routing, and design D3 forbids
creating a package before the change that fills it.

Tasks: 3.1, 3.2 (3.3 is the Product Owner's manual verification and
remains open)
Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Product Owner completed task 3.3's manual microphone verification
and noticed xcodebuild offering an x86_64 destination. dmon-home runs
an MLX speech sidecar locally, and MLX is built on Metal and unified
memory with no Intel path, so an x86_64 slice could be compiled but
never function.

The cause was absence, not a wrong choice: home/project.yml carried no
architecture settings at all, so Xcode's default offered both. ADR-034
does not record the Apple Silicon constraint either -- no mention of
arm64, Metal or unified memory -- so the requirement that makes Intel
impossible was written nowhere findable. Both settings now carry a
comment saying why, so the fix does not repeat the cause.

Two levers are needed and each answers a different spec scenario:

- ARCHS: arm64 in project.yml stops the x86_64 slice being built, and
  binds an Xcode GUI build as well as the command line.
- -destination on the xcodebuild line silences the ambiguous-
  destination warning. ARCHS alone does not: it drops x86_64 "My Mac"
  but leaves specific "My Mac" alongside generic "Any Mac", a duality
  every macOS scheme carries and no project.yml setting collapses.

Also records two Product Owner decisions in design D15 and an in-place
amendment note on ADR-037: dmon-home is arm64-only, and speech runs on
the dmon-home host under any topology. The latter resolves the sidecar-
location question parked since section 1 -- the microphone is where the
person is, so raw audio never crosses the network on the latency-
critical path. It refines ADR-037 D4, whose rationale about co-residency
with the reasoner holds only while everything is on one machine.

-configuration Release and -derivedDataPath are untouched: task 3.3 is
already verified against the resulting path.

Tasks: 3.4 (and 3.3 ticked on Product Owner confirmation)
Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section-3 supervisor review raised two blockers, both about the
instrument used to record two Product Owner decisions rather than about
any code.

The arm64 constraint was normative content -- it became a SHALL in a
binding spec and changed a build manifest -- but was recorded in an
in-place amendment note headed "no numbered decision changes", and
misfiled under Decision 4 (the speech sidecar) when it binds Decision 1
(bucket, manifest, app target) and Decision 3 (app target, bundle id).
A release-matrix reader asking which artifacts are arch-restricted
would never have opened the speech decision. Promoted to Decision 5
with the Product Owner's approval, with a Consequences bullet and a
pickup in the Amends metadata.

Decision 4's body still asserted, unqualified, a rationale this section
made conditional -- and the amendment had qualified only half of it.
Under a split back-end the reasoner is on another machine, so there is
exactly one MLX runtime on the dmon-home host: the memory-contention
argument does not merely weaken, it does not apply, and the
co-residency argument is false. D4 stands on the uv-venv pattern and
the absence of an in-process Swift MLX path; nothing said so. Now
qualified at the decision's own site, and the same correction applied
to design.md D7, which stated the identical rationale in the design's
own words and was missed on the first pass.

Also from the supervisor's required list:

- The new arm64 spec requirement had zero enforcement -- its whole
  evidence was a human running lipo once. The causal story of task 3.4
  is that a constraint written nowhere went unenforced, so the section
  fixing it should not repeat the shape. make dmon-home-app now asserts
  the built binary is arm64-only, and section 10's CI inherits it.
- The authorisation surface could not recover: refresh() was wired to
  no lifecycle event, so after a denial the app told the user to enable
  access in System Settings and never noticed when they did. Now
  refreshes on scene-phase .active, reading status only -- no lifecycle
  path requests authorisation, which task 3.3's completed verification
  depends on.
- Scenario 2's WHEN was wider than true: a bare xcodebuild with
  ARCHS: arm64 still warns. Scoped to the app build target.
- A VAD clause read as though VAD lives in the sidecar, contradicting
  D4, which puts it host-side.

No task numbers and no boxes ticked -- section 3's boxes were already
ticked, so the DEVLOG is the record.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The section-3 supervisor found the arm64 promotion half-delivered: the
adr-index row still summarised a four-decision ADR-037, omitting
Decision 5 entirely.

That is the blocker restated, not a completeness nit. The original
finding was never "the constraint is in the wrong subsection" -- it was
that the constraint must be findable by the reader who needs it, and a
note filed under STT/TTS never reaches the release-matrix reader.
CLAUDE.md designates the index row as the surface readers consult
instead of opening the ADR, so the constraint sat in three places
inside ADR-037 and was missing from the one artifact the workflow tells
readers to trust. The section-1 reviewer blocked on this same row, for
the same reason, over the loopback wording.

The mechanism is worth recording: the post-remediation sweep was scoped
to the Makefile, home/, the change directory and docs/adrs/, and
searched for the old wording. .claude/skills/ was not in the radius,
and a search for stale text cannot surface a missing addition. Sweeps
must be scoped by where a fact is duplicated, not by where the fix was.

Also takes two items the same review parked, since both ambiguities
were created by this section:

- ADR-037's Relationship bullet said "Decisions 3-5, 7" meaning
  ADR-028's decisions. ADR-037 now has its own Decision 5, so the bare
  reference had become ambiguous; now prefixed.
- The guard assertion moved from the "No ambiguous destination"
  scenario to "The built binary is arm64 only", where the property it
  asserts actually lives. Scenario headers untouched, so openspec
  archive's header matching is unaffected.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Supervisor approved 0a593d0..HEAD after four rounds. Records the review
and the durable output: scope a sweep by where a fact is duplicated,
not by where the fix was -- searching for stale wording cannot surface
a missing addition, which is how a clean grep coexisted with an index
row that omitted an entire decision.

Also acts on the supervisor's graphify finding rather than parking it.
CLAUDE.md instructs running graphify update after modifying code and
routes agents to the graph first for codebase questions; the graph was
dated 22 Jul with zero home/ nodes, so every agent spawned in sections
1-3 consulted a map with the whole bucket invisible. Rebuilt to 17,018
nodes with 212 under home/. Derived and gitignored, so no tracked
change -- recorded here because the omission was mine and spanned
three sections.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add the child descriptor model to the Supervisor package: transport,
endpoint, declared health check with timeout, startup order, adoption
policy and launch candidates, plus the full inventory as values — six
supervised children and four read-only monitors.

Monitors are a separate type with no launch, adoption or ordering facet
at all, so spawning or killing one is unrepresentable rather than merely
not done. Health checks are declared (.http / .tcp / .process / .none)
rather than closured, leaving execution to task 4.3.

ChildLaunch carries an ordered candidate list rather than a path,
mirroring dmonium's NetworkManager.networkCandidates. An empty list
means the executable is undecided — true today for both mlx runtimes
(ADR-034 runs them from a uv venv) and the speech sidecar (no
implementation yet) — which avoids naming executables that do not exist.

Tasks: 4.1
Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add HealthChecker, HealthMonitor and ChildHealthStore to the Supervisor
package. Every check is bounded by racing the probe seam against the
child's declared timeout, rather than trusting a probe to police its own
duration — a self-policed bound cannot be tested, because the failing
case needs a probe that misbehaves.

Checks fan out per child, so a hung check records failure without
blocking any sibling. Health is published from an actor-backed store as
an AsyncStream of snapshots, keeping the testable half of "health is
visible" in the package and leaving only rendering to the app target.

.tcp and .process checks report .unknown rather than a health nobody
observed; only .http is executed, which is all task 4.7 needs.

HealthCheckable exposes only id, health check and timeout, so iterating
monitors alongside children cannot reach a launch or adoption facet.

Tasks: 4.3
Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add the adopt-or-spawn path to the Supervisor package. A child's endpoint
is health-checked first and a live process adopted; only a silent
endpoint causes a spawn. Adoption exists so an expensive child — a
multi-gigabyte model runtime — survives a restart of the host.

Spawning uses posix_spawn rather than Foundation's Process, because the
two requirements collide: section 5.1 needs stdout/stderr pipes, which
Process gives free, and a child needs its own process group, which
Process cannot do at all — there is no hook between fork and exec and
setpgid from the parent races it. POSIX_SPAWN_SETPGROUP with pgroup 0
gives both, at the cost of owning child reaping.

Only a spawned child carries a process group id, so an adopted child has
no field to be killed through and outliving the host holds by
construction rather than by a call site remembering to check.

The process-group invariant is checked once, without trapping. A
misgrouped child is killed by pid, reaped, and surfaced as an error;
refusing to signal one child never stops the rest from being signalled.
A trap would have killed the host and orphaned the very process the
guard exists to contain.

Task 4.6's box is not ticked here: killing the groups on host exit needs
the app-termination wiring that arrives with task 4.7.

Tasks: 4.2
Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add HostSupervisor, RestartBackoff and ChildSupervisionStore. Children
start in ascending declared order and shut down in reverse, each asked
to terminate gracefully before the group is killed. Unexpected exits are
detected and retried with exponentially increasing delay.

Backoff resets on the child having stayed up, not on a launch having
succeeded. dmonium resets on every successful start, so a child that
launches fine and dies immediately retries at two seconds forever and
never escalates — the doubling only bites when the launch itself fails,
which is the less likely crash loop.

Graceful shutdown is distinguished from a crash, so quitting the host
does not restart everything it just stopped.

awaitExit is now cancellation-aware and returns AwaitExitOutcome rather
than an optional: a cancelled wait, a real exit and a failed reap are
three different facts. Previously an already-reaped pid reported a
fabricated clean exit, because waitpid's return went unchecked and the
untouched status read as "exited normally".

Shutdown escalation was deadlocking. It waited on a task group that
cannot return until its children finish, so SIGKILL was never reached
for a child that ignores SIGTERM, and one stuck child blocked every
child after it. Cancellation now propagates into the awaited task.

Spawned children no longer inherit the parent's blocked signal mask.
Inheriting it meant SIGTERM could be silently undeliverable, so graceful
termination reported success while doing nothing.

Tasks: 4.4, 4.5
Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Composes the supervision parts built in 4.1-4.5 into a running host: the
app constructs a HostRuntime over ChildInventory, starts it on launch,
surfaces each child's health, and terminates every spawned process group
when it quits.

Before this, killProcessGroups had no caller anywhere, no HostSupervisor
was constructed outside tests, and ContentView rendered a hardcoded
placeholder health value.

- HostSupervisor.terminateSpawnedProcessGroups() is the exit sweep, in
  the package rather than the app target so swift test can assert the
  exit guarantee. Adoption exemption holds by construction: an adopted
  child has no SpawnedChild to collect.
- Exit is graceful-then-sweep. shutdown() runs first; the sweep is a
  backstop for a shutdown cut short by the termination budget.
- HostRuntime is the composition root; AppDelegate uses the two-phase
  applicationShouldTerminate protocol so a bounded async shutdown can
  complete, replying exactly once on every path.
- AppDelegate owns the single ChildStatusObserver, so window reopen
  cannot construct a second one and leak a subscriber.

Task 4.6 ticked. Task 4.7 carries a human verification and stays open;
its recipe lands in tasks.md for the Product Owner to run.

Change: dmon-home-foundations
All four phases of the 4.7 recipe passed: cold start spawned the gateway
and quit terminated it; launching against an already-running ndmon
adopted it and quit left it running.

Records the discriminator the Product Owner observed — adopted reports
healthy immediately, spawned takes ~30s, because HealthMonitor.run
checks once before its first sleep. Section 4 is now fully ticked and
awaits its supervisor review.

Change: dmon-home-foundations
The section supervisor audited all five blocks together and requested
changes on four blockers. This fixes them.

Requirement 5 had no implementation on the app's real quit path.
shutdownChild sent SIGTERM to the group but waited on the leader's pid
only, and the group SIGKILL lived inside the escalation branch. When the
leader exits promptly while a group member does not, no group kill fired
— and handleExit had already cleared spawnedChild, so the exit sweep
could not reach it either. Both mechanisms missed the same case for
different reasons. ndmon spawns dmoncore per session into its own group
and flushes less, so it can exit first, orphaning a dmoncore mid-write.

- Kill the child's process group on shutdownChild's graceful branch too.
  A pgid is not reused until the group's last member exits, so this is
  safe precisely when it matters.
- Delete terminateSpawnedProcessGroups and killProcessGroups. They
  backstopped a state the app cannot produce, since AppDelegate never
  cancels the shutdown task. shutdown() now returns refusals directly.
  AppDelegate documents that non-cancellation as a safety precondition.
- Test it at host-exit level with a grandchild that survives SIGTERM
  under a leader that exits on its own.
- Stop polling the four monitors until a consumer exists (Product Owner
  decision); ChildInventory.monitors is unchanged.
- Delete ChildHealth.starting/.stopped/.isRunning — no writer, no reader.
- Derive terminationBudget from HostSupervisor.worstCaseShutdownDuration.
- Replace an assertion that passed unconditionally on a process the
  supervisor could not signal.

Ticks nothing: every box in section 4 was already ticked.

Change: dmon-home-foundations
The supervisor approved section 4 over 2caeee2..HEAD with the
remediation included, and corrected the risk framing I had written into
AppDelegate.

Cancelling the shutdown task does not re-open the process-group hole:
with the task cancelled, withTimeout's sleep yields nil (escalation
branch) or its operation returns true (graceful branch), and both now
kill the group. Cancellation degrades to kill-everything-fast rather
than skip-the-kill. The real residual exposure is aborting shutdown()'s
loop part-way, which the deleted sweep would have caught because it
iterated states.values rather than following the loop. The comment now
says that instead.

Also prunes NEXT: consumed section 1-4 carry-ins removed along with the
orphaned trailing clauses left by earlier edits, and section 4's output
rolled in — the app-target line, the human-facing falsification rule,
the crash-path descendant leak, and the one-line structural fix for the
shutdown walk.

Change: dmon-home-foundations
Adds ChildLogSource/ChildLogLine and a bounded per-child ChildLogStore to the
Supervisor package, drains every spawned child's stdout and stderr into it,
and renders it in the app window. The buffering lives in the package so the
"output survives a restart" scenario is evaluable by `swift test`; only the
SwiftUI view lives in the app target. An adopted child gets one host-attributed
line saying its output belongs to whoever launched it, rather than an empty
section that reads as silence.

This also closes a latent deadlock rather than only adding a feature: nothing
read the pipes ChildSpawner already created, so a child writing past a pipe
buffer blocked in write(2) forever. The 224KB drain test fails without it.

Readers use DispatchSourceRead, not DispatchIO.read — the latter resolves only
on length-bytes-or-EOF, so a live child's single short line was never delivered
at all — and are genuinely cancellable via ReadWaitBox, mirroring ExitWaitBox.
An orphaned generation's readers are cancelled after a bounded drain grace
instead of being left unreachable, which leaked two fds per crash.

Also removes the whole-struct capture-and-writeback pattern from HostSupervisor:
apply, startChild, handleExit and shutdownChild now mutate states[id] in place.
The reentrant lost update that pattern allowed could not be provoked in 26+
trials, but is real by inspection and would leave a live generation untracked
past host shutdown. The section-4 code moving here is deliberate and recorded
in the DEVLOG.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Foundation.Pipe's descriptors carry no FD_CLOEXEC and spawn named no
POSIX_SPAWN_CLOEXEC_DEFAULT, so every descriptor this host holds was
inherited by every child it spawned. Adds the flag, and wires fd 0 to
/dev/null explicitly — without which the flag would leave the child with
no stdin at all, so its next open(2) would land on 0 and anything writing
to what it believed was stdin would corrupt that file.

Production impact today is nil: spawn closes the parent's copy of each
write end immediately after posix_spawn returns, so a sequentially started
child cannot inherit an earlier one's pipe. The exposure needs concurrent
spawn attempts, and only one child is enabled. The test suite hits it
because swift test runs suites concurrently in one process — a stray
descriptor inherited into an unrelated child's blocking read stalled a
pipe test for 30s in roughly one run in five.

The close list is now filtered to exclude descriptors 0, 1 and 2. For
those slots an explicit close is both unnecessary and harmful:
unnecessary because each is already the target of an adddup2 or addopen
that replaces its prior occupant, and harmful because file actions run in
order, so closing one destroys what an earlier action installed there.
That case is reachable whenever the host's own copy of a standard
descriptor is free at Pipe() time. The decision lives in an internal pure
function so the collisions are asserted against synthetic numbers, rather
than by closing the test runner's own standard descriptors.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds GatewayActivityPolicy to the Power package: it drives the existing
ActivityAssertion from a plain "is the gateway enabled" boolean, and owns
the activity options and reason string. HostRuntime gains isGatewayEnabled,
derived from the descriptor set it is constructed with rather than assumed,
so a runtime built with a disabled or absent gateway answers false.
AppDelegate holds only the join.

Power is kept free of any dependency on Supervisor (design D3 — Supervisor
must not grow a power-policy responsibility), which is what keeps both spec
scenarios evaluable by `swift test`: the begin/release behaviour is asserted
in PowerTests, the enablement fact in SupervisorTests. The app target has no
test bundle, so a decision placed there would be a decision no test can see.

The option set is [.userInitiated, .idleSystemSleepDisabled]. NSProcessInfo.h
defines userInitiated as including idleSystemSleepDisabled in its own bit
pattern, so the second is redundant; it is named anyway for legibility at the
call site, with the header quote and its provenance in the comment.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The section widened block 5.1 to remove a reentrancy hazard, on a causal
story that was later withdrawn: the failing test that prompted it turned
out to have a different cause entirely. The test comment was corrected at
the time; the production comments were not, so HostSupervisor still
narrated the withdrawn story as observed fact and contradicted its own
account of the real cause a hundred lines away.

apply's doc comment is now in the conditional voice the claim actually
has, and states the falsification result: the shape is real by static
trace and independently confirmed by review, but 20 solo and 40-concurrent
trials against the reverted code produced zero failures, so it is closed
by construction rather than because it was reproduced. The false test
attribution is deleted rather than repaired — the real cause is documented
elsewhere in the same file. shutdownChild's comment is softened to match.

Also fixes two comments naming apply(_:id:state:), a signature block 5.1
deleted — one of them in a file that block created — and hoists
logDrainGrace's default to a single constant. That default previously
existed as a literal in two files, which is the same duplicated-value
shape that caused the shared test-double collision two rounds ago.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 5 worked around the same suspected Swift 6.3.3 defect twice, and
each workaround documented it in its own doc comment. Both descriptions
named malloc as the faulting subsystem, and one called the other sighting
"the same signature from an unrelated cause" — which the investigation
that followed contradicted: both triggers produce an identical backtrace
in swift_task_dealloc, Swift Concurrency's own task-context allocator,
which merely reuses malloc's wording.

Rather than repair the same facts in two places, this states them once in
home/TOOLCHAIN-NOTES.md and reduces both comments to pointers. Not an ADR:
ADRs record decisions this project made, and nothing here was chosen. The
note carries the backtrace, both triggers, the three causes excluded by
audit, the reproduction recipe, and explicit limits on what was not
established — the upstream tracker was not searched, and the sanitizer
result rests on a single run.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gracefulShutdownTriggersNoRestart wrote its readiness marker before
installing the TERM trap, so `spawned` appearing meant "the child has
started", not "the child can be terminated gracefully". A SIGTERM landing
in that window hit the shell's default disposition, killed the child
outright, and no `terminated` line was written — failing the test having
proved nothing about restarts. Measured at roughly 1 in 40 full-suite runs.

Proven rather than sampled: injecting a sleep between the two lines makes
the old ordering fail 5/5, and the new ordering pass 5/5 against the same
injection. 26 subsequent full-suite runs are clean.

The fixture is section-4 code and unchanged by section 5, so the race
predates it. Section 5's added reader and drain-grace tasks plausibly widen
the window, but that was not established: the section-4 tip measured 0/40
against HEAD's 1/40, which is not a meaningful difference at that sample
size.

Other fixtures were checked for the same shape — a readiness signal written
before the thing it signals readiness for — and the three sibling shutdown
tests already order theirs correctly.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the post-approval flake hunt and rewrites NEXT for section 6.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parked defects and deferred gaps were living only in the active OpenSpec
change's DEVLOG under `## NEXT`. That is accurate and detailed, but it is
scoped to one change, archived with it, and read only by whoever is working
that change. Debt that outlives a change needs somewhere that outlives a
change.

The DEVLOG stays the narrative record of how a change was built; tech-debt/
is the standing register of what the codebase owes. Items that bear on a
coming section's briefs are written to both, because the two serve
different readers.

Seeded with 14 items from section 5 of dmon-home-foundations and the
sections it carried forward: three behaviour gaps, five guards enforced by
convention rather than construction, the Swift 6.3.3 workarounds, two test
issues, and three doc/tooling items.

Two conventions the notes follow. Each states what was *not* verified — a
register that overstates its evidence is worse than none, because the next
reader budgets against it. And decisions deliberately deferred record their
trigger ("a fourth such store is the point to extract"), so the next author
does not re-argue them under deadline.

No role bucket is created: monorepo-layout governs where first-party
projects live, and this holds none.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Product Owner decisions taken 2026-08-06, opening the gateway client:

- GatewayClient is shaped as a shareable Swift client package — ADR-037
  D2 rationale 4 read as "same code", not merely "same protocol".
  Extraction is deferred; the no-foreclosing constraints are not.
- Portability is enforced by an iOS build gate rather than convention.
- The network host advertises its wire version on `attached`.

The third answers a gap found while reading the wire for 6.1/6.2: task
6.4 checks compatibility against a "negotiated" wire version, and there
is no negotiation. `protocolVersion` rides only on the core's agentReady
event, which CoreLauncher consumes inside the gateway during the create
handshake; `/ws` is the only endpoint and no frame or header carries a
version. As written, 6.4 could only have shipped a check nothing could
trigger. Adds task 6.6 (land before 6.4) and a MODIFIED delta on
remote-session-gateway for the new `wire` field.

Also adds a portability requirement to dmon-home-gateway-client, folds
the iOS gate into 6.1, and records the seq-counting invariant the client
must rely on: the gateway forwards events byte-unchanged, so `seq` is
never on the wire and the client counts from `attached.headSeq`.

Change: dmon-home-foundations
Task 6.1. GatewayTransport is a Sendable protocol (connect/send/receive/
close) with pull-based receive, mirroring URLSessionWebSocketTask so the
in-memory conformer stays trivial and the read loop's ownership — and
with it the event sequence counter — stays with the connection actor in
6.3. WebSocketGatewayTransport is an actor, not a struct with @unchecked
Sendable; its two pieces of real logic (close-code mapping, request
assembly) are pure static functions so they are tested without a socket.

The transport reports three distinct facts, settled after the review
found the two conformers disagreeing about the third: .notConnected
(never connected), .closedLocally (we hung up) and .closed(code:reason:)
(the peer hung up, carrying 4400/4404/4409/4500/1009). 6.3's read loop
and section 7's reattach policy branch on the difference — a self-close
must not trigger a reattach, a 4409 must.

Portability to the iOS clients (design D16) is enforced by a build, not
by convention: home/Package.swift declares an iOS platform and
`make dmon-home-ios-check` builds the GatewayClient target for a generic
iOS destination. The gate was proven to bite — import AppKit fails it
with exit 65 — rather than assumed to.

Change: dmon-home-foundations
Task 6.2. The eight `gw` control frames as value types with hand-written
Codable conformances rather than synthesised ones, so the discriminator
is a literal baked into encode(to:) — mirroring the C# side's get-only
`Gw => "literal"`. Field names and types pinned against the
[JsonPropertyName] attributes in ControlFrames.cs.

Routing has three outcomes, not two: a recognised string `gw` is a
control frame, an absent/null/non-string `gw` is an ADR-003 event
carrying its raw text unchanged, and an unrecognised string `gw` is its
own outcome — logged and skipped by the read loop, never counted as an
event. That third case is forward-compatibility with a gateway that adds
a control frame later, and it protects 6.3's sequence counter: the
client never sees an event's seq (ADR-014 makes it gateway-local), so it
counts received event frames from attached.headSeq, and a control frame
miscounted as an event would make 7.3 reattach from the wrong lastSeq.

Type-tolerance mirrors ControlFrameSerializer.GetGwDiscriminator for the
reason stated there: one malformed frame must never kill the loop.

JSONEncoder on this platform does not preserve encode(to:) call order,
which surfaced as non-deterministic literal-JSON test failures; output
is now sorted-key. That makes the client depend on the host tolerating
an out-of-position discriminator inbound — verified against JsonNode.Parse
and JsonSerializer.Deserialize, and recorded at the call site.

attached.wire is decoded as an optional ahead of 6.6 putting it on the
wire; what its absence means is 6.4's call.

Change: dmon-home-foundations
ThatRendle and others added 28 commits August 9, 2026 14:20
Wires the device-key connect flow section 6 built every mechanism for but
could not reach, closing the two scenarios it deferred to section 7.

AuthenticatedTransportFactory joins the auth decision to transport
construction: no header when the store has no active entries, a Bearer
header for a vouched-for key, provisioning on demand when one is required
and absent, and a named refusal otherwise. A refusal never degrades into an
unauthenticated connect -- the tests assert the transport is never built at
all, not merely that the header is missing.

The session's transport factory becomes async throws so every connection
re-resolves. Resolving once and capturing headers would have re-presented a
credential revoked between connections on every reattach, returning a bare
401 with nothing naming the cause, which is the failure the five auth
outcomes exist to prevent.

Closes the secretHash half of the credential-mismatch gap: a key whose id is
active but whose recorded hash has been rotated or restored now refuses by
name instead of 401ing silently. DeviceKeyProvisioner takes an injected
DevicesFileReader, settling a composition question parked for its third
consumer.

Falsifying the new suspension tests showed that .timeLimit cannot bound a
hang on an un-cancellable continuation -- cancellation is cooperative and
nothing resumes a plain continuation. Two tests carry a trait that does not
protect them, which matters once CI runs this target. Recorded in
tech-debt/ rather than patched here.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects the section review found across blocks, none visible in any
single diff. No task numbers; nothing ticked.

The derived sequence cursor double-counted every replay window. The host
sends headSeq and then replays (lastSeq, headSeq], so seeding at headSeq
and counting the replayed events on top landed the cursor at 2H-L; the next
reattach asked for more than existed, was silently clamped, and received no
replay at all. Reattach-and-replay worked exactly once, with an empty
backlog. The cursor now seeds from the lastSeq this attach sent, clamped to
headSeq, mirroring the host's own clamp.

Every fixture in the section answered a reattach with headSeq equal to the
lastSeq just sent, so no test ever had a non-empty replay window -- the
scenario's own precondition. Two existing tests only passed because of the
bug and now use fixtures the host can actually produce.

Items arriving before the attached frame were dropped. The host's reply
path sends attached through a serialized funnel precisely because Attach()
releases the pump's wake first, so a replayed event can beat it on any
reattach. Such items are now forwarded and counted.

A failed handshake wiped sessionId, so one dropped connection before
attached made every later reattach fail as if no session had existed.
sessionId is now assigned only once the handshake succeeds.

attach() overwrote a dropped-but-not-nil connection without closing it,
where reattach() closed it. Both now share the same teardown discipline.

Two comments asserted wire behaviour the .NET source contradicts: that an
internalError always follows turnStart, and that the host always answers
attach with attached first. Both corrected against the code they describe.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the section review's approval, and corrects a NEXT bullet the
remediation invalidated: it justified the stale-pump channel being
unobservable by an unconditional cursor overwrite that no longer exists.
The conclusion stands -- the generation guard was always the protection --
but the reason did not survive, and NEXT is what outlives the change.

Carries into section 8 what section 7 established about turn outcomes,
including the two shapes a renderer would otherwise wedge on.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds TurnTranscript, the package half of tasks 8.1 and 8.2: a pure value
reducer in GatewayClient that folds TurnProjection's TurnEvents into an
ordered transcript, appending each text delta as it arrives rather than
on completion.

No clock, no timer, no Task. Section 7 established that a submitted turn
can produce nothing on the wire at all, and a turn parked on an
unanswered tool.confirmRequest is indistinguishable from it, so an open
turn stays open and is rendered honestly rather than timed out.

Attribution is positional — streaming events carry no correlation id —
which is sound only because the core's turn gate refuses a concurrent
submit rather than interleaving two turns on one session. Recorded in
the type's doc comment, since no Swift test can observe that changing.

Both refusal and failure notices carry their reason as entry text, so
a view rendering entry.text cannot draw a blank bubble for the refused
submission that task 8.2 exists to surface. The open turn is tracked by
an explicit id rather than a backwards state scan, so an abandoned turn
that never closes cannot be named as open again after a later turn ends.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds SessionCoordinator, the actor that owns a GatewaySession, folds its
inbound stream into the TurnTranscript from afe07d4, and publishes
transcript and connection state together as one SessionSnapshot feed.
The app target can then consume a stream and store a value, deciding
nothing — the same shape ChildLogStore already uses for the log pane,
which keeps every spec-asserted behaviour reachable from swift test.

Implements the mechanism half of the Product Owner's connect policy:
connect() at most once from a connectable state, reattach() manual, and
no retry loop, backoff or timer anywhere. An automatic retry against a
refused device key would loop against a 401 no retry can fix.

reattach() is legal from .connectFailed as well as .dropped. A failed
reattach is the ordinary transient case reattach exists to recover from,
and accepting only .dropped left connect() — a brand-new session at
lastSeq 0 — as the sole remaining action, discarding the resumable
session and its replay cursor.

The transport's close code survives into the published state as a
GatewayCloseCode rather than a rendered string, so 8.3 can tell a
supersede (4409) from a core failure (4500) from a local close.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two blocks of section 8 are in: afe07d4 (TurnTranscript) and 8a5be24
(SessionCoordinator). Records both review loops, the four blockers B2
took, and the resume point — the approved flake block next, then B3
(ticks 8.1/8.2), then B4 (8.3).

Also records the identified GatewaySessionTests flake: waitUntil's 2s
default applied to a readiness wait while the pump drains a 50k
backlog, which is a deadline rather than a bound that discriminates.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backlog stress test gave the reattach connection 2s to appear while
the pump drains 50,000 frames with close() and reattach() racing, under
41 other concurrent suites — a readiness deadline shorter than the work
the test itself creates. It failed roughly one run in three.

waitForTransport and waitForSentFrames now take a timeout, defaulting to
the existing 2s so no other call site moves, and the two waits after the
backlog is enqueued use 30s. Nothing the test asserts is loosened: its
bounds are lastObservedSeq and no leaked backlog frame, neither of which
is timing-sensitive, and the 50,000-frame backlog is unchanged. 15s was
tried first and still failed once on a cold build, which is recorded in
the test rather than left for someone to rediscover.

The lockout regression test from 8a5be24 would have hung rather than
failed if its guard regressed, because the hang is at a continuation
await — the case .timeLimit is documented not to bound. It now asserts
the connection state and a new transport before any blocking await.
Forcing the regression by hand proved this and caught a vacuous check
while doing it: the count comparison used a literal that was already
true, so it would have passed under the regression it guards.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app target now builds its device-key-authenticated transport for
real — DevicesFileReader, the Keychain store, DeviceAuthPolicy and
DeviceKeyProvisioner behind AuthenticatedTransportFactory — and holds a
SessionCoordinator over it, connecting once when the network gateway
child first reports healthy. The window renders the resulting connection
state, including the close code, so a supersede reads differently from a
core failure. No text input yet; that is the next block.

The three app-target observers move into one file with fileprivate
initialisers behind a single owner, so a view in another file can no
longer construct one and leak a subscriber on window reopen. That was
previously guarded by a doc comment in an app target with no tests.

Session teardown now precedes child shutdown, matching the reverse
dependency order children already shut down in: the session is
established after the gateway child and depends on it. Closing first
also sends a real close frame while the peer is alive to receive it.

close() is terminal, and connect()/reattach() check that with no
suspension between the check and the state it protects. Two races were
found by forcing them rather than by reading: close() racing its own
consuming task, and a connect() resurrecting .attached from inside the
cross-actor read of sessionId. Both now have tests that were verified
to fail against the broken code first.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the transcript view and text input, closing tasks 8.1 and 8.2. The
reply renders as it arrives because every fold publishes a snapshot; the
view stores the latest and renders it, deciding nothing.

The input is never disabled. Not while a turn is in flight — a turn can
produce nothing on the wire at all, so any affordance gated on turn
completion wedges the app — and not while unattached either: the
scenario requires that an attempt be refused and surfaced, and a greyed
out field makes no attempt and surfaces nothing. Submitting unattached
puts "no session is attached" on screen as a refused notice.

Connect and reattach are two separate buttons. From a dropped
connection, connect starts a new session and discards the resumable one
with its replay cursor, while reattach resumes it; one control would let
a user bin their conversation by accident.

Which of those verbs is legal now lives on GatewayConnectionState as
allowsConnect/allowsReattach, and the coordinator guards on the same
properties, so the buttons and the guards cannot disagree. The rule that
a notice already carries its reason while an assistant failure does not
moves onto TranscriptEntry, beside the reducer whose branches it
describes, tested through the reducer rather than hand-built entries.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes task 8.3. HostSupervisor wrote a host-attributed log line only for
an adopted child — the one start outcome that is not a failure — and
nothing for the three that are. So the most likely real failure of this
app, ndmon not being installed, rendered as health unknown, supervision
normal, and no output at all: three panes and no signal.

Each failure outcome now says what happened in terms someone can act on.
An unresolved executable names every candidate tried, in order, with
environment candidates rendered by variable name rather than resolved
value. A spawn failure carries its own errno and strerror. A child whose
launch path is simply not decided yet says so, because that is a
configuration state and not a fault.

The window labels the three layers as a walk — supervised child, gateway
connection, then the turn — so a person who watched a turn fail can find
which one broke rather than reading four stacked lists.

The status row still reads unknown/normal for a child that never
started. Both fields are narrow rather than wrong: supervision normal
already covers "this host owns no process", which is what an adopted
child reports today. The log line beside it is what carries the
explanation.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the section review.

The transcript's positional attribution rested on an invariant nothing
enforced. Its doc said the core's turn gate prevents two turns
interleaving, which is true of the core and false of the client: the
reducer opened a turn unconditionally, the coordinator recorded one
because submitTurn returned — which means the frame was written, not
accepted — and the input is deliberately never disabled. A second
message sent while the first was still open therefore landed the core's
uncorrelatable turnInProgress refusal on the wrong entry, leaving the
first turn awaiting forever and its reply folded into the wrong row.

submit now refuses while a turn is open, through the same refusal
surface task 8.2 already uses, and records the turn before awaiting the
write so an event arriving mid-write cannot orphan an entry. Because a
turn can legitimately produce nothing on the wire, and the reducer is
forbidden from concluding from silence that a turn has ended, an
explicit Abandon Turn action clears one. That state is reachable only
from a user pressing the control, never from the reducer: a decision,
not an inference.

The backlog stress test raced an assertion against a legal refusal.
close clears isAttached in its synchronous prelude and reattach guards
on it in its own, so whichever task reached the actor first decided
whether a second transport was ever created; when reattach won, the test
polled for something that could not exist and consumed its whole
timeout. That, not slowness and not a stall, is what two earlier
diagnoses missed. Sequencing the close removes the race, and
instrumenting the pump confirmed the 50,000 frame backlog is still
entirely undrained when the new pump installs, so the overlap the test
exists to stress is intact.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
convertOpenTurnToRefusal acted on whatever turn was open when it ran,
not the entry it was meant to unwind, and its doc justified that by
saying the reducer is not reentered while a caller is mid-call. That is
true of a value type and irrelevant here: the two calls straddle the
await on the socket write, and consume calls apply as a separate caller
on the same actor during it. Replay draining after a reattach is the
realistic source.

It now takes the id recordSubmittedTurn returns and acts only when that
turn is still the open one. On either mismatch — closed with nothing
reopened, or closed and replaced — it appends a standalone refusal
instead, so a message that never reached the wire can no longer record
nothing at all, which is the silence task 8.2 exists to prevent.

Forced at both levels before fixing: reverting to the unscoped version
makes the reducer tests drop a refusal and overwrite a replacement, and
makes the actor-level test fail the same way, with the transport failing
only the turn.submit write so the attach still succeeds.

Also stops claiming apply cannot reach the abandoned state by
construction — markAbandoned is fileprivate and apply is a few lines
away in the same file, so that separation is held by review. And brings
the backlog test's readiness budget to 5s now that the branch it was
widened for is structurally gone; the test measures 0.35s.

Change: dmon-home-foundations

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AttachedFrame.Generation claimed generation fencing was "not enforced
until Group 6" and AckFrame claimed dedup logic "is Group 5". Both are
implemented in Dmon.Network: SessionHandler.Attach evicts, fences and
aborts the prior connection, and CommandAdmission { Accepted, Duplicate }
backs SessionHandler.TryAdmitCommand.

Both replacements state what a client can rely on rather than which
change-group built it. AckFrame's leads with the guarantee — a resend is
delivered to core at most once and never silently dropped — because the
duplicate-recognition mechanism only holds when the original reached
core; a failed core write deliberately un-admits the id so the resend is
forwarded.

Comments only: no behaviour change, no spec delta. The ~20 other Group N
references in the repo are locative and read correctly in the present
tense, so they are untouched.

Change: dmon-home-foundations
The section supervisor found that correcting the DTO comments in 9.1 left
the client-facing protocol guide contradicting them. protocol-schema
spec designates docs/protocol/ as what a client author reads, and it
carried two false present-tense claims about the same two behaviours:

- "sends ack immediately on receipt (before the core has processed it)"
  was inverted. The ack follows a successful write to the core, and a
  failed write sends no ack at all and closes 4500.
- "a resent command ... is silently dropped" contradicted both the new
  AckFrame comment and the standing spec: the duplicate branch re-acks.

Both now match openspec/specs/remote-session-gateway, which was the
wording source; the guide is aligned to the spec, not the reverse.

Also widens the Swift client's close-code doc: 4500 has two production
emission sites, spawn/handshake failure and an established-session core
write failure, and only the first was documented. The second is what the
new "never silently dropped" guarantee is observable through.

Docs and comments only: no behaviour change, no spec delta, no test
change. ControlFrames.cs is untouched.

Change: dmon-home-foundations
The section-9 remediation fixed the outcome but added a fresh false
statement of the shape it had just removed: two clauses equated "the
client observed no ack" with "the core did not receive the command".

The ack is sent on the connection after the write to the core has
already succeeded, so any drop in that window - the ack's own send
failing, a 4409 fence, a dead socket - leaves the core holding the
command while the client never sees an ack. That is the case reattach
and dedup exist for, not an edge case.

A missing ack now means the command may not have reached the core,
with the advice grounded in dedup safety rather than a false diagnosis,
and the "(no ack was ever sent)" gloss is gone: the branch is decided
by the gateway's admission state, which the client never observes.

The guide is now no stronger than AckFrame's own prose, which never
claimed the client can tell which branch it is in.

Change: dmon-home-foundations
Product Owner decision, 2026-08-10, raised by the section-9 supervisor.
The gateway close codes 4400, 4404, 4409 and 4500 live only in
Dmon.Network C# and a hand-mirrored Swift enum; no spec names them and
the protocol guide never mentions a numeric close code.

Section 9 made this load-bearing: AckFrame's "never silently dropped"
guarantee is only observable through 4500, so a client SDK built from
the spec alone could not discover the mechanism its correctness rests
on. The requirement lands in this change's existing
remote-session-gateway delta.

10.4's validate and build now runs last, after 10.5.

Change: dmon-home-foundations
Supervisor approved section 9 on round 3. Records the three rounds, the
two remediations, and a correction to my own earlier gate post: an
interrupted pack-core.sh did not cause the later build failures, because
the script wipes and recreates build/core-feed on every run and
build-core-pack is .PHONY. The sandbox was the whole cause, and it is
not deterministic - the mechanism was not isolated.

Rewrites NEXT for section 10, collecting its six carry-ins into one
place and warning that make build can fail silently under the Bash
sandbox.

Parks the stale Group 5 placeholder comment on Dmon.Desktop's
ConversationView to tech-debt/, per the convention that debt outliving a
change needs somewhere that outlives the change. The note records the
locative-vs-temporal test that decided which Group N comments section 9
touched and which it left.

Change: dmon-home-foundations
Adds dmon-home-changes and dmon-home, mirroring the existing
daemon-app-changes and daemon-app pair: a paths-filter job scoped to
home/** gating a macos-latest job that runs make dmon-home and make
dmon-home-test. Both are wired into ci-gate's needs and its check calls,
since a job absent from either gates nothing.

The Xcode app build and the iOS portability gate are deliberately not in
CI: neither is required by the continuous-integration spec, and the app
build needs xcodegen, which GitHub's macOS runners do not ship. Whether
CI should run them is an open question for the Product Owner.

Also generalises area-map.yml's unmapped-paths comment from "the sole
Swift path" to a durable non-.NET rule naming both members, and adds the
matching row to ci.yml's own header trigger matrix - which documented
one of the two orthogonal Swift paths after this change added the
second.

Live CI behaviour is unverified: Actions cannot run locally, so the
path filtering and macOS Swift toolchain parity are unproven until the
branch is pushed.

Change: dmon-home-foundations
Product Owner decision, raised by the section-9 supervisor: the codes
4400, 4404, 4409, 4500 and the standard 1009 lived only in Dmon.Network
C# and a hand-mirrored Swift enum. No spec named them and the protocol
guide never mentioned a numeric close code, yet AckFrame's
never-silently-dropped guarantee is observable only through 4500.

Adds an ADDED requirement to the change's remote-session-gateway delta
with a scenario per code, and a close-code table to the guide with an
action per code. Values are sourced from the C#, which corrected the
task text: 4500 at the create site fires only on an unexpected
exception, since a handshake timeout replies createRejected instead and
a client abort is excluded by a catch guard.

The requirement is deliberately conditional rather than universal. A
heartbeat-detected dead connection ends with no close code at all - the
loop breaks and the socket is disposed without a close handshake - so a
claim that the host never drops a connection uncoded would have been
false, and would have contradicted the accepted Heartbeat liveness
requirement in the same spec. A scenario names that case instead and
tells a client the durable response: reattach with lastSeq, and resend,
because idempotency makes a resend safe whether or not the drop
explained itself.

Docs and spec only: no behaviour change, no C# or Swift edit.

Change: dmon-home-foundations
10.3 and 10.4. Proved over the whole change range that
daemon/Daemon.App and release.yml are unmodified, that ci.yml's
daemon-app pair and its ci-gate wiring are byte-for-byte unchanged
(section 10 only appended a sibling), and that release.yml's artifact
job still reads the path make daemon-app produces.

The substantive half was running the targets rather than trusting the
diff, since the Makefile did change during this change when the home
targets landed: make daemon-app builds and make daemon-app-test passes
72 tests, 0 failures.

Live CI behaviour and the real packaging step remain unverified - they
cannot be run locally.

Also files the guide's profile/unknown_profile drift to tech-debt: the
wire moved to agent/unknown_agent under ADR-022, so a client written
from section 3.2 fails at its first create.

Change: dmon-home-foundations
The gateway-client spec requires portability be enforced by a build
rather than by convention, and the build that enforces it -
make dmon-home-ios-check - ran in no workflow. Section 10 wired the
home packages' build and test targets and stopped there, so the
enforcing act was a human choosing to type the target.

That decays at archive: the DEVLOG that remembers the target moves to
archive/, and the first PR adding a macOS-only import to GatewayClient
goes green. The section-8 supervisor had also declined a module
extraction on the argument that GatewayClient cannot import Supervisor
without breaking the iOS gate - an argument resting on a gate nobody
runs.

Adds the step to the existing dmon-home job, which already runs on
macos-latest behind the home/** filter and is already wired into
ci-gate, so a failure gates without further change. make dmon-home-app
stays out of CI: it needs xcodegen, which the runners do not ship.

Unverified until the branch is pushed: whether macos-latest carries an
iOS SDK for the generic/platform=iOS destination. It passes locally.

Change: dmon-home-foundations
Section 10 approved on the supervisor's second round after the iOS
portability gate was wired into CI. Every section now has a supervisor
approval and every box is ticked.

Records the supervisor's second gate-shaped finding, declined as a
blocker for a precise reason: home/App/DmonHomeApp is compiled by
nothing automated, which section 3 predicted in as many words, but no
requirement demands it - unlike the iOS gate, whose requirement existed
and had no automated home. That makes it a costed Product Owner call,
not an unmet obligation.

Rewrites NEXT around the three items that outlive the change: one
observed green CI run before archiving, the two live human
verifications owed since section 7, and the optional app-target CI gap.
Also records the sandbox trap on both toolchains, with the Swift
mechanism marked as a lead for the .NET one rather than its answer.

Folds the unverified schema.json into the existing protocol-guide drift
note: same failure mode, same unowned sweep, so one note rather than
two.

Change: dmon-home-foundations
PR #109's first CI run went red on two timing-dependent tests written in
sections 4 and 5. Both pass locally; both failed on the slower macOS
runner. The CI wiring itself was correct - ci-gate failed closed, the
home filter fired, and Daemon.App skipped while dmon-home ran.

The health-monitor test asserted wall-clock elapsed < 300ms to prove
checks fan out concurrently, where serial would be >= 350ms. CI measured
566ms, above even the serial bound, so the proxy had collapsed. Widening
the threshold was not an option: any bound above 350ms passes under a
fully serial implementation, which would leave a test that cannot fail
for its own reason. It now counts probes in flight and asserts the peak
exceeds one, which serial fan-out can never satisfy at any speed.

The supervisor test sampled state every 2ms hoping to catch
repeatedFailure, and on a slow runner never observed it - failing at its
guard before testing the no-flicker property it exists for. It now
consumes every published transition from the store's unbounded stream
and stubs the backoff sleep through an injection point a neighbouring
test already uses, driving a definite fifteen restart cycles.

Both fixes were proven by forcing the failure: making checkOnce serial
trips the concurrency assertion, and reintroducing the historical
unconditional publish(.normal) in the spawned branch trips the
no-flicker assertion.

Tests only; production code is byte-identical. Local green does not
prove CI green - the claim is that neither assertion depends on
wall-clock speed any more.

Change: dmon-home-foundations
Run 31408388611 on PR #109 is green: ci-gate pass, build/test pass,
dmon-home (macOS) pass, Daemon.App correctly skipping.

The iOS portability gate is the last step of the dmon-home job, so a
green job also settles the one thing that could not be checked locally:
macos-latest does carry an iOS SDK for the generic iOS destination.

Change: dmon-home-foundations
PR #109's third CI run failed on a different test from the first, with a
DEVLOG-only commit between the green run and the red one - so the home
suite is flaky on loaded runners rather than regressed.

The child-output drain test gave 4000 lines a 5s budget to land, then
asserted on buffer content regardless of whether the drain had
finished, so one timeout produced two failures and the second one
blamed payload text. The budget is now 30s and the content assertion is
gated on the drain completing.

Widening is safe here in a way it was not for the concurrency assertion
fixed in the previous commit: that threshold was the discriminator, and
any larger value passed under a serial implementation. This one asserts
that all 4000 lines arrived, which a lossy or truncating drain never
satisfies at any budget - append adds exactly one line per call,
eviction is disabled at this capacity, and the reader is a single
sequential read-split-append loop with no double-delivery path.

An audit of all 22 waitUntilTrue sites found no others where the
timeout value itself carries the test's meaning; 21 are left untouched
rather than padded. The one exception is the same ungated-assertion
shape in the backoff-sequence test, which has already been fixed once
for a related timing reason, so it is gated here too.

Tests only; production code is byte-identical.

Change: dmon-home-foundations
Measured on a single unchanged commit, PR #109's CI failed 2 of 4
reruns, and both failures landed in SessionCoordinatorTests - the
earlier pattern of a different area each run has stopped.

Two of that file's sixty iterator.next() sites were fragile. One
asserted on the next snapshot after enqueuing an event, assuming that
snapshot was the one the event caused; under load an unrelated snapshot
arrives first and the assertion reads the wrong slot. It now waits for
the expected condition through a new helper. The other was a waitUntil
whose 2s budget was simply too tight for the runner, so it is 8s - a
hang guard, not a discriminator.

The remaining fifty-eight sites are deliberate sequence assertions and
are left untouched: converting them to wait-until-condition would erase
the property they exist to pin.

The helper takes the stream rather than an iterator, because
AsyncStream.Iterator is not Sendable here, and races its wait through an
actor-guarded box rather than withTaskGroup, which this repo has already
reproduced hanging against a stuck continuation.

Proven by forcing the failure: breaking the reducer to orphan an entry
still trips the converted test, on the entry-role assertion rather than
on the wait predicate - the predicate finds the moment, the assertion
catches the defect.

Tests only; production code is byte-identical.

Change: dmon-home-foundations
This test has been the sole failing test in the last eight red CI runs,
and two prior theories about it were wrong. Widening its wait from 2s to
8s was falsified when the next failure consumed the full budget and took
9.25s: the value it waited for was not arriving late, it was never
arriving.

The mechanism was then forced rather than theorised. Cutting the send
delay to 1ms and instrumenting the update stream showed the entry count
going 2 - carrying a refusal notice rather than the real reply - through
two transient count-of-3 states in the wrong shape, to 4. So polling
every 5ms for a bare count of 3 was waiting on a value that either never
occurs in the required shape or exists for microseconds.

A send marked to fail now parks on a gate until the test releases it,
rather than sleeping a fixed duration and racing the frames. The test
waits for the write to be provably parked, applies the four frames,
waits for a compound predicate naming the whole expected shape, and only
then releases the failure.

That predicate cannot be satisfied before all four frames have landed:
turnEnd clears the open turn, so an ended second entry and a streaming
open turn can only coexist once the fourth frame has synthesised a new
one. The ordering is enforced by a suspension point rather than by
timing margin, and the test's duration is now a stable 0.24s rather than
ranging to 9.25s.

Every original assertion is preserved, and two are now checked after the
failure rather than before it, which is strictly stronger. Proven by
reintroducing the unscoped convertOpenTurnToRefusal that section 8
fixed: the restructured test fails deterministically in 0.217s.

Tests only; production code is byte-identical.

Change: dmon-home-foundations
Commit 260f4fa is 4/4 green on PR #109, meeting the archive condition.

Records the premature green I reported from a single run, the four
rounds of stabilisation, and the three rules earned: a green run is a
sample not a property; before widening a timeout ask whether the value
can occur at all; and an exact-value poll on a racing quantity is a bug
even when it passes.

Change: dmon-home-foundations
@ThatRendle
ThatRendle merged commit 9e8e4bd into main Aug 10, 2026
7 checks passed
@ThatRendle
ThatRendle deleted the change/dmon-home-foundations branch August 10, 2026 22:26
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