feat(home): dmon-home foundations — macOS host, supervision, gateway client - #109
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the
dmon-home-foundationsOpenSpec change: thehome/bucket and thedmon-homemacOS 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-generatedarm64-only unsandboxed
.app, and the local Swift packagesSupervisor,GatewayClient,Power,DeviceKeys.crash backoff, dependency-ordered start/shutdown, process-group kill for spawned children
while adopted ones outlive the host.
Dmon.Networkis the one child started here.gwframe codec, wire-version checking, device-key auth with the secret in the Keychain, the
create→attach handshake, reattach-from-
lastSeq, and turn submission with incrementalrendering. Portable to iOS, enforced by a build.
unattached, and surface child health and connection state so a failed turn is attributable.
home/packages behind its ownhome/**filter, plus theiOS portability gate.
ControlFrames.cs, theclient-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 wholechange 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
dorny/paths-filtergating and whethermacos-latestcarries an iOS SDK forgeneric/platform=iOScan only be proven here.Keychain round trip across a process restart, and whether
Dmon.Networkaccepts acredential this client provisioned.
release.yml's packaging step — unmodified and statically checked;gh release createcannot run locally.Known gap, deliberately deferred
home/App/DmonHomeApp/is compiled by no automated job —make dmon-homebuildshome/Sources/*only, andmake dmon-home-appneedsxcodegen, which the runners do notship. A follow-up change closes it.
Change: dmon-home-foundations
🤖 Generated with Claude Code