# WINDOWS_PORT — Nucleic for Windows, full implementation plan

> **Status:** In execution, 2026-07-31 — **§12 work items 1–10 implemented** (item 10's
> Protocol DLL is Linux-verified and Windows x64 build/export/P/Invoke-verified),
> and **M0 + M1 (a) both complete**: the whole Windows manifest builds and NucleicCarbonTests
> passes on Windows 11 amd64, and the wslc API is verified against a live service. See "Execution
> status" below for per-item state, and §13.1 for the wslc findings. This
> document is the working spec for the native Windows port. It is written to be
> self-contained: an agent picking up any work item below should be able to execute it from
> this document plus the referenced source files, without access to the planning conversation.
>
> **Goal:** full feature parity with the macOS app **except** the macOS-VM / GUI-Linux-VM
> computer-use subsystem (`Sources/NucleicCore/MacVM/`), which cannot exist on Windows.
> Reuse as much existing Swift as possible via the official Swift-for-Windows toolchain.
> The UI is **WinUI 3 (C#)**. Anything not covered by existing Swift is written in **C#**,
> per Microsoft's recommendation for the WSL Container API.

---

## Execution status (updated 2026-07-31)

Work began in worktree `keen-river-gecko-yexx` and has continued in normal dev worktrees.
Verification tiers used below:
**[macOS ✓]** full host build and/or named suites green on a real Mac; **[Linux ✓]**
`nucleicd` build + `NucleicProtocolTests`/`NucleicCarbonTests` green under Swift 6.2.1
Linux; **[Windows ✓]** built and/or tested on a real Windows 11 amd64 box via
`windows/build.ps1` (the tier that replaced the old **[CI-pending]** marker once hardware
became available, 2026-07-29). The full macOS suite state:
1448/1449 pass — the one failure (`MacVMTests/ensureBaseBundleRefusesAnIncompleteControlPlane`)
requires a built macOS-VM base image the dev machine lacks; environmental, unrelated to the
port. NucleicProtocolTests on Linux are green; the 16 MiB
`RUDPTests/liveLoopbackReliableInOrderDelivery` case completes in ~33.7 seconds in the sandbox,
so its correctness-test budget is 60 seconds rather than the former too-tight 30.

**Item 1 — Manifest & gating. Done [macOS ✓][Linux ✓].** `Package.swift`: `onWindows` /
`nonDarwin` / `windowsProtocolOnly` flags; NucleicProtocol's swift-crypto + NIO conditions
widened to `[.linux, .windows]`; per-platform NucleicCore excludes via
`computeNucleicCoreExcludes()` (Windows = the Linux list minus `Container/ContainerManager.swift`,
plus the `Windows/` dirs excluded everywhere else); Windows-only targets/products
(`nucleic-hostd`, `NucleicProtocolC` dynamic library + `include/nucleic_protocol.h`).
Structural note: the product/dependency/target lists are HOISTED into typed top-level `let`s
— inlined in `Package(...)` they exceed the manifest type-checker budget (first hit on the
Linux toolchain). `NUCLEIC_WINDOWS_PROTOCOL_ONLY=1` (manifest-eval env) narrows the Windows
manifest to NucleicProtocol + its tests, because `swift test` builds every declared target
and NucleicCore doesn't compile on Windows until items 9's shims land.

**Item 2 — PortableLogging + WindowsSupport. Done [macOS ✓][Linux ✓].** The `os.Logger`
shim moved from `LinuxSupport.swift` to `Sources/NucleicCore/PortableLogging.swift` (shared
Linux/Windows); `Sources/NucleicCore/Windows/WindowsSupport.swift` documents which
LinuxSupport stand-ins serve Windows as-is. `LinuxSupport.swift` now also carries a
`ContainerError` mirror (the real enum lives in the Windows-excluded engine file) and its
stub `ContainerManager` is gated `!os(Windows)`.

**Item 3 — SecretStore seam. Done [macOS ✓][Linux ✓].** `Sources/NucleicCore/SecretStore.swift`:
`protocol SecretStore` (get/set/remove by optional service + account, `Data` values) +
`SecretStores.default` (KeychainSecretStore over the untouched `KeychainOwnedAccess` on
Darwin; FileSecretStore over `LinuxSecretStore` elsewhere — Windows shares the file backend
until the §4.2 DPAPI `CredentialStore` lands). ~10 call-site files collapsed to single-path
code. Constraints honored (audit findings): absent `kSecAttrService` is load-bearing (most
items have none — a default service would orphan them); `ControlAuth`'s Darwin and Linux key
names deliberately differ and both are preserved; `ClaudeLoginKeychain`'s Linux leg stays
Claude's own credentials file; `CarbonKeyEscrow` (iCloud-synchronizable) stays OUTSIDE the
protocol. No `list` yet — no consumer exists; add with its first caller.

**Item 4 — SandboxEngine seam. Done [macOS ✓][Linux ✓].**
`Sources/NucleicCore/Container/SandboxEngine.swift` with signatures lifted verbatim from
`ContainerEngine` (incl. `exec(...memoryLimitBytes:)`, `runCapturing(...gid:)`,
`reconcileDisk`, `checkDefaultImageUpdate`); `extension ContainerEngine: SandboxEngine {}`
is empty by construction; `ContainerManager.engine` retyped to `any SandboxEngine` (init
default `ContainerEngine()` only where Containerization imports). Added beyond the sketch:
`sessionHostGateway()` (default nil; see item 7) — the pre-container gateway the Windows
flow needs.

**Item 5 — C# broker. Done for the compat surface [Linux ✓ — 18/18 xUnit on .NET 9, AND the real
`WslcFacade.cs` now COMPILES against the real `Microsoft.WSL.Containers` 2.9.3]. D13's
internal-COM arm has its **Tier 1 ("recover") half done and confirmed on hardware** —
`Wslc/WslcInternal.cs` auto-clears a session orphaned by a dead broker instead of leaving the user
a `wsl --shutdown` (§13.3). Tier 2 ("adopt", keeping containers alive across a restart) is
blocked; see §13.2. The subsystem has now RUN end to end (§13.3).**
`Wslc/WslcFacade.cs` was rewritten from the transcription against the surface dumped from the
shipped assembly (§13.2) — every mechanical correction in §13.1's table, plus four things that
table did not have: `Session.Authenticate` (the producer of the `RegistryAuth` string, without
which a private GHCR pull has no auth path), `Container` having **no `Name` property**, the
`IBuffer` digest, and a targeting-pack floor that made the file unbuildable. It no longer carries
"KNOWN WRONG"; it carries a capability report.

Verified in-container on Linux — `dotnet build -p:UseWslc=true -p:EnableWindowsTargeting=true`
restores the preview NuGet and compiles the facade, so the wslc arm is now buildable **without
Windows hardware**. `windows/build.ps1 -Target broker` runs that build alongside the fake-backed
tests, because the tests never compile the one file that touches the SDK.

Landed with it: `IWslc.Capabilities`, merged into the `hello` list (§2.3), so hostd learns from
the handshake that this facade cannot enumerate, reattach or allocate a pty — with
`WslcError.Unsupported` / `.SessionExists` as the matching call-site answers. Stats are real:
there is no `GetStatistics()`, so `container.stats` execs a cgroup v2 read in the guest (`cat` and
`echo` only — no awk, because the image is the user's). A broker reports `["stats", "recover"]`
when D13's Tier 1 arm bound and `["stats"]` when it did not.
 `windows/Nucleic.sln`, `windows/props/Directory.Build.props`
(+ channel defines), `windows/NucleicBroker` (`nucleic-brokerd`): the full §3.3 NDJSON
JSON-RPC surface behind an internal `IWslc`; `OutboundWriter` queues + coalesces stdio
notifications (WinRT event threads never block; `proc.exit` can never overtake output;
LF framing pinned — TextWriter.NewLine would be CRLF on Windows); structured facade errors
as JSON-RPC `-32000` + `data.kind` (`wslc_unavailable`, `not_found`, `not_running`,
`image_pull_failed`, `start_failed`, `ai_unavailable`, plus `unsupported` and `session_exists`);
`ai.generate` seam present (`UnavailableAiProvider` until item 16). `data.kind` is derived from
the **HRESULT number**, never the message — these exceptions frequently arrive with an empty
message, so string matching would classify every one of them as the fallback.
`windows/NucleicBroker.Tests` drives BrokerService with raw NDJSON lines
against `FakeWslc` — these tests pin the wire contract the Swift client (item 6) consumes.

**Item 6 — Swift wslc client. Written [CI-pending].**
`Sources/NucleicCore/Container/Windows/`: `WslcBrokerClient` (spawns brokerd via
`ChildProcess`, reuses `JSONRPCConnection(includeVersionHeader: true)`, routes
proc/session/pull notifications, exponential-backoff restart + `onReattach`),
`WslcContainerEngine` (full `SandboxEngine` conformance: one session per channel, image
ensure with the same registry-auth rule as the macOS engine, the identical in-guest
control-plane probe script, stats-delta resource sampling, channel-scoped
`reconcileDisk`), `WslcProcessHandle` (broker events → the shared `LineSplitter` framing;
`forceCloseStreams` on broker loss). `checkDefaultImageUpdate` returns
`.idle` until the registry-HEAD logic relocates behind the broker (M2).

**Two fixes after the first live run [Windows ✓ — NucleicCore compiles on titan]:**
- **`error.data` is no longer dropped.** `JSONRPCConnection.RPCError.server` gained
  `kind: String?` (populated from `error.data.kind`), and `WslcContainerEngine.callMapped` now
  branches on it instead of inferring from which method failed — a guess that was sometimes wrong,
  e.g. a `proc.exec` refused because the image cannot drop privileges is not `notRunning`. The
  method-based mapping stays as the fallback for an older broker. Only one call site matched that
  enum case, and the `JSONRPCConnection` tests assert the error *type*, not its payload, so the
  added associated value is contained. One thing deliberately NOT done: `unsupported` means
  *this broker build cannot serve the call as asked* — not that it never will — and its useful
  signal is "retrying will not help". `ContainerError` has no case carrying that, so it maps to
  `.startFailed` (which reads as transient) with the reason in the message. A dedicated
  `.unsupported` case is the right fix and needs the Darwin definition and its `LinuxSupport`
  mirror to move together — not something to slip in from the Windows side.
- **A dropped-notification hang, the mirror of the broker bug in §13.3.** `WslcProcessHandle`
  registers its sinks only *after* `proc.exec` returns, but resolving that request's continuation
  merely *schedules* it while the notification drain is a separate task already reading the next
  line. For a command that finishes instantly, `proc.stdout` and `proc.exit` arrived first and
  `route` silently discarded both — so `wait()` never returned. `runCapturing` was the most
  exposed path, since every short probe it runs is exactly that case. Fixed by buffering events
  for not-yet-registered procIds and draining them in `register(procId:sinks:)`, with the exit
  always kept and the buffer cleared on broker death (procIds restart at 1).

**Item 7 — Control plane. Done end-to-end [macOS ✓ incl. MCPApprovalServerTests 46/46 +
the 1449-test adapter suites][Linux ✓][Windows leg CI-pending].**
Guest: `control-bridge.js` dials `NUCLEIC_CONTROL_HOST`/`NUCLEIC_CONTROL_PORT` when set
(unix-socket branch kept for macOS; proxy pair `NUCLEIC_PROXY_HOST/PORT` supported).
Host transport: `MCPApprovalServer.swift` gained an in-file `#if os(Windows)` NIO leg —
`NIOByteConn` + `NIOByteConnHandler` + `performNIOBind` with the same self-heal +
single-flight contract as the Darwin/Linux listeners (in-file rather than §4.3's separate
`NIOByteConn.swift` because `ByteConn` is deliberately private there); the AF_UNIX members
and binds are `#if !os(Windows)`; `start(unixSocketPath:)` throws on Windows;
`isUnixSocketListening` returns false there. NucleicCore links NIOCore/NIOPosix on
`.windows`. Sequencing: new `Sources/NucleicCore/Container/ContainerControlPlane.swift` —
`bringUp(spec:manager:server:)` encodes the per-platform ORDER (macOS: `ensureRunning` →
`start(unixSocketPath:)` → listening guard; Windows: `sessionHostGateway()` →
`start(host: gateway)` → `spec.withEnvironment(merging:)` the endpoint → `ensureRunning`),
and all four backends (Claude, Codex app-server, Codex exec, Grok ACP) are rewired onto it.
Supporting seams: `ContainerSpec.withEnvironment(merging:)`; `SandboxEngine.sessionHostGateway()`
(wslc engine ensures the session and returns the gateway; nil on macOS). Accepted gap: the
`linux_container` scratch-container auxiliary listener (ClaudeCodeBackend, `try? start(unixSocketPath:)`)
degrades to no-control-plane on Windows — same behavior custom images already get.

**Item 8 — hostd. Bring-up shared + main written [shared: macOS ✓/Linux ✓; Hostd.swift
CI-pending].** `Sources/NucleicCore/NucleicHeadless.swift` factors the headless bring-up
out of nucleicd (`scrubBlankAPIKeys`, `makeStore(support:sandboxEngine:)` — Windows MUST
inject the wslc engine, `loadPersistentState`); nucleicd is rewired onto it unchanged in
behavior. `Sources/nucleic-hostd/Hostd.swift`: named-mutex single instance
(`Local\nucleic-hostd-<channel>` via CreateMutexW), `%APPDATA%\Nucleic<channelSuffix>` data
root, brokerd spawn + wslc session/gateway warm-up (best-effort — a missing WSL stack is an
onboarding condition, not a dead host), engine wiring (pull progress → download-progress
surface; reattach → gateway invalidation), `lan,relay` transport default,
`SetConsoleCtrlHandler` → `stopSyncServer` + broker shutdown. Still open in hostd: the
rendezvous file + local renderer loopback listener (the DLL client half is now present; hostd
still needs to expose the local sync port and item 11 needs to consume it) and DNS-SD advertise
(item 13).

**The post-reattach `ContainerManager.reconcile` sweep is NOT needed, and was removed from this
list after being investigated (2026-07-30).** It was specified for a world where containers
survive a broker restart — D13 Tier 2. Under Tier 1, which is what ships:
- Tier 1 *terminates* the orphaned session, so no container survives for a sweep to find
  (confirmed on hardware: the recovery run enumerated 5 containers and terminated them, §13.3).
- `WslcContainerEngine.ensureRunning` reads `containerState` first and only creates when the
  container is `absent`, so a vanished container is recreated on next use — it self-heals without
  a sweep.
- `ContainerManager.reconcile(activeSessions:)` only forwards to `engine.reconcileDisk(keepNames:)`
  anyway. It would not clear the manager's own stale bookkeeping (`physicalNames`, the `active`
  refcounts), so it was never the right tool for "the mechanism restarted underneath us" even in
  principle.
Revisit with Tier 2, where surviving containers make the reconcile meaningful again.

**Item 14 (partial) — CI. WITHDRAWN, 2026-07-29 — see §14.0.** The authored
`.github/workflows/windows.yml` was deleted rather than enabled; `windows/build.ps1` on the
dev box is the Windows gate. The description below is retained as the design record for
whenever a hosted/self-hosted runner is worth it. It read: `.github/workflows/windows.yml`:
required `protocol` job (x64 `windows-2025` + `windows-11-arm`, toolchain pinned by
`windows/props/swift-version.txt`, `NUCLEIC_WINDOWS_PROTOCOL_ONLY=1`); experimental
continue-on-error `core` job — the M0 gap list, flips to required at M2 (includes vcpkg
SQLite provisioning for GRDB, §14.2); required `broker` job (`dotnet test windows/Nucleic.sln`,
no wslc NuGet). `runs-on` reads `WINDOWS_X64_RUNNER`/`WINDOWS_ARM64_RUNNER` repo variables
with hosted fallbacks (§14.1). Toolchain pin is `swift-6.3.3-RELEASE` — floor set by the
runner's MSVC (VS 2026 STL needs clang ≥ 20; Swift 6.2.x bundles 19).

**Item 9 — Windows shims. Written [macOS ✓ (NucleicCore builds; 129 tests across the 9
touched suites green)][Windows leg CI-pending].** Five new files under
`Sources/NucleicCore/Windows/` plus two shared seams:
- `WindowsSignals.swift` — `pid_t` / `SIGKILL` / `SIGTERM` / `SIGINT` as module-internal
  stand-ins (ucrt has no SIGKILL and no `kill`), so ~30 shared call sites stay platform-free.
  Deliberately in a file that does NOT import WinSDK, so it can never collide with ucrt's.
- `ProcessTree.swift` — `kill(pid:sig:)` (SIGKILL → `TerminateProcess` **with exit code 137**
  so `Backend.isSIGKILL` classifies a killed host command identically everywhere; SIGTERM/SIGINT
  → `GenerateConsoleCtrlEvent`, a documented no-op until a spawn site opts into its own process
  group, with the caller's grace→SIGKILL ladder doing the real work), `WindowsJobObject`
  (`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, attached in `ChildProcess.init` right after
  `Process.run()` — corelibs exposes neither creation flags nor the process handle), and the
  Toolhelp / `GetProcessTimes` / `GetProcessIoCounters` backings for `ProcessHost`'s three
  sampling ladders.
- `FilePermissions.swift` — protected single-ACE DACL via `SetEntriesInAclW` +
  `SetNamedSecurityInfoW`, behind the new shared `SecretFile` (below).
- `WindowsEnvironment.swift` — registry `Path` (`HKLM` Session Manager + `HKCU\Environment`)
  for `LoginShellPATH`, plus `setenv`/`unsetenv` over `SetEnvironmentVariableW` (POSIX-only
  names that `ucrt` lacks and `NucleicHeadless`/`RunnerCredentialVault`/both hosts call).
- `PowerBlocker.swift` — `SetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED)`, driven
  from a 5-second main-actor tick in hostd off `store.localTurnInFlightCount` (no SwiftUI
  `onChange` headlessly). **Default-on**, unlike the Mac's opt-in Smart Sleep — a background
  host that suspends mid-turn kills the agent, the container and the sync link at once — but
  it reads the same `nucleic.power.blockSleep` key, so one lever governs both.
- `WindowsFileIO.swift` — `MoveFileExW(REPLACE_EXISTING|WRITE_THROUGH)` for
  `CarbonShardStore.atomicWrite`: the CRT's `rename` *fails* on an existing destination, which
  for a content-addressed store turns every idempotent re-write into an error.
- Shared seams: `SecretFile.swift` (owner-only create/write/restrict — the POSIX legs are the
  byte-identical `FileManager` calls they replaced, ~12 credential sites retargeted) and
  `NucleicPaths.swift` (one data root: `<Application Support>/Nucleic` unchanged on
  macOS/Linux, `%APPDATA%\Nucleic<channel>` on Windows, `NUCLEIC_DATA_DIR` override honored by
  *every* derived path — previously hostd's root governed only the store path it passed to
  `makeStore`). `ApprovalServerRegistry`, `MoveLog`/`MeshDebugLog`, `TailnetTransport` and
  `LinuxSecretStore` now derive from it.
Also in this item, found by sweeping the Windows-compiled file set for POSIX-only symbols:
`MCPApprovalServer`'s libc-alias ladder gained an `os(Windows)` arm (its `#else` was the Glibc
one, so Windows fell into `Glibc.bind`); `SessionController`'s `getuid()/getgid()` use
`ContainerSpec.narosAgentUID` on Windows (no host uid to mirror, and the guest runs under WSL);
`ChildProcess` resolves bare executables over PATH × PATHEXT (no `/usr/bin/env`);
`LoginShellPATH` splits/joins on `;` (splitting a Windows PATH on `:` shreds every `C:\…`
entry) and reads the registry instead of probing a shell, while `LoginShellEnv` returns nil
(a Windows process is born with the merged machine+user environment — nothing to recover);
`nucleicd` left the Windows manifest entirely (`nonWindowsTargets`/`nonWindowsProducts` —
its control endpoint is a BSD-socket listener with a `DispatchSource` signal handler, and
`swift test` builds every declared target). `FileWatcher` from the §4.3 table proved
unnecessary — see the table's note.

**M0 gap closed, 2026-07-29 — the WHOLE Windows manifest builds, and NucleicCarbonTests
runs.** `build.ps1 -Target core -Test` compiled *every* declared target on Windows 11 amd64
— `NucleicCore`, `nucleic-hostd`, `NucleicProtocolC`, `nucleic-smoke`, `NucleicCarbonTests`
— and ran 74 Carbon tests, of which 61 passed on the first attempt. All 13 failures had a
single cause, and it was a **port bug, not a gap**: `GitRunner` has its own spawn path, and
item 9 taught only `ChildProcess` to PATH-search, leaving `GitRunner` pointing at
`/usr/bin/env`. Windows has no such file, so every `git` invocation failed with corelibs'
"The file doesn't exist". Fixed by giving all three host spawn sites ONE resolver —
`resolvedSpawnTarget(executable:args:path:)` in `ProcessHost.swift` — rather than a second
copy of the rule, since a second copy is precisely what caused it. Two `Process` spawns
elsewhere still hardcode POSIX absolute paths and will need attention before those features
work on Windows, neither of them test-visible: `NvrsionTrunk.swift`'s `/bin/sh` and
`GitHubCredentials.swift`'s `/usr/bin/ssh-keygen` fallback.

This also retired an anti-pattern in the tooling: `build.ps1` labelled a failing `core -Test`
"FAIL (expected — items 10+ gap list)" and exited 0, which is how a real regression read as
a known gap. No leg is excused now — every one has been green on Windows at least once, so
red means look.

**`NucleicCore` BUILDS ON WINDOWS.**
`.\windows\build.ps1 -Target core -Persist` is green on Windows 11 amd64 with the item-9
shims in place. That was the M0/M2 risk the whole plan was sequenced around, and it is now
behind us: the port is no longer speculative on the Swift side. Consequences recorded in the
tooling — the `core` job's `swift build --target NucleicCore` step is **required** on x64
(ARM64 stays `continue-on-error`, no ARM64 box has run it), and `build.ps1` no longer bills a
core failure as an expected gap. `swift test --filter NucleicCarbonTests` stays a per-step
probe on both arches, because it builds EVERY declared target — `nucleic-hostd`,
`NucleicProtocolC`, `nucleic-smoke` — which are items 10+ work, not NucleicCore.

**Item 10 — Protocol DLL. Implemented [Linux ✓: 4/4 ABI/transport + 20/20 projection
equivalence + 73/73 bridge/fixture/wire tests under Swift 6.3.3][Windows ✓: x64 DLL build,
all 11 `np_*` PE exports, and a live C# P/Invoke smoke on Titan].** The JSON bridge,
its tag surface, complete golden ABI corpus, client lifecycle, transports, callbacks, intent
path, and transcript projection entry points are present.
- `Sources/NucleicProtocol/Sync/ProtocolJSON.swift` — **divergence from §6, deliberate**: the
  bridge lives in `NucleicProtocol`, not "in the DLL layer only" as §6 sketched. §6 was written
  assuming a hand-written per-case JSON mapping; in fact `ClientMsg`/`HostMsg` are already
  `Codable` (tagged `{"t": …}`) and the CBOR wire codec *is* a `Codable` implementation, so the
  JSON view rides the same conformance and there is no per-case mapping to write — or to drift.
  Putting it in NucleicProtocol makes it testable by `NucleicProtocolTests`, which already runs
  on macOS, Linux AND the required Windows CI leg; in the DLL it would only be reachable from a
  Windows-only target. The DLL becomes a veneer.
- What that leaves as real decisions, all pinned and tested: keys `.useDefaultKeys`; dates as
  seconds-since-2001 `Double` (what CBOR already emits, so a date reads identically on both
  sides — not ISO8601, not Unix epoch); `Data` as base64 (the one deliberate divergence from
  CBOR, which emits a byte array — a 4 KB blob as 4,096 JSON numbers is hostile to `byte[]`);
  non-conforming floats as `"Infinity"`/`"-Infinity"`/`"NaN"` rather than a throw; output
  `.sortedKeys` + `.withoutEscapingSlashes` so fixtures compare byte-for-byte.
- `ProtocolJSON.tag(of:)` — the `"t"` discriminator as an **exhaustive** switch over both enums
  (115 cases). Adding a message kind is now a compile error until it is named, and a test
  cross-checks every label against the `"t"` the encoder actually emits, so the switch cannot
  silently disagree with the wire. This is the surface a C# discriminator keys on.
- `fixtures/protocol-abi/v1/{client,host}/` now holds 115 paired vectors: the exact canonical
  `.json` bytes the C ABI exposes and the exact wire CBOR as `.cbor.hex` (64 client kinds + 51
  known host kinds; `HostMsg.unknown` is a forward-compat fallback, not a kind). The Swift test
  catalog constructs one representative of every case, asserts unique/complete tags, verifies
  both fixture bytes and both decode paths, rejects stale/extra files, and has an explicit
  `NUCLEIC_UPDATE_PROTOCOL_ABI=1` regeneration mode documented in the fixture README. It lives
  in `NucleicProtocolTests`, not a Windows-only `NucleicProtocolCTests` target, so the same gate
  runs on every platform that builds the wire layer; the C# half joins in item 11 when that test
  project exists. Building the full catalog exposed and fixed one real drift the old spot-check
  missed: Swift's case is `.transcriptFetchComplete`, but its frozen wire tag is
  `"transcriptComplete"`.
- `NIOClientFrameChannel` is the Windows/Linux TCP leg: one shared NIO event-loop thread,
  the existing four-byte `WireFraming`/`FrameAccumulator`, and no changes to `SyncClient` or
  Noise. `np_client_connect_local` accepts hostd's `{pid,port,localPSK}` rendezvous (also `psk`
  as an alias); remote connect accepts pinned LAN/tailnet TCP and relay descriptors; pairing
  consumes the existing `nucleic://pair?d=…` payload. `direct` is correctly rejected as a dial
  descriptor because it is a negotiated relay upgrade.
- Client creation persists the 64-byte `DeviceIdentity`, stable device id, and a base64
  host-id→static-key pin table under `identityDir`; the pin learned at XXpsk0 readiness is
  committed before the `ready` state callback. Entry-point return values report synchronous
  acceptance/validation (`0`, or stable `-1…-4` codes); TCP/Noise outcomes remain asynchronous.
- The `SyncClient.Event` mapping is exhaustive at compile time. Every message-shaped event is
  re-encoded with `ProtocolJSON` and delivered to `np_event_cb`; lifecycle events
  (`connecting`, transport selection, `ready` with `Welcome` + learned host key, failure,
  closed) go to `np_state_cb`. A real process-wide single-thread executor—not merely a serial
  GCD queue—pins both callback types and every client handle to one dedicated DLL thread.
  `np_client_close` cancels/drains that handle's pending work and is a callback barrier, so C#
  may release its callback context as soon as close returns.
- `TranscriptProjectionABI` wraps the unchanged shared incremental projector and maintains
  events per session across `snapshot`, `events`, transcript backfill chunks, and reverts. Each
  `np_projection_apply` returns a versioned minimal middle splice
  `{start,deleteCount,items}`; irrelevant host messages and idempotent replays return no splices.
  Render-item `seq` is a decimal string so the full `UInt64` survives JSON's 53-bit ecosystem.
- `NucleicProtocolC` and its tests are declared on Linux as a compile contract while the dynamic
  product remains Windows-only. The four tests cover identity/handle ownership, exact projection
  splices including revert, dedicated callback-thread delivery, and a real NIO TCP
  frame/deframe echo. The existing incremental-projection equivalence suite now runs on Linux too.
- **Windows hardware rerun (Titan, Swift 6.3.3, x86_64):** the full manifest emitted a
  69,157,888-byte `NucleicProtocolC.dll`; `llvm-readobj --coff-exports` found exactly the 11
  header entry points. A live PowerShell-hosted C# P/Invoke smoke loaded that PE, created and
  closed a client, persisted all three identity files, observed `NP_INVALID_STATE` (`-2`) for
  an intent before connect, projected a canonical `pong` to `{"splices":[],"version":1}`, and
  freed the returned string. `NucleicCarbonTests` passed 83/83. The protocol suite passed
  302/303 on its first run; the only miss was the 16 MiB RUDP loopback reaching the old
  30-second test deadline. It failed identically in isolation, then passed with a larger timing
  probe; Linux measured completion at ~33.7 seconds, so the committed budget is 60 seconds.
  This validates the DLL half of M1(c); its C# connect-pair-echo host round trip remains item 11.

**M1 spike (a), phase 1 — DONE and CONFIRMED ON HARDWARE, 2026-07-29.**
`windows/spikes/WslcApiDump --probe` runs clean on Windows 11 amd64 with WSL 2.9.3: all 54
recorded members read `ok` against the shipped `wslcsdkcs.dll` (37 API types + 22 ABI
projection types), and `GetMissingComponents()` returns empty. **Re-confirmed after the item-5
facade rewrite:** the assumption list grew to 59 as the facade took on `Session.Authenticate`,
`Container.Id`, `InstallProgress.*`, `ContainerState.Deleted` and `Process.GetInputStream`, and
all 59 read `ok` on hardware — so every member the rewritten facade calls exists in the shipped
package, not just in the metadata dump it was written from. The surface in §13.1 — derived
offline from the `.nupkg` — is therefore verified against the real assembly, not just read
from it. Two environment facts worth keeping: the SDK needs **WSL ≥ 2.9.3, which is
pre-release-only** (`wsl --update --pre-release`; a plain `wsl --update` will not reach it),
and an installed-but-too-old WSL answers `ERROR_NOT_SUPPORTED` (0x80070032) rather than the
`REGDB_E_CLASSNOTREG` (0x80040154) a machine with nothing installed gives — opposite
diagnoses that look alike.

**`--session` has also now run** (§13.1, "live session findings"), and it **confirms D13**: the
`Session` constructor is lazy (a second one with the same name constructs fine), but `Start()`
refuses with `ERROR_ALREADY_EXISTS`. The compat surface cannot re-adopt a running session, so
§2.3 reattach does need the internal COM interface.
Also worth noting for D13's drift concern: the service is **2.9.4** while the NuGet is pinned
at 2.9.3 — the two version independently, and already have.

**The real wslc API is known; see §13.1.**
Obtained without Windows hardware: the package is public on nuget.org, so the `.nupkg` was
downloaded, its C#/WinRT projection assembly extracted, and its metadata read with
`MetadataLoadContext` in a Linux container. `windows/spikes/WslcApiDump` (reflection-based, so
it cannot fail to build) remains checked in as the **drift detector** for the next package
version, with its assumption list retargeted from guesses to the surface actually observed.

The transcription in `Wslc/WslcFacade.cs` was substantially wrong — as expected, which is why
it sat behind `IWslc` — and, more importantly, three of its errors are not typos but **missing
capability**. See §13.1 for the full comparison; the headline is that the SDK has no container
enumeration, no per-container statistics, and no pty, and that the §5 gateway address does not
come from the SDK at all.

**Not started:** items 11–13, 15–16. **M1 is complete** — (a1) the API surface, (a2) the
live happy path, and (b) gateway reachability are all answered on hardware (§13.2, §13.3). The D13 internal-COM arm is
unblocked and unwritten — §13.2 records its entry point (confirmed on hardware) and the one
vtable check that should precede writing it.

**Known Windows gaps opened but not closed by item 9** (each is a listener, not a shim):
the Claude token proxy has no Windows host listener — `ClaudeTokenProxy`'s TCP and AF_UNIX
legs are both `#if canImport(Network)`, so the opt-in proxy degrades to off on Windows even
though `control-bridge.js` already understands `NUCLEIC_PROXY_HOST/PORT`; and the OAuth
loopback listener (`OAuthLoopback.swift`, wholly Network.framework) is absent, which is what
§8 step 5's agent sign-in needs. Both want the same NIO treatment `MCPApprovalServer` got in
item 7 and are sized with item 11 (renderer onboarding), not here.

**Environment/toolchain notes for future agents** (also in the session memory): Linux
verification ran from an agent sandbox with Swift 6.2.1 (ubuntu24.04 tarball) extracted via
Python `tarfile` — GNU tar's delayed-symlink mode-000 placeholders are unremovable on
virtiofs; needs `libncurses.so.6` → `libncursesw` shim, vendored `sqlite3.h` + `libsqlite3.so`
symlink, and `-Xlinker --allow-shlib-undefined` (this toolchain's libswiftObservation
references an unexported `swift::threading::fatal`). Linux resolution rewrites
`Package.resolved` (drops Sparkle) — restore it after builds. Scratch persists in the
worktree's `.linux-verify/`.

**The C# legs also run from a Linux agent sandbox**, including the wslc facade, which is worth
knowing before anyone waits on Windows hardware for a broker change. Install the .NET 9 SDK with
`dot.net/v1/dotnet-install.sh` (it is not preinstalled), and export
`DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` — the sandbox has no ICU, and without it every `dotnet`
invocation dies in `CultureInfo`'s static constructor with a stack trace that names globalization
and not the missing package. Then:

```bash
dotnet test  windows/Nucleic.sln                                              # fake-backed, 18/18
dotnet build windows/NucleicBroker/NucleicBroker.csproj \
             -p:UseWslc=true -p:EnableWindowsTargeting=true                   # the REAL facade
```

**Limit of ssh-staging for SWIFT changes.** Copying individual files onto the Windows box works
for self-contained ones (anything under `Sources/NucleicCore/Container/Windows/`,
`JSONRPCConnection.swift`, `Hostd.swift`) and **fails for files coupled to the rest of the tree**.
Patching a newer `AppStore.swift` onto a checkout at an older `origin/dev` produced errors naming
*other* files' members (`SessionController.setRoutingNote`, `StartChatRequest`) — version skew, not
a defect in the change. So a cross-cutting Swift change cannot be verified by staging: it needs the
branch on `origin/dev` so the box can pull a coherent tree. Plan Swift work accordingly, or keep it
inside the Windows-only files.

`EnableWindowsTargeting=true` is what lets a non-Windows SDK restore the Windows targeting packs;
the preview NuGet itself is public, so it restores anywhere. The same trick reads the API surface
without hardware: point `MetadataLoadContext` at
`~/.nuget/packages/microsoft.wsl.containers/<version>/lib/*/wslcsdkcs.dll` with the
`Microsoft.Windows.SDK.NET.Ref` assemblies on the resolver path (§13.1's re-derivation recipe,
which is how §13.2's findings were obtained).

---

## 0. Locked decisions (user-approved — do not relitigate)

| # | Decision |
|---|---|
| D1 | **Headless Swift core + C# renderer.** `nucleic-hostd.exe` (Swift, Windows toolchain) owns sessions, git, storage, approvals, and sync. The WinUI 3 app is a *renderer* over the existing Noise/CBOR sync protocol via local loopback — the same "N renderers, one authority" pattern the iPhone app uses. No Swift-in-C#-process embedding of the core. |
| D2 | **Control-only.** Windows supports ONLY Nucleic Control projects. Every agent session runs inside a WSL container. There is no host-spawned agent path on Windows. (Control will be forced on macOS soon too; plan accordingly.) |
| D3 | **Containerization = WSL Container API** (`Microsoft.WSL.Containers` NuGet, WinRT; public preview now, GA fall 2026), driven from a C# broker process. Not full VMs, not Docker, not `wsl --import` distros. |
| D4 | **MacVM subsystem excluded** (both macOS-guest and GUI-Linux-VM computer-use paths). MDM (`NucleicMDM` server role) and `NucleicPowerHelper` are also not ported (macOS-VM-only / macOS-concept). |
| D5 | **Distribution:** MSIX packaging + per-channel `.appinstaller` sideload feeds on Cloudflare R2, mirroring the five-channel Sparkle model (dev/canary/beta/rc/stable). Authenticode via Azure Trusted Signing. Store/winget deferred to post-beta. |
| D6 | **On-device AI assists:** Windows AI Foundry / Phi Silica (Windows App SDK) where hardware supports it; graceful degradation to the existing heuristic fallbacks elsewhere (the same behavior as a Mac without Apple Intelligence). |
| D7 | **Renderer protocol client = Swift DLL.** `NucleicProtocol` (+ transcript projections) compiled into `NucleicProtocolC.dll` exposing a thin flat C ABI; C# P/Invokes it. No C# reimplementation of CBOR/Noise/envelope. |
| D8 | **Repos on NTFS.** Control clones + worktrees live at `%USERPROFILE%\.nucleic\control`, bind-mounted into containers via `ContainerVolume`. Host git ops keep using `GitRunner`/`WorktreeManager` with Windows `git.exe` (bundled MinGit), unchanged. **Measured on naros (§13.3): the mount costs ~15–23x — `git status` 1.5 s vs 65 ms, writes 15x. D8 STANDS; the indicated mitigation is write-hot dirs on a `ContainerNamedVolume`, not relocating the tree. An earlier `find`-based figure of 164x was inflated ~10x.** |
| D9 | **Full remote parity:** DNS-SD advertise + QR pairing for the iPhone remote over LAN, the relay transport, AND tailnet (libtailscale built for Windows as a DLL). |
| D10 | **x64 + ARM64 from day one.** The naros-agent OCI image gains a multi-arch (amd64+arm64) build. |
| D11 | **Self-hosted Windows CI runners** (one x64 first, ARM64 later) for the container e2e suite; hosted runners lack nested virtualization. |
| D13 | **Two wslc surfaces, no CLI.** The broker binds the stable compat SDK (`Microsoft.WSL.Containers`) for everything it covers, and the service-internal COM interface (`wslc.idl` — `IWSLCSessionManager`, IID `82A7ABC8-6B50-43FC-AB96-15FBBE7E8760`) for the five things it does not: container enumeration, per-container stats, session/container reattach, pty + resize, and the VM GUID. Shelling out to `wslc.exe` is rejected — a spawn per call, scraped text, no events, and a second mechanism to maintain. The internal ABI is explicitly unstable, so the broker probes it at startup and degrades (reporting through the `capabilities` hello) instead of failing. Everything stays behind `IWslc`. See §13.1. **Amended 2026-07-29 (§13.2):** the compat arm is written and the degradation reporting is live. The internal arm's entry point is **confirmed on hardware** — `wslc.idl` declares no activatable class, but `WSLCCompatSessionManager` (`a9b7a1b9-0671-405c-95f1-e0612cb4ce8f`, the same class the SDK activates) answers a QI for `IWSLCSessionManager`. One row of D13 is withdrawn, though: `IWSLCVirtualMachine` is unreachable from a client in the shipped IDL — only a factory the SYSTEM service owns produces one — so the control-plane/VM-GUID row has no route today and §5 keeps gateway TCP. |
| D12 | **The Windows app is also a remote client.** It can pair with and render any Nucleic host (its own hostd, or a Mac) — host-picker UI, reusing the same pairing/connection code. |

---

## 1. Background: what exists today (verified 2026-07-28)

### 1.1 The codebase already has the port seams

- `Package.swift` (swift-tools-version 6.2) computes `onLinux` at manifest-eval time
  (`Package.swift:62-64`), drops `darwinOnlyTargets` (`:70`, `:402`), conditionalizes
  dependency lists (`:219`, `:266`), and excludes Apple-framework sources from
  `NucleicCore` via a per-target `exclude:` list (`:352-381`). The manifest is also
  channel-aware (`NUCLEIC_CHANNEL` env → product name + `NUCLEIC_DEV/CANARY/BETA/RC/STABLE`
  compile define).
- Source-level gating is almost entirely `#if canImport(...)` (only 3 `#if os(...)` in all
  of `Sources/`: `Claude/ClaudeCredentialBroker.swift:360`,
  `Codex/CodexCredentialBroker.swift:166`, `Project.swift:465`).
- `Sources/NucleicCore/LinuxSupport.swift` (~443 LOC) provides stub actors
  (`ContainerManager`, `MacVMManager` mirrors that throw `LinuxUnsupported`) plus an
  `os.Logger` shim, so shared call sites compile unchanged on non-Darwin. **This is the
  template for `WindowsSupport.swift`.**
- `Sources/nucleicd/` (645 LOC: `Nucleicd.swift`, `ControlEndpoint.swift`) is a proven
  headless host over `NucleicCore` for Linux (the Covalence Cloud Runner): one `AppStore`
  graph, `startSyncServer()`, pairing mint, signal-driven shutdown. **This is the template
  for `nucleic-hostd`.**
- `NucleicProtocol` builds for macOS, iOS, and Linux already. On non-Apple platforms it
  swaps CryptoKit→swift-crypto and URLSession-WebSocket→SwiftNIO via `#if canImport`.

### 1.2 Target portability inventory

| Target | LOC (≈) | Verdict for Windows |
|---|---|---|
| `NucleicProtocol` (57 files) | 13,200 | **Portable as-is.** AgentEvent model, ClientMsg/HostMsg envelope (`Sync/MessageEnvelope.swift` ~1,079 LOC; `Sync/WireMessages.swift` ~831), custom CBOR codec (`CBOR/`), Noise (XXpsk0 pairing + IK reconnect, `Noise/`), `SyncClient.swift` (generic over `FrameChannel`), STUN/UDP punch (`Sync/Direct/`), relay WebSocket (NIO off-Apple). |
| `NucleicCore` (177 files) | 79,600 | **The battleground.** Exclude `Container/` engine files + all `MacVM/` engine files (~15.7k LOC, mirroring the Linux exclude list); shim the rest (see §4). |
| `NucleicApp` (76 files) | 30,000 | **Do not port.** SwiftUI/AppKit cockpit — this is the WinUI 3 rewrite target. Includes SwiftTerm terminal, Sparkle updater, Dock-bounce/NSSound hooks, AppleIntelligence UI. |
| `NucleicTailnet` | 640 | Portable Swift over `#if canImport(TailscaleKit)`; Windows needs a libtailscale DLL leg (D9, §9.1). |
| `NucleicMDM` | 2,540 | Not ported (macOS-VM-only role, D4). Crypto/ASN.1 parts are portable if ever needed. |
| `nucleicd`, `nucleic-smoke`, `nucleic-punch-harness` | ~1,200 | Portable; `nucleic-smoke` becomes the Windows e2e driver (§14). |
| `NucleicPowerHelper` / `NucleicPowerProtocol` | ~215 | Not ported; replaced by `SetThreadExecutionState` in hostd (§4.3). |
| `ios/NucleicRemote` | ~18,300 | Not ported, but its `Models/RemoteStore.swift` (3,396 LOC, pure host-state projection) + `Models/HostConnection.swift` (1,294 LOC, owns `SyncClient` over a `FrameChannel`) are **the structural template for the C# renderer** (§7). Its `Views/Transcript/` projection files are compiled into the host-testable `NucleicRemoteProjection` SPM target — reused by the DLL (§6). |

External dependencies (from `Package.resolved`): GRDB 7.11 (**Windows support since 7.10**),
swift-crypto, swift-nio(+ssl), swift-certificates/asn1, swift-log, swift-system,
swift-collections etc. — all portable. `SwiftTerm` and `Sparkle` are App-target-only
(replaced on Windows). `third_party/containerization` (Apple's framework, vendored/patched)
and `TailscaleKit.xcframework` are Apple-only binaries.

### 1.3 NucleicCore internals: what needs what

| Area | Files (representative) | Windows disposition |
|---|---|---|
| App engine / state | `AppStore.swift` (14k LOC, `@MainActor @Observable`, conforms to `SyncHostBridge`) | Runs headless inside hostd as the sync authority (as nucleicd proves). |
| Session engine | `SessionController.swift` (2,071), `Transcript.swift`, `TranscriptProjection` | Portable. |
| Agent backends | `Claude/` (9.5k), `Codex/` (2.4k), `Grok/` (1.2k), `Backend.swift`, `ACP/` | Portable; container-exec path only (D2). |
| Approvals / control plane | `Claude/MCPApprovalServer.swift` (~3.9k), `Claude/ApprovalServerRegistry.swift` | Portable logic; needs a NIO `ByteConn` (§5). |
| Git | `Git/GitRunner.swift`, `Git/WorktreeManager.swift` (1,079), `Git/GitHubCredentials.swift`, `Git/ProjectCloner` | Shells out to `git`/`gh` CLI — portable (D8). Credentials → SecretStore (§4.2). |
| Process spawning | `ProcessHost.swift` (578: `ProcessSpec`/`ProcessHandle`/`ChildProcess`, plain pipes, **no PTY**), `LoginShellPATH/Env` | Foundation `Process` works on Windows; signals + login-shell probing need shims (§4.3). |
| Persistence | `Persistence/GRDBMetadataStore.swift` (978, WAL `DatabasePool` under Application Support), `SessionMetadataStore` protocol | Portable; path mapping to `%APPDATA%` (§4.4). |
| Sync host | `Sync/SyncHost.swift` (706), `ConnectionHandler.swift` (1,146), `SyncHostBridge.swift` (473) | Portable; LAN transport files are Network.framework and already Linux-excluded — NIO rewrite (§4.3). |
| Containers | `Container/{ContainerEngine,ContainerEngine+Rootfs,ContainerManager,ContainerizedProcessHandle,MemoryBalloon,OCIArtifact,CommandInterceptor}.swift` | Engine = Apple-only, replaced by wslc broker (§3). **Manager = policy, ported** (§3.1). `CommandInterceptor.swift` is pure values — keep. |
| MacVM | `MacVM/` (28 files, 12.8k) | Excluded (D4); keep pure value files (`MacVMSpec`, `MacVMSurface`) compiling, stub `MacVMManager`. |
| Crypto | CryptoKit sites | Free via existing `#if canImport(CryptoKit)` → swift-crypto seams. |
| Keychain | 24 `Security` sites in ~14 files | SecretStore protocol + DPAPI backend (§4.2). |
| Logging | 12 `os`/OSLog sites | Lift the Linux Logger shim into a shared file (§4.3). |
| On-device AI | `Intelligence.swift` (1,295, provider seam + heuristic fallbacks), `AFMRequestQueue.swift` | Windows Foundry provider (§9.2). |

### 1.4 The Control/container subsystem (what wslc replaces)

Authoritative docs: `docs/VSOCK_CONTROL_PLANE.md`, `docs/CONTAINER_ISOLATION.md`,
`docs/NAROS.md`, `docs/NASH.md`, `docs/RUNTIME_ARCHITECTURE.md`.

**Two orthogonal virtualization stacks — do not conflate:**
1. **The agent sandbox** (`Container/`): Apple `containerization` framework, headless Linux
   microVMs. → **This maps to wslc.**
2. **Computer-use VMs** (`MacVM/`): full `Virtualization.framework` GUI VMs. → **Excluded.**

Key mechanics that carry over unchanged (all guest-side / protocol-level, OS-agnostic):

- **Control plane protocol:** host `MCPApprovalServer` serves HTTP JSON-RPC at `POST /mcp`
  (Streamable-HTTP MCP: `initialize`, `ping`, `tools/list`, `tools/call` with SSE for gated
  calls), bearer-token multiplexed per session, plus plain JSON `POST /git-event`,
  `/gh-event`, `/command-event`, `/shell-event` (dispatch at
  `MCPApprovalServer.swift:1903-1916`). Agent-side MCP config
  (`mcpConfigJSON`, `:1611`) always points at `http://127.0.0.1:9099/mcp`.
- **Guest bridge:** `containers/nucleic-sandbox/control-bridge.js` listens on guest
  loopback `9099` (`ContainerSpec.controlBridgePort`) and forwards byte-for-byte to the
  host endpoint. On macOS that endpoint is a per-container host AF_UNIX socket relayed
  into the guest over virtio-vsock via Apple `UnixSocketConfiguration(.into)` — **the only
  macOS-specific piece of the entire control plane.** A second optional forwarder
  (`proxyBridgePort` 9098) serves the opt-in Claude token proxy
  (`Claude/ClaudeTokenProxy.swift`).
- **Interceptors:** Node git/gh shims installed at `/usr/local/bin/{git,gh}` (sources are
  Swift string literals: `ContainerEngine+Rootfs.swift:394` and `:466`), exec the real
  binary then POST `{argv, cwd, exitCode, subcommand, sessionId}` to
  `NUCLEIC_GIT_HOOK_URL`/`NUCLEIC_GH_HOOK_URL` with a bearer token. Optional bash command
  tracer + batch poster in `Container/CommandInterceptor.swift` (also home of
  `CommandInterceptor.hookEnv(...)`, the single source of interceptor endpoint env, and
  `agentShellArgv`/`hostShellArgv` for nash selection). The nash shell (`shell/`, Rust)
  reports `/shell-event`.
- **Agent run flow** (`Claude/ClaudeCodeBackend.swift` `runLoop`, `:766-1087`; Codex/Grok
  backends identical shape): resolve approval server via `ApprovalServerRegistry` →
  `containerManager.ensureRunning(cspec)` → mint per-session bearer token →
  `server.start(unixSocketPath: run.container.controlSocketHostPath)` → register handlers
  keyed by token (`register`, `registerGitReport`/`GhReport`/`CommandReport`/`ShellReport`,
  `registerNucleicTools`) → argv `claude -p --output-format stream-json
  --input-format stream-json --verbose --permission-prompt-tool mcp__nucleic__approve
  --mcp-config <mcpConfigJSON>` → `sandbox.exec(name:workdir:env:argv:uid:gid:)` returning
  a `ProcessHandle` (`Container/ContainerizedProcessHandle.swift` adapts framework stdio
  onto the same NDJSON `AsyncThrowingStream` interface a host `ChildProcess` provides).
- **ContainerSpec** (`Backend.swift:54`): `name`, `image`, `mounts: [Mount]`
  (host/container/readOnly), `workdir`, `env`, `idleTimeout`,
  `claudeHomeStaging`/`claudeHomeWritable`, `installGitInterceptor`, `cpus`, `memoryGiB`,
  `runAsUID`(=`narosAgentUID` 501)/`runAsGID`, `controlSocketHostPath`,
  `proxySocketHostPath`. Constants: `controlSocketGuestPath = "/run/nucleic/control.sock"`,
  `controlBridgePort = 9099`, `proxyBridgePort = 9098`. Built by
  `SessionController.containerSpec()` (`SessionController.swift:643`) from
  `Project.sandbox` (`ProjectSandbox`, `Project.swift:77`).
- **Images:** agent rootfs `ghcr.io/abkslm/naros-agent:26.07` (narOS, built from `os/` by
  `.github/workflows/naros.yml`; contract in `docs/NAROS.md`: `naros-init` PID 1,
  `agent` uid 501, nash as `/bin/sh`, `NAROS_BRIDGE=1` supervises control-bridge); legacy
  `ghcr.io/abkslm/nucleic-sandbox:v7` (`containers/nucleic-sandbox/Dockerfile`). The
  **kernel OCI artifact and patched vminitd initfs are microVM concepts — NOT needed on
  WSL** (WSL supplies kernel + init). GHCR auth: `ContainerEngine.registryAuth(for:)`
  pattern (`NUCLEIC_REGISTRY_TOKEN` or app GitHub token with `read:packages`).
- **Lifecycle policy** (`Container/ContainerManager.swift`, 687 LOC): busy ref-counting,
  idle timers, shared-container naming (`sharedControlContainerName`, per-backend
  claude/codex/xai splits, `channelSuffix`), teardown/GC, `reconcile(activeSessions:)`,
  `updateAgentCLIs`, `checkImageUpdate`, `controlContainerStatus/Usage/DownloadProgress`,
  `probeControlPlane`, `requestControlPlaneRecovery`. Its public surface is mirrored
  byte-for-byte by the `LinuxSupport.swift` stubs — **it IS the port contract.**

### 1.5 The WSL Container API (wslc) — facts

> **Measured, not read.** Everything below is verified against the shipped assembly and a live
> service on Windows 11 amd64 (WSL 2.9.4) — see §13.1 for how, and for the parts that are still
> open. **Do not trust Microsoft Learn over this section**: its C# sample uses `MemoryMB`,
> `CmdLine` and `DeleteContainerFlags`, none of which exist in the shipped package.

**Identity.** NuGet **`Microsoft.WSL.Containers`**, version **2.9.3** — the only one published,
and it tracks WSL's own version scheme. The managed assembly is
`lib/net8.0-windows10.0.19041.0/**wslcsdkcs.dll**` (namespace `Microsoft.WSL.Containers`), so the
package name is *not* the assembly name. It also ships a native `wslcsdk.dll` + `wslcsdk.h`, a
`.winmd` for C++/WinRT, and MSBuild/CMake targets. **Public preview; breaking changes possible;
GA fall 2026.** Requires **WSL ≥ 2.9.3, which is pre-release-only** (`wsl --update --pre-release`).
CLI twin: `wslc.exe` — *not on PATH by default*; it ships beside `wsl.exe`.

**Two COM surfaces, and the SDK wraps the smaller one** (§13.1, D13). `wslcsdkcs.dll` projects
`WSLCCompat.idl`, the backwards-compatible SDK surface. The service-internal `wslc.idl` — what
`wslc.exe` itself calls — has everything the compat surface lacks. Both are in the open-source WSL
repo. The object model *of the compat surface*:

- **`WslcService`** (statics): `GetVersion() → ServiceVersion {Major,Minor,Revision}`,
  `GetMissingComponents() → IReadOnlyList<Component>` (a **list**, not flags; `Component` =
  `VirtualMachinePlatform | WslPackage | SdkNeedsUpdate`), `InstallWithDependencies()`.
  `GetMissingComponents` answers from OS feature state and works even when the service class is
  unregistered, which makes it the call that explains every other failure.
- **`Session`** — `new Session(SessionSettings(name, storagePath))` then **`Start()`**. There is no
  `CreateOrOpen`. **The constructor is lazy**: it only captures settings, always succeeds, and is
  *not* evidence a session exists. `Start()` is where the service is consulted, and it refuses a
  name that is already running with `ERROR_ALREADY_EXISTS` (0x800700B7) — so the compat surface
  **cannot re-adopt a running session**. Settings: `CpuCount`, `MemorySizeInMB` (not `MemoryMB`),
  `Timeout`, `EnableGpu`, `VhdRequirements`. Images: `PullImage` / `PullImageAsync` (the async form
  carries `ImageProgress` through a `.Progress` handler), `GetImages` → `ImageInfo {Name, Sha256,
  Size, CreatedTimestamp}`, import/load/push/tag/delete. `CreateContainer(...)`, `Terminate()`,
  `event Terminated(SessionTerminationReason)`. **No container enumeration.**
  `Session.Authenticate(uri, user, password) → string` mints the identity token
  `PullImageOptions.RegistryAuth` takes — the only producer of it (§13.2).
- **`Container`** — created with `ContainerSettings(imageName)` + `Name`, `HostName` (capital N),
  `InitProcess`, and reachable **only** through the handle `CreateContainer` returned: the
  `Container` object itself exposes `Id`/`State`/`InitProcess`/`Inspect()` and **no `Name`**, so
  nothing can look one up (§13.2). Settings also carry
  `NetworkingMode` (`ContainerNetworkingMode` = **`None | Bridged`** — not NAT/mirrored),
  `Volumes` (`ContainerVolume(windowsPath, containerPath, readOnly)`), `NamedVolumes`,
  `PortMappings`, `EnableAutoRemove`, `EnableGpu`, `Privileged`, `DomainName`. Then `Start()`,
  `Stop(Signal, TimeSpan)`, `Delete(DeleteContainerOption)`, `State`, `Inspect() → string`,
  `CreateProcess(...)`. **No statistics call.**
- **`Process`** — `container.CreateProcess(settings)` **then** `process.Start()`: two steps, which
  is what lets handlers attach before the process runs (a single call would drop the first chunk).
  `ProcessSettings`: `CommandLine` (not `CmdLine`), `EnvironmentVariables` (not `Environment`),
  `WorkingDirectory`, `OutputMode = ProcessOutputMode.Event`. **No uid/gid and no pty**, so exec
  wraps argv in `setpriv`/`su` (§3.2 anticipated this) and the Terminal panel needs the internal
  interface. Events `OutputReceived` and `ErrorReceived` are **separate** (no stderr flag), plus
  `Exited`; stdin is `GetInputStream()`, not a write call; `Signal(Signal)` where `Signal` is a
  named enum limited to `None, SIGHUP, SIGINT, SIGQUIT, SIGKILL, SIGTERM`.
- Errors arrive as COM HRESULTs, frequently with an **empty message**. The `WSLC_E_*` range is
  documented in `wslc.idl` (0x8004060x). Codes seen in practice: `REGDB_E_CLASSNOTREG`
  (0x80040154, nothing installed), `ERROR_NOT_SUPPORTED` (0x80070032, installed but too old),
  `ERROR_ALREADY_EXISTS` (0x800700B7, session name in use), `E_ILLEGAL_METHOD_CALL` (0x8000000E,
  session not started).
- OCI images pulled directly from registries (docker.io, GHCR); multi-arch manifests resolve per
  host arch. GPU access supported. Interactive stdin/stdout streaming supported.
- **Service state is service-backed** — containers created earlier are addressable by name from a
  process that never held their handle, which is the premise §2.3's broker supervision rests on.

---

## 2. Architecture

### 2.1 Process topology

```
┌─────────────────────────── MSIX package (per channel) ───────────────────────────┐
│                                                                                  │
│  Nucleic.exe (WinUI 3, C#)              nucleic-hostd.exe (Swift, full trust)    │
│  ┌───────────────────────┐   Noise/CBOR ┌──────────────────────────────────┐     │
│  │ RendererStore (C#)    │◄────────────►│ AppStore (headless) + SyncHost   │     │
│  │  ▲ P/Invoke callbacks │  loopback    │ ApprovalServerRegistry (NIO)     │     │
│  │ NucleicProtocolC.dll  │  TCP frames  │ GitRunner / WorktreeManager      │     │
│  │ (Swift, C ABI)        │              │ GRDB @ %APPDATA%\Nucleic\<chan>  │     │
│  └───────────────────────┘              │ ContainerManager (policy, Swift) │     │
│                                         │   └─ WslcBrokerClient (JSON-RPC) │     │
│                                         └───────────────┬──────────────────┘     │
│                                          NDJSON JSON-RPC │ stdio                 │
│                                         ┌────────────────▼─────────────────┐     │
│                                         │ nucleic-brokerd.exe (C#/.NET 9)  │     │
│                                         │ Microsoft.WSL.Containers (WinRT) │     │
│                                         └────────────────┬─────────────────┘     │
└──────────────────────────────────────────────────────────┼───────────────────────┘
                                                WinRT/COM  │
                                    ┌──────────────────────▼──────────────────────┐
                                    │ wslc Session "nucleic-<channel>" (WSL VM)   │
                                    │  ┌────────────────────────────────────────┐ │
                                    │  │ naros-agent containers (shared/split)  │ │
                                    │  │  control-bridge.js :9099 ──TCP──► host │ │
                                    │  │  git/gh shims, nash, claude/codex/grok │ │
                                    │  │  /work ◄─ContainerVolume─ NTFS worktree│ │
                                    │  └────────────────────────────────────────┘ │
                                    └─────────────────────────────────────────────┘

iPhone (NucleicRemote) / other renderers ──► LAN (DNS-SD + QR) / relay / tailnet ──► SyncHost
Windows app in remote-cockpit mode (D12) ──► same transports ──► any Nucleic host
```

### 2.2 Division of labor

| Concern | Owner | Why |
|---|---|---|
| Sessions, transcripts, approvals, risk classification, Autoship + merge queue, LockManager, nvrsion, quotas, idea inbox, activity/streaks | Swift hostd (existing `NucleicCore`, logic unchanged) | Maximum Swift reuse (D1). |
| Container lifecycle **policy** (refcounts, idle, shared naming, reconcile, CLI updates) | Swift hostd (`ContainerManager`, retargeted onto `SandboxEngine`) | Policy stays single-sourced across macOS/Windows. |
| Container **mechanism** (wslc Session, image pull, create/exec/stdio/signals) | C# `nucleic-brokerd` | wslc API is WinRT; Microsoft recommends C# (D3). |
| Wire protocol (CBOR, Noise, envelope, transcript projection) | Swift `NucleicProtocolC.dll` | Single-sourced wire format (D7). |
| UI, toasts, taskbar flash, theming, Phi Silica calls | C# `Nucleic.exe` | WinUI 3 mandate. |

### 2.3 Broker process model

**Separate exe, child of hostd, NDJSON JSON-RPC 2.0 over stdio.** Rationale:
- Containers must outlive the UI window; broker lifetime must match hostd, not Nucleic.exe.
- hostd already has everything needed to run it: `ChildProcess`
  (`Sources/NucleicCore/ProcessHost.swift`) for spawn + NDJSON line streams, and
  `Sources/NucleicCore/JSONRPCConnection.swift` for framing. Zero new IPC surface — no
  named-pipe ACLs, no COM registration.
- Crash containment: wslc is preview software. hostd supervises brokerd with
  exponential-backoff restart. On reattach, brokerd re-opens the named wslc `Session` and
  re-enumerates containers (wslc state is service-backed); hostd then runs
  `ContainerManager.reconcile(activeSessions:)` against the fresh listing. In-flight execs
  fail with a distinguishable `brokerLost` error, mapped to the same recovery path
  `ContainerizedProcessHandle.forceCloseStreams()` serves today.
- Broker sends a `capabilities` hello (wslc/NuGet version, supported features) so hostd can
  degrade gracefully across preview→GA churn.

### 2.4 Lifetime & single-instance

- `Nucleic.exe` launches hostd at startup if not running. Single-instance guard: named
  mutex `Local\nucleic-hostd-<channel>` (hostd also self-guards).
- hostd writes a rendezvous file `%LOCALAPPDATA%\Nucleic\<channel>\hostd.json` =
  `{pid, port, localPSK}` with an **owner-only DACL**. The renderer (and `nucleic-smoke`)
  reads it to connect. Stale detection: pid liveness + connect probe.
- hostd outlives the renderer window while any session is active or the Autoship queue is
  non-empty; the renderer exposes explicit "Quit host". Clean shutdown via
  `SetConsoleCtrlHandler` (CTRL_CLOSE/LOGOFF/SHUTDOWN) routed into the same shutdown path
  nucleicd drives from SIGTERM (`Nucleicd.swift:255-263` gets a Windows leg).
- MSIX declares `runFullTrust`; hostd + brokerd + DLL + Swift runtime + MinGit ship in the
  package payload.

---

## 3. Container subsystem port

### 3.1 The `SandboxEngine` seam (Darwin-first refactor)

New file `Sources/NucleicCore/Container/SandboxEngine.swift` — a platform-neutral protocol
capturing exactly the mechanism surface `ContainerManager` and the backends use today
(extracted from `ContainerEngine.swift:513, :660, :697, :727-:924`):

```swift
public protocol SandboxEngine: Sendable {
    /// Idempotent create-or-start; returns the container name and the address at which
    /// the guest can reach the host (macOS: vmnet gateway; Windows: WSL vEthernet gateway).
    func ensureRunning(_ spec: ContainerSpec) async throws -> (name: String, hostGateway: String)
    func exec(name: String, workdir: String, env: [String: String],
              argv: [String], uid: UInt32, gid: UInt32) async throws -> any ProcessHandle
    func runCapturing(name: String, workdir: String, env: [String: String],
                      argv: [String]) async -> (exitCode: Int32, output: String)?
    func stop(name: String) async
    func restart(name: String) async -> Bool
    func remove(name: String) async -> Bool
    func isRunning(name: String) async -> Bool
    func containerExists(name: String) async -> Bool
    func list() async -> [ControlContainerEntry]
    func sampleResourceUsage(name: String) async -> ContainerResourceSample?
    func probeControlPlane(name: String) async -> ControlPlaneProbe
    func diagnoseKill(name: String) async -> String?
    func reclaimMemoryNow() async
    func pullImage(_ ref: String) async throws     // progress via existing download-progress plumbing
    func checkImageUpdate(_ ref: String) async -> Bool
    // NOTE: match the real ContainerEngine signatures exactly when implementing — the
    // list above is the shape; lift precise signatures from ContainerEngine.swift.
}
```

Steps (this lands **Darwin-first** and is verified by the existing macOS test suite before
any Windows code exists):
1. Add the protocol; conform the Apple `ContainerEngine` (mechanical).
2. Retype `ContainerManager`'s engine reference (and the few direct engine references in
   `ClaudeCodeBackend` / `CodexAppServerBackend` / `CodexExecBackend` / `GrokACPBackend`)
   to `any SandboxEngine`.
3. Keep `LinuxSupport.swift` stubs compiling (Linux/nucleicd behavior unchanged — the
   stub `ContainerManager` remains the Linux story).
4. On Windows, `ContainerManager.swift` moves **off** the exclude list and compiles against
   the protocol. Engine files (`ContainerEngine*.swift`, `ContainerizedProcessHandle.swift`,
   `MemoryBalloon.swift`) stay excluded. Audit `OCIArtifact.swift`: pure ref-parsing/version
   helpers move to a shared file if `WslcContainerEngine` wants them.

### 3.2 `WslcContainerEngine` mapping

New files in `Sources/NucleicCore/Container/Windows/` (compiled only on Windows):

- **`WslcBrokerClient.swift`** — spawns `nucleic-brokerd.exe` via `ChildProcess`, speaks
  NDJSON JSON-RPC over its stdio, multiplexes requests, supervises (backoff restart,
  capabilities hello, reattach → notify `ContainerManager.reconcile`).
- **`WslcContainerEngine.swift`** — `SandboxEngine` conformance over the broker RPC.
- **`WslcProcessHandle.swift`** — mirrors `ContainerizedProcessHandle.swift`: adapts
  `proc.stdout/stderr/exit` notifications onto the `ProcessHandle` protocol
  (`ProcessHost.swift:44-70`): `stdoutLines`/`stderrLines` as
  `AsyncThrowingStream<Data, Error>` through the shared `LineSplitter`, `writeLine`
  framing NDJSON to stdin, `wait()` → exit code, `sendSignal` → broker `proc.signal`,
  `forceCloseStreams()` on broker loss, `lastActivityNanos` bumping,
  `supportsHostCPUSampling = false`.

Concept mapping:

| Nucleic concept (macOS) | wslc equivalent |
|---|---|
| One microVM per container (Apple containerization) | **One wslc `Session` per channel** (`new SessionSettings("nucleic-<channel>", storagePath: %LOCALAPPDATA%\Nucleic\<channel>\wslc)`, `CpuCount`/`MemorySizeInMB` from settings, then `Start()`) hosting **all** Nucleic containers as siblings. |
| `ContainerSpec.name/image` | `ContainerSettings(image)` + `.Name` (keep existing naming statics incl. `channelSuffix` so dev/canary/stable coexist). |
| `spec.mounts` (host worktree → guest path) | `ContainerVolume { hostPath: NTFS path, guestPath: mount.container, readOnly }`. |
| `spec.cpus/memoryGiB` | Session-level `CpuCount`/`MemorySizeInMB` (wslc resources are per-session). Per-container limits: cgroup exec inside the container if needed later. |
| Memory balloon (`MemoryBalloon.swift`) | No per-container balloon. `reclaimMemoryNow()` → WSL autoreclaim / drop-caches exec; `SessionSettings.MemorySizeInMB` resize applies **after sandbox restart** — surface in Settings exactly like the existing restart-shared flow. |
| `sampleResourceUsage` → `ContainerResourceSample` | No `GetStatistics()` on any wslc surface the broker can reach, so `container.stats` **execs a cgroup v2 read in the guest** (`cat /sys/fs/cgroup/{cpu.stat,memory.current,memory.max,memory.events}`) and parses it broker-side — `cat`/`echo` only, since the image is the user's. `memory.max` reads the literal `max` when unlimited, which must become "unknown" and not 0, or a usage percentage reads as "no memory allowed". |
| `runAsUID: 501` | **Settled by M1 (a): `ProcessSettings` has no uid/gid**, so exec wraps argv in `setpriv --reuid=<uid> --regid=<gid> --init-groups --` (implemented) — the fallback §3.2 always named, with `su agent -c` as the alternative if an image lacks util-linux. Interceptors and nash don't care about the numeric uid. |
| Rootfs pull + ext4 clone (`+Rootfs.swift`) | `session.PullImageAsync(new PullImageOptions(ref))`, progress via the returned operation's `.Progress` handler (`ImageProgress`) → `controlDownloadProgress` plumbing; `RegistryAuth` is a plain **string**, minted by `session.Authenticate(registryUri, user, token)` (§13.2). **No kernel/vminitd artifacts.** |
| Shim seeding (`seed(_:in:)`, base64 install) | Prefer baking into the multi-arch narOS image (already the case); keep idempotent re-seed via `runCapturing` for legacy-image support. |
| `ensureRunning` returns `hostGateway` | **No API surfaces one** (§13.1), so the broker derives it from `GetAdaptersAddresses` over `vEthernet (WSL)` — implemented in `WslcFacade` via `NetworkInterface`, the managed wrapper over that call, polled because the vNIC appears as the VM boots. The hvsocket alternative is **not** available: `IWSLCVirtualMachine` is unreachable from a client, so there is no VMID (§13.2). **M1 (b) confirmed it reachable from a container under the default firewall (§13.3).** |

### 3.3 Broker RPC surface (`windows/NucleicBroker`)

NDJSON JSON-RPC 2.0 over stdio; binary payloads base64. Versioned `hello` first.

Requests (hostd → broker):
- `hello() → {brokerVersion, wslcVersion, capabilities: [..]}`
- `components.missing() → {flags}` / `components.install()` (+ progress notifications;
  wraps `WslcService.GetMissingComponents` / install — feeds onboarding, §8)
- `session.ensure({name, dataDir, cpu, memoryMB}) → {gateway}` / `session.terminate()`
- `image.pull({ref, auth})` (+ `image.pullProgress {ref, status, current, total}`),
  `image.list` / `image.delete` / `image.inspect`
- `container.create({name, image, volumes:[{host,guest,ro}], networkingMode, hostname, env, initArgv})`
- `container.start` / `container.stop({name, signal, graceMs})` / `container.delete` /
  `container.list` / `container.state` / `container.stats({name})`
- `proc.exec({container, argv, env, cwd, uid, gid, tty}) → {procId}`
- `proc.stdin({procId, b64})` / `proc.closeStdin({procId})` / `proc.signal({procId, sig})`
- `proc.resize({procId, cols, rows})` (tty mode, for the Terminal panel)
- `ai.generate({prompt, schema})` (§9.2 — Phi Silica sidecar duty)

Notifications (broker → hostd):
- `proc.stdout {procId, b64}` / `proc.stderr {procId, b64}` / `proc.exit {procId, code}`
- `session.down {reason}` (from `SessionTerminationHandler`)
- `image.pullProgress`, `components.installProgress`

Broker implementation notes: .NET 9, single-file publish per arch; all wslc calls behind an
internal `IWslc` interface so unit tests run against a fake; version-pin the NuGet; map
`ProcessOutputMode.Event` → `proc.stdout` notifications (`OutputReceived` and `ErrorReceived` are
two separate events, so subscribe twice); batch/flush stdout notifications (agents emit high-rate
NDJSON — coalesce writes, never block the WinRT event thread).

**Which surface serves which RPC (D13, §13.1).** Four of the calls above have no compat-SDK
implementation and must come from the internal COM interface:

| RPC | Served by |
| --- | --- |
| `container.list`, and the reattach roster behind `container.state` | `IWSLCSession::ListContainers` / `OpenContainer`. **Today: the broker's own name→handle roster**, because `Container` has no `Name` and `Session` cannot enumerate (§13.2) — correct within one broker lifetime, empty after a restart. |
| `container.stats` | ~~`IWSLCContainer::Stats` (JSON)~~ — **now served by the cgroup read via exec** (§13.2), so this row no longer needs the internal arm at all |
| `proc.resize` (and `proc.exec` with `tty: true`) | `IWSLCProcess::ResizeTty`; the compat surface has no pty at all. **Today: `unsupported`**, with `tty` absent from the `hello` capabilities. |
| session reattach on broker restart | `IWSLCSessionManager::OpenSessionByName` / `EnterSession` — confirmed necessary on hardware, since the compat `Session` constructor is lazy and its `Start()` refuses an existing name. **Today: `session.ensure` fails `session_exists`**, naming `wsl --shutdown` as the manual remedy. |

**Entry point confirmed on hardware:** `CoCreateInstance(WSLCCompatSessionManager,
a9b7a1b9-0671-405c-95f1-e0612cb4ce8f)` then `QueryInterface(IWSLCSessionManager,
82A7ABC8-6B50-43FC-AB96-15FBBE7E8760)`. `wslc.idl` names no coclass, so this pairing is the thing
to remember. See §13.2.

Everything else — `session.ensure`/`terminate`, `image.*`, `container.create`/`start`/`stop`/
`delete`, `proc.exec`/`stdin`/`signal` and the event stdio — rides the compat SDK.

---

## 4. NucleicCore on Windows: shims & seams

### 4.1 `Package.swift` changes

- Add `onWindows` (mirror the `onLinux` detection at `Package.swift:62-64`);
  introduce `nonDarwin = onLinux || onWindows` and use it wherever `onLinux` currently
  gates `darwinOnlyTargets`, Sparkle, and product lists (`:70`, `:219`, `:266`, `:402`).
- Widen `NucleicProtocol`'s swift-nio / swift-crypto dependency conditions from `.linux`
  to `[.linux, .windows]`.
- NucleicCore `exclude:` for Windows = the Linux list (`:352-381`) **minus**
  `Container/ContainerManager.swift` (now protocol-typed, compiles on Windows), keeping
  excluded: `Container/ContainerEngine.swift`, `Container/ContainerEngine+Rootfs.swift`,
  `Container/ContainerizedProcessHandle.swift`, `Container/MemoryBalloon.swift`,
  all `MacVM/*Engine*`/`MacVMManager`/`MacVMExecChannel`/`MacVMAgentClient`, and the
  Network.framework Sync files. `Container/CommandInterceptor.swift`, `MacVMSpec.swift`,
  `MacVMSurface.swift` stay in (pure values).
- Add `Sources/NucleicCore/Windows/**` and `Sources/NucleicCore/Container/Windows/**`
  (excluded on non-Windows), the `NucleicProtocolC` dynamic-library target/product, and
  the `nucleic-hostd` executable target.
- `nucleicd` is excluded from the Windows manifest (`nonWindowsTargets` /
  `nonWindowsProducts`, item 9). It is the Covalence *runner* host; Windows has `nucleic-hostd`,
  and `ControlEndpoint.swift` is a BSD-socket listener whose libc ladder has no Windows arm,
  with a `DispatchSource` signal handler in `Nucleicd.swift`. Since `swift test` builds every
  declared target, leaving it in would mean porting a daemon Windows will never run.
- SwiftPM remains the build system, including the DLL (`.library(type: .dynamic)`).
  If `@_cdecl` symbol auto-export proves unreliable, pass a checked-in
  `Sources/NucleicProtocolC/exports.def` via `-Xlinker /DEF:`. CMake is the documented
  fallback, not the plan.

### 4.2 SecretStore (Keychain replacement)

Introduce `protocol SecretStore` (get/set/delete/list by service+account, `Data` values)
in NucleicCore; macOS backend wraps the existing Keychain calls; Windows backend
`Sources/NucleicCore/Windows/CredentialStore.swift` uses **DPAPI**
(`CryptProtectData`/`CryptUnprotectData`, user scope, `CRYPTPROTECT_UI_FORBIDDEN`)
encrypting per-item files under `NucleicPaths.secretsDirectory` with owner-only DACLs. DPAPI
chosen over Windows Credential Manager because of Credential Manager's ~2.5 KB blob limit
vs. Nucleic's larger OAuth token sets.

Path note (item 9): the secrets dir is `%APPDATA%\Nucleic<channel>\Secrets`, not the
`%LOCALAPPDATA%\Nucleic\secrets` this section originally sketched — it hangs off the single
`NucleicPaths.dataRoot` with everything else the host owns, so one `NUCLEIC_DATA_DIR` moves
the store, the secrets, the run dir and the logs together. The interim file backend
(`LinuxSecretStore`, shared with Linux per item 3) already writes there with an owner-only
DACL via `SecretFile`; the DPAPI layer adds encryption at rest on top of the same path.

Retarget the ~24 `Security` call sites (~14 files) onto the protocol; macOS behavior
unchanged. Files: `KeychainOwnedAccess.swift`, `Claude/ClaudeLoginKeychain.swift`,
`Claude/ClaudeCredentialBroker.swift`, `Codex/CodexAuthFile.swift`,
`Codex/CodexCredentialBroker.swift`, `Carbon/CarbonKeyCustody.swift`,
`Carbon/CarbonCrypto.swift`, `Git/GitHubCredentials.swift`, `HeartbeatReporter.swift`,
`ControlAuth.swift`, `SubscriptionUsage.swift`, `Sync/HostIdentityStore.swift`,
`Sync/CredentialProvider.swift`, `Sync/RunnerCredentialVault.swift`,
`Sync/PushRelayClient.swift`, `Sync/RelayAccess.swift`, `Sync/RunnerPoolClient.swift`
(grep `canImport(Security)` for the authoritative list; `MacVM/MacVMEngine+MDM.swift` is
excluded anyway). Land Darwin-first like the SandboxEngine seam.

### 4.3 Windows platform shims (`Sources/NucleicCore/Windows/`)

| File | Replaces | Implementation |
|---|---|---|
| `WindowsSupport.swift` | `LinuxSupport.swift` role | Stub `MacVMManager` (and anything else the Linux file stubs that Windows also lacks). Lift the `os.Logger` shim out of `LinuxSupport.swift:13-54` into a shared `PortableLogging.swift` compiled on both Linux and Windows (swift-log backend; rolling files at `%LOCALAPPDATA%\Nucleic\logs`, ETW optional later). |
| `NIOByteConn.swift` | `NWByteConn` (Network.framework) in `MCPApprovalServer` | SwiftNIO TCP listener behind the existing `ByteConn` abstraction; `start(host:port:)` keeps its signature; AF_UNIX leg goes `#if !os(Windows)`. Also usable on Linux later. |
| `NIOLANTransport.swift` / `NIOLANListener.swift` | `Sync/LANTransport.swift`, `LANBrowser.swift`, `PeerDialer.swift`, `MeshPathMonitor.swift` (Network.framework files, already Linux-excluded) | `FrameChannel` + listener over NIO; path monitoring degraded to periodic reachability probes (no NWPathMonitor equivalent — acceptable). |
| `WindowsDNSSD.swift` | Bonjour advertise/browse | dnsapi.dll `DnsServiceRegister`/`DnsServiceBrowse`/`DnsServiceResolve` (WinSDK module) for `_nucleic._tcp` with the pairing TXT payload. Fallback documented: in-process NIO mDNS responder if dnsapi TXT limits bite. |
| `WindowsSignals.swift` **[landed]** | `pid_t`, `SIGKILL`/`SIGTERM`/`SIGINT` (ucrt has no SIGKILL and no `kill`) | Module-internal stand-in declarations, so ~30 shared call sites keep their POSIX spelling. In a file that does NOT import WinSDK, so it can't collide with ucrt's own SIGINT/SIGTERM. |
| `ProcessTree.swift` **[landed]** | POSIX signal semantics in `ChildProcess.sendSignal`; the `hostProcessTable`/CPU/IO samplers | Assign every host child to a **Job Object** at spawn (`CreateJobObjectW` + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, attached right after `Process.run()` — corelibs exposes no creation flags). SIGKILL → `TerminateJobObject`/`TerminateProcess` **with exit code 137**, so `Backend.isSIGKILL` reads a killed host command the same on every platform; SIGTERM/SIGINT → `GenerateConsoleCtrlEvent`, then job-terminate after grace. Process table via Toolhelp, CPU via `GetProcessTimes`, I/O via `GetProcessIoCounters`. (Container processes get real POSIX signals via wslc — no shim needed there.) |
| `FilePermissions.swift` **[landed]** | `chmod 0600` / `posixPermissions` hardening (~12 sites: `Carbon/CarbonKeyCustody.swift`, `Claude/ClaudeLoginKeychain.swift`, `SessionController.swift`, `Codex/{CodexCredentialBroker,CodexAuthFile}.swift`, `Sync/{CredentialProvider,RunnerCredentialVault}.swift`, `Git/GitHubCredentials.swift`, `LinuxSupport.swift`'s file secret store) | `SetEntriesInAclW` + `SetNamedSecurityInfoW` protected single-ACE DACL, behind the shared `SecretFile` (`createDirectory` / `write` / `restrictToOwner` / `createEmptyFile`) that both platforms call. The unix-socket `chmod`s in `ClaudeTokenProxy`/`MCPApprovalServer` are NOT retargeted — those paths don't exist on Windows. |
| ~~`FileWatcher.swift`~~ **[not needed]** | — | The `DispatchSource` uses attributed here to file watching are socket **accept** sources (`MCPApprovalServer`, `ClaudeTokenProxy`), and both already sit behind platform legs — NIO on Windows, Darwin-only respectively. Nothing in NucleicCore watches a file with `DispatchSource`. |
| `PowerBlocker.swift` **[landed]** | `NucleicPowerHelper` + `SleepBlocker` | `SetThreadExecutionState(ES_CONTINUOUS \| ES_SYSTEM_REQUIRED)` held while a turn is in flight; released on idle and at shutdown. Driven from a 5-second main-actor tick in hostd over `store.localTurnInFlightCount` (headless has no `onChange`); one thread throughout, because the state dies with its thread. Default-**on** (see the item-9 status note), same `nucleic.power.blockSleep` key as the Mac. |
| `WindowsEnvironment.swift` **[landed]** — the `LoginShellEnv` / `LoginShellPATH` Windows legs | POSIX login-shell probing; `setenv`/`unsetenv` | No shell probe: a Windows process is born with the merged machine+user environment, so `LoginShellEnv.resolve` returns nil. `LoginShellPATH` instead reads `HKLM\…\Session Manager\Environment` + `HKCU\Environment` (an installer's PATH edit is invisible to a running host otherwise) and splits/joins on `;` — splitting a Windows PATH on `:` shreds every `C:\…` entry. Also supplies `setenv`/`unsetenv` over `SetEnvironmentVariableW`. |
| `WindowsFileIO.swift` **[landed]** | `write temp → fsync → rename(2)` (`CarbonShardStore`) | `MoveFileExW(REPLACE_EXISTING \| WRITE_THROUGH)`. The CRT's `rename` fails when the destination exists, which for a content-addressed store would turn every idempotent re-write into an error; `WRITE_THROUGH` buys back what `fsync` did. |
| `WindowsSupport.swift` **[landed]** | `/usr/bin/env` PATH resolution in `ChildProcess` | `windowsResolveExecutable` — PATH × PATHEXT, against the same widened PATH the child will get. `Process` resolves `executableURL` against the cwd, so a bare `git` would otherwise spawn `<cwd>\git`. |

### 4.4 Storage & paths

- corelibs-foundation maps `.applicationSupportDirectory` → `%APPDATA%`. Data root:
  `%APPDATA%\Nucleic\<channel>\` (channel-suffixed so all five channels coexist —
  mirrors the container `channelSuffix` scheme). GRDB WAL `DatabasePool`
  (`Persistence/GRDBMetadataStore.swift:13` and the other Application-Support call sites:
  `KeychainOwnedAccess.swift:177`, `TailnetTransport.swift`,
  `Claude/ApprovalServerRegistry.swift`, `Cast/MeshDebugLog.swift`) get a single
  `NucleicPaths` helper rather than N inline lookups. **[landed, item 9]** —
  `NucleicPaths.dataRoot` / `.runDirectory` / `.logsDirectory` / `.secretsDirectory`, with
  `NUCLEIC_DATA_DIR` overriding all of them. macOS/Linux resolve to exactly the previous
  literal (`<Application Support>/Nucleic`), so no installed path moves; only Windows takes the
  channel-suffixed `%APPDATA%` branch. `nucleicd`'s runner root (`NUCLEIC_RUNNER_DATA_DIR` →
  `NucleicRunner`) is deliberately NOT folded in — it is a different root by design.
- Control repos: `%USERPROFILE%\.nucleic\control\<project>\` with worktrees at
  `<repo>\.nucleic\worktrees\<slug>` (unchanged layout).
- Approval-server socket paths (`/tmp/nucleic/<name>.sock`) become `(gatewayIP, port)`
  tuples behind the existing `ApprovalServerRegistry` API on Windows (§5).
- Git: bundle **MinGit** (x64/arm64) in the MSIX; configure managed clones with
  `core.longpaths=true` and `core.autocrlf=false` (agents run in Linux containers — the
  repo must stay LF; `.gitattributes`-driven projects behave as configured).
  `GitRunner`/`WorktreeManager` code unchanged.

---

## 5. Control plane on Windows

Everything above the transport is reused: `MCPApprovalServer` routes + SSE + bearer-token
multiplexing, `ApprovalServerRegistry`, interceptor shims, `control-bridge.js`,
`CommandInterceptor.hookEnv`, `mcpConfigJSON` — all unchanged. Only the host↔guest byte
path is replaced.

**Primary: TCP to the WSL vEthernet gateway (smallest change that works).**
1. `MCPApprovalServer.start(host:port:)` gains the `NIOByteConn` transport (§4.3), bound
   **only** to the WSL-facing interface address (never 0.0.0.0).
2. `WslcContainerEngine.ensureRunning` returns the gateway address the broker reports —
   this is exactly the `hostGateway` value the macOS engine already returns, so the
   contract doesn't change.
3. `control-bridge.js` gets a small additive branch: when `NUCLEIC_CONTROL_HOST` /
   `NUCLEIC_CONTROL_PORT` env are present, forward guest-loopback `9099` to that TCP
   endpoint instead of the relayed UDS (keep the UDS branch for macOS). Same for the
   proxy bridge (`9098` → `NUCLEIC_PROXY_HOST/PORT`).
4. Networking-mode handling: NAT (default) → gateway routing as above. Mirrored mode
   (Win11 22H2+) → broker detects and reports the reachable host address instead.
   Auto-detect; prefer NAT for determinism.
5. Firewall: MSIX can't install rules; rely on Windows 11's Hyper-V firewall defaults
   (guest→host on the WSL vSwitch) and ship an onboarding **connectivity self-test**
   surfaced through the existing `probeControlPlane` seam, with an elevated one-shot
   `New-NetFirewallRule` remediation script if the default-deny case is hit.

> **Superseded in part by D13 (§13.1).** `IWSLCVirtualMachine::GetId` supplies the VM GUID an
> `AF_HYPERV` bind needs — the missing piece that made hvsocket "upside" below. The ordering
> should invert at M1 (b): hvsocket primary, gateway TCP fallback. Note also that
> `ContainerNetworkingMode` is `None | Bridged`, not the NAT/mirrored pair item 4 assumes, and no
> API surfaces a gateway address (it comes from `GetAdaptersAddresses` over `vEthernet (WSL)`).

**Spike + fallback: Hyper-V sockets (true vsock parity).** Host listens on `AF_HYPERV`
bound to the WSL VM's VMID + a service GUID derived from the logical port; guest dials
`AF_VSOCK` CID 2. No firewall exposure, no routing variance. Unknown: whether wslc
containers pass vsock through their namespaces (likely — vsock isn't network-namespaced).
M1 answers this; promote to primary if gateway-TCP shows friction in the wild
(enterprise firewall policy, DNS tunneling interactions).

**Rejected:** named-pipe-via-WSL-interop (pipes aren't visible inside wslc containers
without brittle 9P mounts).

Security note: the bearer token — not the transport — is the auth boundary today (the
vsock relay was defense-in-depth). Interface-scoped binding restores equivalent posture;
tokens remain per-session, minted in `runLoop`.

---

## 6. `NucleicProtocolC.dll` (renderer protocol client)

New target `Sources/NucleicProtocolC/`, depending on `NucleicProtocol` +
`NucleicRemoteProjection` (the SPM target that already compiles
`ios/.../Views/Transcript/{TranscriptProjection,IncrementalTranscriptProjection}.swift`
for host testing — reuse it, don't duplicate).

Flat C ABI via `@_cdecl` (UTF-8 JSON strings in/out; caller frees with `np_free`;
header at `Sources/NucleicProtocolC/include/nucleic_protocol.h`):

```c
typedef void (*np_event_cb)(void* ctx, const char* host_msg_json);
typedef void (*np_state_cb)(void* ctx, const char* state_json); // connect/disconnect/transport/errors

np_handle*  np_client_create(const char* config_json);   // identity dir, host key pins
void        np_client_set_callbacks(np_handle*, np_event_cb, np_state_cb, void* ctx);
int         np_client_connect_local(np_handle*, const char* rendezvous_json); // {host,port,psk}
int         np_client_connect_remote(np_handle*, const char* endpoint_json);  // D12: LAN/relay/tailnet
int         np_client_pair(np_handle*, const char* pairing_code);
int         np_client_send_intent(np_handle*, const char* client_msg_json);
void        np_client_close(np_handle*);
void        np_free(const char*);

np_proj*    np_projection_create(void);
const char* np_projection_apply(np_proj*, const char* host_msg_json); // incremental render-diff JSON
void        np_projection_free(np_proj*);
```

Internals: a NIO loopback/TCP `FrameChannel` implementation feeding the **unchanged**
`SyncClient` (`NucleicProtocol/Sync/SyncClient.swift` is generic over `FrameChannel` —
no changes). Noise runs even locally (one code path for local + remote peers; the
rendezvous PSK auto-approves local pairing, §2.4). Callbacks fire from one dedicated DLL
thread; C# marshals to its dispatcher.

The integer return is synchronous admission, not connect success: `0` means the request was
validated and queued; `-1` invalid argument, `-2` invalid handle state, `-3` malformed JSON,
and `-4` unsupported transport. State JSON then reports `connecting`, `transport`, `ready`,
`failed`, and `closed`. A `ready` document carries the decoded `Welcome`, learned host key, and
canonical host id. Event JSON is always one canonical `HostMsg`.

Projection output is a versioned splice contract:

```json
{"version":1,"sessionID":"…","splices":[
  {"start":12,"deleteCount":2,"items":[{"id":"…","seq":"42","type":"message", "...":"…"}]}
]}
```

The renderer applies splices in order to its `ItemsRepeater` source. Empty `splices` is an
idempotent/no-projection-change result. `seq` is deliberately a decimal string: it is a wire
`UInt64`, and routing it through a JSON floating-point representation would silently lose high
bits.

**The one real work item:** JSON encode/decode for `ClientMsg`/`HostMsg` at the ABI
boundary (they are CBOR-coded on the wire). *Landed as `NucleicProtocol/Sync/ProtocolJSON.swift`
rather than in the DLL — see the item-10 note in the execution status for why, and for the list
of coder decisions it pins.* Lock it with **golden fixtures**: `fixtures/protocol-abi/` holds paired CBOR↔JSON vectors for every
message kind. Swift's `ProtocolABIFixtureTests` validates them on every wire-layer platform;
`NucleicProtocolCTests` validates the actual C veneer on Linux. The C# half (xUnit in
`windows/NucleicApp.Tests`) joins in item 11, so both language views then gate every change.
The envelope already carries protocol versioning — bump fixtures with it.

---

## 7. WinUI 3 renderer (`windows/NucleicApp`)

Stack: .NET 9, Windows App SDK 1.7+, WinUI 3, CommunityToolkit.Mvvm +
CommunityToolkit.WinUI (SettingsControls; Markdown via Labs MarkdownTextBlock with
RichTextBlock fallback), WebView2 (Monaco) for diff/editor, Windows Terminal control
(`Microsoft.Terminal.Control`) for the terminal panel.

Layering mirrors `ios/NucleicRemote` exactly — study `Models/RemoteStore.swift` (a pure
projection of host state; the wire types ARE the models) and `Models/HostConnection.swift`
(owns the SyncClient, handles pairing modes and transport failover) before writing C#:

- `Interop/` — P/Invoke bindings for `nucleic_protocol.h`; `HostMsg`/`ClientMsg` DTOs via
  System.Text.Json **source generators** (AOT-friendly, fast).
- `Services/HostConnection.cs` — DLL handle lifecycle, reconnect loop, rendezvous-file
  watch; **multi-host** (D12): one instance per paired host, local host auto-added.
- `Services/RendererStore.cs` — the `RemoteStore` analog: `ObservableObject` projection
  (sessions, projects, approvals, dashboard, quotas, activity, todos, mesh state), fed
  exclusively by HostMsg events; **all mutations are intents** (`np_client_send_intent`).
  No canonical state invented in the renderer, ever.
- `Services/HostdLauncher.cs` — mutex check, spawn hostd, health probe.
- `Services/OnboardingService.cs` — wslc component flow (renderer → hostd intent → broker
  `components.*`), progress UI, connectivity self-test display.

Screens (parity targets from `Sources/NucleicApp` and `ios/.../Views`):

| Screen | macOS source (reference) | Notes |
|---|---|---|
| Shell / navigation | `RootView.swift` (1,681) | `NavigationView` split layout; host picker at top (D12). |
| Home | `HomeView.swift`, activity grid, streaks, idea inbox (`TodoView.swift`) | Pure projections — data comes from host. |
| Project view / Control panel | `ProjectView.swift`, `ControlPanelView.swift` | Container status/usage cards use `controlContainerStatus/Usage` wire data. |
| Session detail + transcript | `SessionDetailView.swift` (2,407), `TranscriptRow.swift` (2,343), `HostExecCard.swift` | `ItemsRepeater` with incremental virtualization driven by `np_projection_apply` diffs; tool rows color-coded by risk; collapsible thinking. |
| Composer | `NewChatComposer.swift`, `ChatInputField.swift`, `ComposerAttachments.swift` | Model/effort/Orchestra picker (SKU-routed backends), attachments. |
| Approvals | approval cards in session view; `ApprovalCardView` (iOS) | Allow / deny / modify-and-allow / always-allow-in-session; first-responder-wins collapse on `approvalResolved`. |
| Terminal panel | `Panels/TerminalPanel.swift` (SwiftTerm) | **In-container shell** (D2): hostd intent → broker `proc.exec(tty:true)` of `agentShellArgv` (nash) in the session container; bytes ride a per-terminal loopback side-channel vended by hostd; rendered in the Windows Terminal control; `proc.resize` on layout. No host ConPTY needed. |
| Editor / diff | `Panels/EditorPanel.swift`, `SyntaxHighlighting.swift`, diff tab | Monaco in WebView2 (read-only diff-first); native later if desired. |
| Build & Run | `BuildRunPanel.swift` | Host process spawn via hostd intent (ChildProcess + Job Objects). |
| Settings | `SettingsView.swift` (2,995), `RemoteAccessView.swift`, `ModelCatalog.swift`, `QuotaIndicator.swift` | Sections that apply: agents/accounts (OAuth via loopback listener), sandbox (CPU/memory, restart-shared), remote access (QR pairing display, transports), updates (channel display; App Installer drives updates). |
| Attention | `DockBounce.swift`, sound hooks (`AppStore.swift:668-681`) | `AppNotificationManager` toasts + `FlashWindowEx` + optional sound, wired to the same AppStore hook events over the wire. |
| Accessibility/theming | 4 color-vision palettes + 5 text sizes (app-wide) | Resource dictionaries keyed off host-synced settings (`SyncedSettings.swift`); honor Windows high-contrast. |

---

## 8. Onboarding flow (first run)

1. Renderer launches → `HostdLauncher` starts hostd → hostd starts broker → broker
   `components.missing()`.
2. If WSL/wslc components missing → guided install page (elevated `wsl --install` /
   component install via broker), progress streamed, reboot handling.
3. Broker `session.ensure` → `image.pull` naros-agent (progress → existing
   `controlDownloadProgress` UI path).
4. Control-plane connectivity self-test (`probeControlPlane`) with actionable remediation
   (firewall script) on failure.
5. Agent CLI auth: Claude/Codex/Grok OAuth flows (loopback listener, DPAPI custody) —
   same UX as macOS Settings.
6. Add or clone a Control project → first session.

---

## 9. Parity gap plans

### 9.1 Tailnet (D9)
- Build libtailscale as `tailscale.dll` (Go `-buildmode=c-shared`) for windows/amd64 +
  windows/arm64; new `windows/scripts/build-libtailscale-windows.ps1` pinned to the same
  commit as `scripts/build-tailscalekit.sh`.
- `Sources/NucleicTailnet` gains a Windows leg over the same libtailscale C header that
  TailscaleKit wraps (`#if canImport(TailscaleKit)` stays Apple-only; add
  `#if os(Windows)` + dlopen-or-link of the DLL). The fd-backed `FrameChannel` transport
  works over Winsock fds.
- Absent DLL → existing `isBuiltIn == false` path (feature shows "not built in").

### 9.2 On-device AI (D6)
- `Intelligence.swift` already has a provider seam with heuristic fallbacks. Add a
  `WindowsFoundryIntelligenceProvider`: Phi Silica is WinRT-only, so hostd routes requests
  to a C# sidecar — **reuse brokerd** with the `ai.generate` RPC (keeps one helper
  process). Capability-probe at startup; fall back to heuristics when unavailable
  (non-Copilot+ hardware) — identical UX to a Mac without Apple Intelligence.

### 9.3 Free-by-construction (verify, don't rebuild)
LockManager, ConflictCoordinator, nvrsion, Autoship + merge queue, idea inbox, activity/
streaks, quota gauges (`SubscriptionUsage.swift` logic), Cast/Mirror, transfer — all pure
core + GRDB; they work once NucleicCore compiles and DPAPI custody lands. Cover via the
existing cross-platform test suites plus the M2 e2e.

### 9.4a Deferred is not excluded — read this before writing "unsupported"

Two different things get written up the same way and must not be:

- **Excluded by decision** (§9.4 below): the MacVM computer-use subsystem, `NucleicMDM`,
  `NucleicPowerHelper`, Sparkle, SwiftTerm. These are D4 calls about what the Windows product *is*.
- **Deferred until it can no longer be avoided**: everything else that currently answers
  `unsupported`, degrades, or is missing a capability flag. These are sequencing calls, not product
  ones, and each has a known route:

| Deferred | Route when it becomes unavoidable | Needed by |
| --- | --- | --- |
| pty / `proc.exec(tty:true)` / `proc.resize` | `IWSLCProcess::ResizeTty` + `CreateRootNamespaceProcess` on the internal COM arm (D13) | item 12's Terminal panel, M4 |
| D13 Tier 2 "adopt" — containers survive a broker restart | internal-COM exec/stdio (`IWSLCContainer::Exec`, `IWSLCProcess::GetStdHandle`), since §13.2 found the compat projection cannot wrap a service-side handle | nothing before M4; Tier 1 covers M2 |
| service-wide `container.list` (`enumerate`) | `IWSLCSession::ListContainers`, already written and proven for recovery (§13.3) — it just is not wired into the live roster | a fuller `reconcile` than M2 needs |
| signals outside wslc's six (`SIGUSR1`, `SIGWINCH`) | no route on either surface today; would need Microsoft to widen `WSLCSignal` | nothing known |
| uid drop on a non-util-linux image | a `su`-based fallback, or documenting util-linux as an image requirement | only custom images; narOS ships it |

So `WslcError.Unsupported` means **"this build does not serve that call, and retrying will not
help"** — a signal about retry semantics, not a claim about the product. When one of these is
implemented, the kind stops being returned and the `capabilities` hello gains a flag; nothing above
`IWslc` changes. Write new gaps up the same way: what it would take, and what needs it.

### 9.4 Explicitly out of scope (D4)
`MacVM/` computer-use (mac_vm_* and GUI linux_vm_* tools stay unadvertised — they're
per-token-registered, so simply don't register handlers), `NucleicMDM` server role,
`NucleicPowerHelper`, Sparkle, SwiftTerm, `shell/` nash host-side use (nash still ships
*inside* the containers and is fully used there).

### 9.5 nvrsion pre-land hooks — deferred to the nash port

`NvrsionTrunk.runPrelandHook` runs a trunk's pre-land hook as `/bin/sh -c <command>`; hooks are
written as `sh` one-liners and Windows has no `sh`. `cmd.exe` was rejected as a stand-in — it
would run those commands with different semantics, silently, at the moment the hook exists to be
a safety gate — and Git-for-Windows `bash.exe` was rejected as a second shell to depend on when
nash is the intended answer.

So on Windows a configured hook **rejects the land** with a message naming the reason (the same
outcome the POSIX path gives for a hook that can't start, minus the misleading "could not start"
text). A trunk with no hook configured is unaffected: `land(...)` only calls the hook when
`prelandHook` is non-nil. Revisit when nash ports to Windows and can execute these directly.

---

## 10. Repo layout for new code

```
windows/                          # sibling of ios/ and cloud/
  Nucleic.sln
  NucleicApp/                     # WinUI 3 app: Views/ Services/ Interop/ Assets/
  NucleicApp.Package/             # MSIX packaging project (per-channel manifests templated)
  NucleicBroker/                  # nucleic-brokerd (Microsoft.WSL.Containers + ai.generate)
  NucleicProtocol.Interop/        # shared P/Invoke + DTO library (app + tests)
  NucleicApp.Tests/               # xUnit incl. protocol-abi golden fixtures
  NucleicBroker.Tests/            # broker contract tests against fake IWslc
  spikes/                         # M1 spike programs (kept, runnable)
  appinstaller/                   # nucleic-<channel>.appinstaller.template
  props/Directory.Build.props     # TFM, versions, channel from VERSION/NUCLEIC_CHANNEL
  scripts/{build-hostd.ps1, package-msix.ps1, release-windows.ps1,
           build-libtailscale-windows.ps1}
Sources/
  nucleic-hostd/                  # Windows headless host (shares bring-up with nucleicd)
  NucleicProtocolC/               # C-ABI DLL target (+ include/nucleic_protocol.h, exports.def)
  NucleicCore/Container/SandboxEngine.swift
  NucleicCore/Container/Windows/{WslcContainerEngine,WslcBrokerClient,WslcProcessHandle}.swift
  NucleicCore/Windows/{WindowsSupport,WindowsSignals,ProcessTree,FilePermissions,
                       WindowsEnvironment,WindowsFileIO,PowerBlocker,      # item 9
                       CredentialStore,WindowsDNSSD,NIOLANTransport,
                       NIOLANListener}.swift                              # later items
  NucleicCore/PortableLogging.swift          # lifted from LinuxSupport.swift
  NucleicCore/{NucleicPaths,SecretFile}.swift  # shared seams behind the Windows legs
Tests/NucleicProtocolCTests/
fixtures/protocol-abi/
.github/workflows/windows.yml
```

---

## 11. Windows hostd design (`Sources/nucleic-hostd`)

Factor the shared headless bring-up out of `Sources/nucleicd/Nucleicd.swift` (`makeStore`,
blank-API-key scrub, startup choreography ~lines 142-171) into an internal
`NucleicHeadless` helper used by both nucleicd and hostd. hostd-specific duties:

- Single-instance mutex; rendezvous file write (owner-only DACL); `SetConsoleCtrlHandler`.
- Start order: GRDB store → AppStore graph → broker spawn + `session.ensure` →
  `startSyncServer()` → local renderer listener (loopback TCP, port 0, Noise XXpsk0 with
  PSK auto-approval — the local analog of `meshAutoApprovePairing`, `Nucleicd.swift:101`)
  → LAN listener + DNS-SD advertise → relay/tailnet transports per settings.
- `PowerBlocker` held while sessions active. Windows Job Object on brokerd so it dies with
  hostd.
- Transports env default: `NUCLEIC_SYNC_TRANSPORTS=lan,relay` (+`tailnet` when configured)
  vs nucleicd's relay-only default.

---

## 12. Work breakdown

> Progress: items **1–10 are implemented**; item **10** includes the JSON bridge, exhaustive tag
> surface, all 115 golden CBOR↔JSON vectors, DLL entry points, NIO TCP channel, and shared
> projection splice adapter. See
> "Execution status" at the top of this document for per-item verification state,
> divergences, and known gaps. Items 11–16 remain (item 14 has build-script/CI design groundwork,
> but the MSIX/release implementation is not done).

Ordered; items 1–4 are **Darwin-first refactors** verifiable by the existing macOS suite
and safe to start immediately on macOS hardware.

1. **Manifest & gating** — `Package.swift` only: `onWindows`/`nonDarwin`, widened platform
   conditions, Windows exclude list, new targets.
2. **PortableLogging** — lift the Logger shim from `LinuxSupport.swift`;
   `WindowsSupport.swift` stubs.
3. **SecretStore seam** — protocol + Keychain backend + retarget ~14 files (§4.2); DPAPI
   backend can land later with the Windows build.
4. **SandboxEngine seam** — protocol + `ContainerEngine` conformance + retype
   `ContainerManager` and backend references (§3.1). macOS tests must stay green.
5. **Broker** — `windows/NucleicBroker` full RPC surface (§3.3) + fake-`IWslc` tests. *Done for
   the compat surface: RPC surface, tests (18/18), and `WslcFacade.cs` rewritten against the real
   API and compiling against the real NuGet (§13.2). The D13 internal-COM arm is unwritten but
   unblocked — its entry point is confirmed on hardware (§13.2), and writing it (enumeration,
   reattach, pty) is the next task in the container subsystem, after one `--internal-call` vtable
   check.*
6. **Swift wslc client** — `WslcBrokerClient` / `WslcContainerEngine` /
   `WslcProcessHandle` + tests against a scripted fake broker exe (mirror the
   `fake-claude` adapter-contract pattern).
7. **Control plane** — `NIOByteConn`, gateway plumbing through `ensureRunning`,
   `control-bridge.js` TCP branch (guest contract unchanged).
8. **hostd** — `NucleicHeadless` factor-out; `Sources/nucleic-hostd/Hostd.swift` (§11);
   NIO LAN transport + `WindowsDNSSD`.
9. **Windows shims** — `WindowsSignals`, `ProcessTree` (+ Job Objects), `FilePermissions`
   (+ shared `SecretFile`), `WindowsEnvironment` (registry PATH, `setenv`), `WindowsFileIO`,
   `PowerBlocker`, `LoginShellEnv/PATH` legs, `NucleicPaths`. (`FileWatcher` turned out not to
   be needed — §4.3.)
10. **Protocol DLL** — target + C header + JSON bridge + golden fixtures + Swift tests.
11. **Interop + renderer core** — `NucleicProtocol.Interop`, `HostConnection.cs`,
    `RendererStore.cs`, `HostdLauncher`, onboarding.
12. **Screens** — Home → SessionDetail/Transcript → Composer → Approvals → Projects/
    Settings → Panels (Terminal, Diff, Build&Run, Todos) → RemoteAccess/host picker.
13. **Remote parity** — DNS-SD advertise + QR (reuse the `mintPairingCode()` path), relay
    (portable already), tailnet DLL + `NucleicTailnet` Windows leg.
14. **Packaging/release** — MSIX projects, appinstaller templates,
    `cloud/nucleic-updates` `/win/<channel>/latest` routes, `release-windows.ps1`,
    Trusted Signing.
15. **naros multi-arch** — extend `os/` + `.github/workflows/naros.yml` to push an
    amd64+arm64 manifest list to GHCR (D10).
16. **Phi Silica provider** (§9.2), accessibility/theming parity, polish.

Packaging details (item 14): MSIX Identities `BLAKESLEE.Nucleic[.Dev/.Canary/.Beta/.RC]`
mirroring the bundle-ID scheme (`Package.swift:27-35`); `runFullTrust`; payload = three
exes + `NucleicProtocolC.dll` + Swift runtime DLLs + MinGit; per-arch MSIX with
`.appinstaller` `<MainPackage ProcessorArchitecture>` selection; feeds
`nucleic-<channel>.appinstaller` on R2 beside the Sparkle appcasts with in-app update
check reading the same feed (App Installer has no delta updates); publisher CN must match
the Trusted Signing cert.

---

## 13. Milestones & exit criteria

**M0 — Toolchain proof (riskiest first).**
Swift 6.3-windows (pinned in `windows/props/swift-version.txt`, x64 + arm64 toolchains;
the pin has a floor — the hosted runners' VS 2026 STL hard-errors STL1000 on a bundled
clang older than 20, which rules out Swift 6.2.x)
builds `NucleicProtocol`; `NucleicProtocolTests` + `NucleicCarbonTests` (the designated
cross-platform suites, `Package.swift:391-401`) green on hosted `windows-2025` (x64) and
`windows-11-arm` runners, including GRDB/SQLite linkage.
*Exit: `windows.yml` green on both arches; corelibs/toolchain gap list filed as issues.*

**M1 — De-risking spikes (parallel, checked into `windows/spikes/`).**
(a1) **wslc API — DONE** (`windows/spikes/WslcApiDump`, §13.1). The surface is verified
member-by-member against the shipped assembly on WSL 2.9.4, the compat/internal split is
established (D13), session reattach is settled, and uid semantics are answered (no uid on
`ProcessSettings` → `setpriv` wrapper).
(a2) **wslc happy path — RUN AND GREEN, 2026-07-29. See §13.3.** The whole path works on
hardware; it found three broker bugs (all fixed), a BusyBox `setpriv` gap, and the 9P number that
puts D8 in question. (`windows/spikes/WslcSpike`): GHCR pull of
naros-agent → container with an NTFS `ContainerVolume` → exec in the bind-mounted worktree →
stdio round-trip → signal → teardown, plus the 9P measurement (`git status` on the mount vs. a
copy on container-local ext4, same container and repo) and a live D13 Tier 1 recovery test that
kills the broker mid-session. It drives **`nucleic-brokerd` over its real NDJSON surface** rather
than calling wslc itself — its csproj is plain `net9.0` with no wslc reference, so it cannot drift
into a second implementation, and a passing run is evidence about the shipping code. This is the
first execution of *anything* in the container subsystem.
(b) **Gateway-TCP reachability — TODO, and it is the control-plane spike that matters** (§13.2
retracted the hvsocket inversion: `IWSLCVirtualMachine` is unreachable from a client, so there is
no VMID to bind `AF_HYPERV` to). Measure whether a `Bridged` wslc container can reach the host at
the `vEthernet (WSL)` address `WslcFacade` now returns, under the **default** Windows firewall,
and confirm `control-bridge.js` completes an MCP round trip over it. `ContainerNetworkingMode` is
`None | Bridged`, so the NAT/mirrored matrix this originally called for does not exist. hvsocket
stays as upside contingent on sourcing a VMID outside wslc (HCS enumeration) — not on the M2 path.
(c) **DLL**: SwiftPM-built `NucleicProtocolC.dll`, C# P/Invoke connect-pair-echo round-trip
against a Swift test host; settle symbol-export strategy.
*Exit: (a1) ✓. Remaining — 9P perf numbers with a mitigation decision; hvsocket-in-container
reachability confirmed or the fallback promoted back; wslc API gaps filed upstream while preview
feedback still lands (microsoft/WSL#41024 is the existing report, unanswered).*

**M2 — Headless host runs an agent.**
NucleicCore compiles on Windows (work items 1–9); hostd boots, GRDB at `%APPDATA%`,
broker supervises the wslc session; `nucleic-smoke` (already cross-platform) pairs over
loopback and drives a full Claude session: prompt → MCP approval round-trip →
git-interceptor event → worktree commit on NTFS.
*Exit: e2e session with approval + interceptor events asserted from the transcript;
containers survive hostd kill/restart and reconcile; runs on the self-hosted x64 runner (D11).*

**M3 — Renderer alpha.**
DLL interop + golden fixtures; HostConnection/RendererStore; Home, Sessions,
SessionDetail transcript, Composer, Approvals; hostd auto-launch + rendezvous.
*Exit: dogfood-usable real coding session end-to-end from the UI; transcript-parity harness
(replay recorded HostMsg streams through `np_projection_apply` vs. the Swift projector,
diff the JSON) passes on a recorded fixture session.*

**M4 — Remote + panels + packaging.**
DNS-SD advertise + QR pairing (iPhone pairs to the PC over LAN), relay listener,
remote-cockpit host picker (D12), Terminal/Diff/Build&Run/Todos/Settings panels, toasts +
taskbar attention, MSIX per-channel + `.appinstaller` on R2 (dev + canary), Trusted
Signing, ARM64 package.
*Exit: iPhone full remote session against a Windows host over LAN + relay; canary
installs and auto-updates via the App Installer feed on clean x64 + ARM64 VMs.*

**M5 — Parity beta.**
Tailnet DLL + Windows leg; Phi Silica provider + fallback; accessibility/theming parity
(4 palettes, 5 sizes); quota/OAuth flows; idle/memory policy tuning; **wslc GA migration**
(fall 2026) absorbed behind the broker; beta channel live; winget evaluation.
*Exit: parity checklist (every §7 screen row + every §9 gap row) green; 14-day dogfood
with no P0s.*


### 13.1 M1 (a) findings — the real `Microsoft.WSL.Containers` (2026-07-29)

**Identity.** Package `Microsoft.WSL.Containers` **2.9.3** — the only version on nuget.org, and
it tracks WSL's own version scheme, not the `0.1.0-preview.N` this document assumed. Managed
assembly is `lib/net8.0-windows10.0.19041.0/**wslcsdkcs.dll**` (namespace
`Microsoft.WSL.Containers`), so the package name is not the assembly name. It also ships a native
`wslcsdk.dll` + `wslcsdk.h`, a `.winmd` for C++/WinRT, and MSBuild/CMake targets that build
container images as a build step. Its own README states the preview/breaking-change warning that
D3 and §15 already assume.

**How to re-derive this** without a Windows box, when the version bumps: fetch
`https://api.nuget.org/v3-flatcontainer/microsoft.wsl.containers/<version>/microsoft.wsl.containers.<version>.nupkg`,
unzip, and read `lib/*/wslcsdkcs.dll` with `MetadataLoadContext` (it needs `WinRT.Runtime.dll`
from `Microsoft.Windows.CsWinRT` on the resolver path). That is exactly what `WslcApiDump` does
on a real machine, and the two should agree.

#### Mechanical corrections (fixed behind `IWslc`, no design impact)

| `WslcFacade` assumed | Actually |
| --- | --- |
| `WslcService.GetServiceVersion()` | `GetVersion()` → `ServiceVersion {Major,Minor,Revision}` |
| `GetMissingComponents()` → `ComponentFlags` | → `IReadOnlyList<Component>`; `Component` = `VirtualMachinePlatform` \| `WslPackage` \| `SdkNeedsUpdate` (a list, not flags) |
| `InstallComponentsAsync(flags)` | `InstallWithDependencies()` / `InstallWithDependenciesAsync()` |
| `Session.CreateOrOpen(settings)` | `new Session(settings)` + `Start()` — **see below** |
| `SessionSettings.MemoryMB` | `MemorySizeInMB` (`uint?`); `CpuCount` is `uint?`; also `Timeout`, `EnableGpu`, `VhdRequirements`, `StoragePath` |
| `Session.SessionTerminationHandler` property | `event Terminated` carrying `SessionTerminationReason` = `Unknown` \| `Shutdown` \| `Crashed` |
| `PullImageOptions.Credentials` = `RegistryCredentials` | `RegistryAuth` is a **`string`**; ctor is `PullImageOptions(uri)` |
| `PullImageOptions.Progress` event | Progress rides `ImageProgress {Id, Status, CurrentBytes, TotalBytes}` + `ImageProgressStatus` (Pulling/Waiting/Downloading/Verifying/Extracting/Complete), surfaced through the **async** `PullImageAsync` overload |
| `ImageInfo.Reference/.Digest/.Size` | `.Name` / `.Sha256` / `.Size` (`ulong`) / `.CreatedTimestamp` |
| `ContainerSettings.Hostname` | `HostName`; image is `ImageName`; also `EnableAutoRemove`, `Privileged`, `EnableGpu`, `PortMappings`, `NamedVolumes`, `DomainName` |
| `ContainerVolume(host, guest, ro)` | ✓ correct — `ContainerVolume(windowsPath, containerPath, readOnly)` |
| `Container.RunProcess(settings)` | `CreateProcess(settings)` **then** `process.Start()` — two steps, which is what makes it possible to attach handlers before the process runs. The facade's single call could drop the first output chunk. |
| `DeleteContainerFlags` | `DeleteContainerOption` |
| `Signal` as an int | a named enum: `None, SIGHUP, SIGINT, SIGQUIT, SIGKILL, SIGTERM`. The RPC carries POSIX ints, so the broker needs an int→enum map, and anything outside that set (SIGUSR1) is unavailable. |
| `Process.WriteStdin/CloseStdin` | `GetInputStream()` / `GetOutputStream()` (WinRT streams) |
| `Process.OutputReceived(stderr, data)` | **two** events — `OutputReceived` and `ErrorReceived`, each `(byte[] data)`. The broker's `ProcOutput(procId, stderr, chunk)` sink is still right; the facade just subscribes twice. |
| — | `Error` enum (`ImageNotFound`, `ContainerNotFound`, `ContainerNotRunning`, `SessionReserved`, `RegistryBlockedByPolicy`, …) — a better source for `data.kind` than string matching |

#### There are TWO COM surfaces, and the SDK wraps the smaller one

WSL is open source, which settles this properly. `wslc.exe` is not special and does not have
private powers: it is a COM client, and both interfaces it can talk to are checked in.

| | `WSLCCompat.idl` | `wslc.idl` |
| --- | --- | --- |
| Purpose | **SDK-facing.** `wslcsdk.dll` — and therefore the C# projection — wraps exactly this. | **Service-internal.** What `wslc.exe` itself calls. |
| Stability | *"Changes in this file must maintain backwards compatibility"* | *"ABI breaking changes in this file are OK, since both client & server always ship together. The WSLC SDK must not use this file"* |
| Entry point | `WSLCCompatSessionManager`, CLSID `a9b7a1b9-0671-405c-95f1-e0612cb4ce8f` | `IWSLCSessionManager`, IID `82A7ABC8-6B50-43FC-AB96-15FBBE7E8760` |
| Container list | — | `IWSLCSession::ListContainers(options, …, portMappings, …)` |
| Container stats | — | `IWSLCContainer::Stats([out] LPSTR* Output)` (JSON) |
| Attach to existing | — | `IWSLCSession::OpenContainer(Id)`, `IWSLCContainer::Attach(DetachKeys, StdIn, StdOut, StdErr)` |
| Session attach | `CreateSession` only | `OpenSessionByName(DisplayName)`, `EnterSession(name, storagePath, …)`, `ListSessions()` |
| pty | — | `IWSLCProcess::ResizeTty(Rows, Columns)`; `CreateRootNamespaceProcess(…, TtyRows, TtyColumns, …)` |
| VM identity | — | `IWSLCVirtualMachine::GetId([out] GUID* VmId)`, `AcceptConnection`, `ConfigureNetworking`, `MapVirtioNetPort` |
| Events | progress/warning/crash callbacks | + `IWSLCPluginNotifier`: `OnContainerStarted(InspectJson)`, `OnContainerStopping`, `OnImageCreated`, `OnImageDeleted` |

So the earlier conclusions were both wrong, in opposite directions. The SDK is not hiding anything
— `WSLCCompat.idl` genuinely lacks all of it, and the native `wslcsdk.dll` export table is exactly
the 60 documented functions plus `DllGetActivationFactory`, with nothing undocumented. **But
shelling out to `wslc.exe` is not the only alternative**: the internal interface is ordinary COM
with published IIDs, and calling it directly is what the CLI does.

**`IWSLCVirtualMachine::GetId` deserves its own line.** It hands back the VM's GUID, which is
precisely what an `AF_HYPERV` bind needs — so §5's "spike + fallback" hvsocket path, filed as
upside we might never reach, looked directly reachable, and with it true vsock parity with the
macOS control plane.

> **Retracted, 2026-07-29 — see §13.2.** Reading the IDL rather than the summary of it:
> `IWSLCVirtualMachine` is produced **only** by `IWSLCVirtualMachineFactory::CreateVirtualMachine`,
> and that factory is an *input* the SYSTEM service passes into `IWSLCSession::Initialize` /
> `IWSLCSessionFactory::CreateSession`. Nothing a client can reach — not `IWSLCSessionManager`,
> not `IWSLCSession` — hands one back. So a client cannot call `GetId`, and the VM GUID does not
> come from here. §5's ordering therefore stands as originally written (gateway TCP primary),
> and the hvsocket path is back to needing a VMID from somewhere else entirely.

##### D13 — the split: compat-SDK where it reaches, internal COM where it doesn't

**Decided.** `WslcFacade` binds to both surfaces, and which one answers a call is invisible above
`IWslc`:

| Path | Surface | Why |
| --- | --- | --- |
| session create/terminate, image pull with progress, container create/start/stop/delete, `proc.exec` + event stdio + signals | **compat SDK** (`Microsoft.WSL.Containers`) | The hot path, and the contractually-stable surface. Event-mode stdio is the whole reason to use the SDK at all. |
| `container.list` / `container.state` sweep on reattach | **internal COM** — `IWSLCSession::ListContainers` | §2.3's reconcile has no compat-surface equivalent. Returns port mappings alongside, which the compat surface never exposes. |
| `container.stats` | **internal COM** — `IWSLCContainer::Stats` (JSON) | Feeds `ContainerResourceSample`. A cgroup read via `runCapturing` is the escape hatch if this one call proves unstable. |
| session + container reattach | **internal COM** — `IWSLCSessionManager::OpenSessionByName` / `EnterSession`, `IWSLCSession::OpenContainer`, `IWSLCContainer::Attach` | The mechanism §2.3's broker supervision assumes, **confirmed on hardware** (§13.1 live findings): a second same-named `Session` constructs — the constructor is lazy — but its `Start()` refuses with `ERROR_ALREADY_EXISTS`. The compat surface cannot re-adopt a running session, and has no container enumeration at all. |
| Terminal panel tty (§7) | **internal COM** — `IWSLCProcess::ResizeTty`, `CreateRootNamespaceProcess(…, TtyRows, TtyColumns, …)` | The compat surface has no pty at all, so this is the only way item 12's Terminal panel is real rather than a pipe. |
| control-plane transport (§5) | **internal COM** — `IWSLCVirtualMachine::GetId` | The VM GUID an `AF_HYPERV` bind needs. See below. |

**No CLI arm.** Shelling out to `wslc.exe` was considered and rejected: it would mean a process
spawn per call, scraped text instead of HRESULTs and JSON, no event notifications, and a second
mechanism to keep working — for a stability gain that is real but partial, since a WSL update that
breaks the internal ABI can equally change CLI output. One mechanism, understood and pinned, beats
two.

##### What this costs, and how it is contained

`wslc.idl` says outright that *"ABI breaking changes in this file are OK, since both client &
server always ship together"*. We are not both, so this is a genuine risk and the plan owns it
rather than hoping:

- **Nothing above `IWslc` moves.** `SandboxEngine`, `ContainerManager`, the §3.3 RPC surface and
  every line of Swift are already written against the seam. A broken ABI is a broker-local repair.
- **Degrade, don't die.** The broker probes the internal interface at startup and reports what
  bound in its `capabilities` hello (§2.3). If it fails, the compat SDK still runs sessions,
  containers and agents — the port loses reattach, stats and the Terminal panel, and hostd is told
  so, rather than the sandbox failing wholesale. That is a bad day, not a dead host.
- **Pin and detect.** The WSL version is pinned per release alongside the NuGet, and
  `windows/spikes/WslcApiDump` grows an IDL-hash check against the pinned revision of
  `wslc.idl` + `WSLCCompat.idl`, so a drifted interface is caught by a spike run rather than by a
  user. This is the same posture §15 already takes on the preview NuGet, extended to cover the
  interface it does not ship.
- **Cheapest binding.** ~~Marshalling `wslc.idl` from C# is real work — `size_is` arrays,
  `system_handle`, unions — so a small C++/WinRT shim inside the broker is likely cheaper and
  safer than hand-written `ComImport` interop.~~ **Measured instead (§13.2): hand-written
  `ComImport` works.** `IWSLCSessionManager` binds and calls correctly from C# with `[PreserveSig]`
  and `IntPtr` placeholders for unused parameters. The shim stays in reserve for the one case that
  would still justify it — a reattached handle that cannot be projected back onto the compat
  surface, so that every operation needs hand-marshalling rather than just the lookup.

##### §5 revisited: hvsocket does NOT become the primary (corrected 2026-07-29)

This section previously argued that `IWSLCVirtualMachine::GetId` supplies the VM GUID an
`AF_HYPERV` listener needs, so §5's ordering should invert and hvsocket become primary. **That is
withdrawn**: per the IDL, no client-reachable interface returns an `IWSLCVirtualMachine` at all
(see the retraction above). The VMID has no known source, so the argument had no premise.

§5 therefore stands as written: **gateway TCP is the primary**, with the address coming from
`GetAdaptersAddresses` over `vEthernet (WSL)` on the broker side, since no wslc API surfaces one.
That is what `WslcFacade.EnsureSessionAsync` implements today (via `NetworkInterface`, the managed
wrapper over the same call), polling because the vNIC appears as the VM boots and `Start()`
returning does not mean it is up.

hvsocket returns to being upside rather than a plan. Reaching it needs a VMID from outside wslc —
`HcsEnumerateComputeSystems`, or the WSL VM's registry/HCS identity — which is a separate spike
with its own stability story, and is **not** a prerequisite for M2. **M1 (b) has since measured the
gateway path and it works** (§13.3) — a container reached an interface-scoped host listener under
the default firewall — so gateway TCP is the committed transport and hvsocket stays unbuilt.

#### Why the CLI is not the answer, though it could have been

The gaps are confirmed by three independent sources — the C# projection, the native `wslcsdk.h`
(no `WslcListContainers` either), and the official [API reference](https://wsl.dev/api-reference/)
— so they are not an artefact of the projection. They are gaps in `WSLCCompat.idl` itself, and
the `wslc.exe` CLI shipping in the same WSL build has all of them:

```powershell
wslc container ps                 # enumeration — what the compat surface has no call for
wslc container stats              # per-container resource usage
wslc run --rm -it … --name web    # interactive tty, and a name to address it by later
wslc container stop web           # …addressed by name from a DIFFERENT process
```

That last line is worth keeping even though the CLI is rejected: a container created earlier is
addressable from a process that never held its handle, which means container state is
**service-backed** — the premise §2.3's broker supervision rests on, now evidenced rather than
assumed.

The limitation is publicly raised and **unanswered** by Microsoft
([microsoft/WSL#41024](https://github.com/microsoft/WSL/discussions/41024) — "The WSL Container API
is missing many features that the wslc.exe command already has"), so compat-surface parity is not
something to plan around. D13 is the answer instead.

#### The findings themselves

1. **No container enumeration in the SDK.** `Session` exposes `CreateContainer` and nothing
   that lists containers; the native header has no `WslcListContainers` either. Resolved by the
   CLI arm above (`wslc container ps`). What still needs a live answer is the *session* half:
   whether `new Session(settings)` with an existing name attaches or throws
   (`Error.SessionReserved` hints at the latter), and whether
   `WSLC_CONTAINER_START_FLAG_ATTACH` — native-only, unexposed in C# — is the in-SDK reattach
   primitive.

2. **No per-container statistics.** There is no `GetStatistics()` — `Container` offers `Id`,
   `State`, `InitProcess` and `Inspect()` (a string). So `container.stats` →
   `ContainerResourceSample` has no source, and `sampleResourceUsage` must instead exec
   `cat /sys/fs/cgroup/...` inside the container and parse it, the way a Linux-native engine
   would. Recoverable, but it is a different implementation from the one §3.3 sketched.

3. **No pty.** `ProcessSettings` has `CommandLine`, `EnvironmentVariables`, `WorkingDirectory`
   and `OutputMode` — **no `Terminal`, no resize, and no `UserId`/`GroupId`**. Two consequences:
   - §7's Terminal panel loses `proc.exec(tty:true)` / `proc.resize`. Either the panel degrades
     to a pipe-mode shell (no line editing, no ANSI sizing), or the pty is created *inside* the
     container (run the shell under `script`/`socat`) and resize becomes an in-band message.
   - The uid question §3.2 flagged is settled the way it hoped: there is no uid setting, so exec
     wraps argv in `setpriv`/`su agent -c`. Interceptors and nash don't care about the numeric
     uid, so this costs nothing.

#### §5 control plane: the gateway does not come from wslc

There is no `HostGatewayAddress`, and no address property anywhere on `Session` or `Container`.
`ContainerNetworkingMode` is `None | Bridged` — not the NAT/mirrored pair §5 assumed. What does
exist is `ContainerPortMapping(windowsPort, containerPort, protocol)` with a `WindowsAddress`,
i.e. inbound host→guest forwarding, which is the wrong direction for the control plane.

So §5's primary transport survives, but its address must be obtained from **Windows networking
APIs on the broker side** (`GetAdaptersAddresses` over the `vEthernet (WSL)` adapter) rather than
from the SDK — `EnsureSessionAsync` still returns a gateway string, and nothing above `IWslc`
changes. That needs confirming on hardware in phase 2, along with whether a `Bridged` container
can reach the host at that address under the default firewall. If it cannot, §5's AF_HYPERV
fallback stops being upside and becomes the plan.

#### The published docs are stale against the shipped package

Microsoft Learn's own C# sample uses `MemoryMB`, `CmdLine` and `DeleteContainerFlags`; the
assembly in 2.9.3 has `MemorySizeInMB`, `CommandLine` and `DeleteContainerOption`. The native
header references a `WslcCanRun` that it does not declare. **Read the metadata, not the docs** —
which is what `windows/spikes/WslcApiDump` is for, and why it stays checked in.

One thing the docs did answer that metadata could not: pull progress is
`session.PullImageAsync(options)` returning an async operation whose `.Progress` handler receives
`(operation, ImageProgress)`. That closes the last mechanical unknown in `PullImageAsync`.

#### Live session findings (2026-07-29, WSL 2.9.4) — CONFIRMED ON HARDWARE

`WslcApiDump --session` on Windows 11 amd64, WSL 2.9.4. Every line below is an observed result,
not an inference; M1 (a) is complete:

- **The session constructor works and `Start()` succeeds.** The compat SDK boots a real WSL VM
  from `new Session(new SessionSettings(name, storagePath))` + `Start()`, exactly as transcribed.
- **The `Session` CONSTRUCTOR IS LAZY.** A second `Session` with the same name constructs
  happily — it only captures settings. Its success means nothing, which is exactly how the first
  reading of this probe got it wrong.
- **`Start()` is where identity is enforced, and it REFUSES.** The second `Start()` fails with
  **`ERROR_ALREADY_EXISTS` (0x800700B7)**, "Cannot create a file when that file already exists".
  So the compat surface **cannot re-adopt a running session**, and reattach does need
  `IWSLCSessionManager::OpenSessionByName` / `EnterSession` on the internal interface. **D13's
  reattach row is confirmed on hardware**, not merely inferred from an IDL.
- Note the code: `ERROR_ALREADY_EXISTS`, *not* `WSLC_E_SESSION_RESERVED` (0x80040607). The
  reserved code exists in `wslc.idl` but evidently means something narrower — do not key on it.
- A read on an unstarted `Session` throws `E_ILLEGAL_METHOD_CALL` (0x8000000E, "Session has not
  been started"), which is a useful state signal for the facade.
- **The service and the SDK version independently.** `GetVersion()` reports 2.9.4 against a NuGet
  pinned at 2.9.3 — a newer service with an older package, which is the benign direction, but it
  confirms the two move separately and that §15's pinning has to track both.

**What this does to D13: confirms it.** The row that was in question — session reattach — is
settled in D13's favour, and the other four never depended on this. The lesson for
`WslcFacade` is a design one: **the constructor is not a session; `Start()` is.** Anything the
broker does to detect or adopt an existing session must go through the internal interface, and
the facade must not treat a constructed `Session` as evidence that one exists.

#### Still unknown (needs a live run — phase 2)

Whether a second `new Session(...)` with an existing name attaches or throws; whether a session
(as opposed to a container) survives the creating process exiting; whether `wslc container ps`
sees containers the SDK created, and in what output format (a `--json`-style flag would save
parsing); whether `wslc … -it` supports resize; the gateway address and its reachability from a
`Bridged` container under the default firewall; and 9P latency for `git status` and `npm install`
on an NTFS `ContainerVolume`.

### 13.2 Writing the facade against the real API (2026-07-29) — and what it turned up

§13.1 established the surface from a Windows box. This pass *used* it: `Wslc/WslcFacade.cs` was
rewritten and — the part that had never been possible before — **compiled against the real
package**, in a Linux container, with no Windows hardware:

```bash
dotnet build windows/NucleicBroker/NucleicBroker.csproj -p:UseWslc=true -p:EnableWindowsTargeting=true
```

`EnableWindowsTargeting=true` is the whole trick: it lets a non-Windows SDK restore the Windows
targeting packs. The preview NuGet is public, so it restores anywhere. This closes a real gap in
the loop — the one file that touches the SDK was previously unbuildable except on the dev box,
and the fake-backed tests never compile it. `build.ps1 -Target broker` now runs this build too.

The API surface was re-derived the same way §13.1 describes (read `wslcsdkcs.dll` with
`MetadataLoadContext`), and it agrees with §13.1 member-for-member. **Four things it adds:**

1. **`Container` has no `Name`, and `Session` has no `GetContainers()`.** §13.1 recorded "no
   container enumeration"; the sharper fact is that a container is reachable *only* through the
   handle `CreateContainer` returned. `Container` exposes `Id`, `State`, `InitProcess` and
   `Inspect()` — nothing to match a name against. So the facade keeps its **own name→handle
   roster**, and `container.list` answers from that roster rather than from the service. Two
   consequences, both now reported through `capabilities` instead of being discovered at a call
   site: the roster cannot see a container this process did not create, and it dies with the
   process. A restarted broker has an empty sandbox as far as `ContainerManager.reconcile` can
   tell. This is the sharp edge of the D13 reattach gap, and it is worse than "no enumeration"
   sounded.

2. **`Session.Authenticate(uri, username, password) → string` is where `RegistryAuth` comes from.**
   §13.1 correctly recorded that `PullImageOptions.RegistryAuth` is a plain string rather than a
   credentials object, but not what produces the string. It is this call — so a private GHCR pull
   is `Authenticate("https://ghcr.io", user, token)` then assign. Without it the field has no
   producer and `image.pull` with auth cannot work at all.

3. **`ImageInfo.Sha256` is an `IBuffer` of raw bytes**, not a string, so the facade hex-encodes it
   into the `sha256:<hex>` digest the rest of Nucleic speaks. `ImageInfo.Size` is `UInt64`.

4. **The package's own `lib` folder lies about its targeting pack, and it is a hard build error.**
   The assembly ships under `lib/net8.0-windows10.0.19041.0/`, and the csproj pinned
   `net9.0-windows10.0.19041.0` *deliberately*, reasoning that matching the folder avoided a
   needless targeting-pack requirement. But `wslcsdkcs.dll` is itself compiled against
   `Microsoft.Windows.SDK.NET` **10.0.26100.79**, so a 19041 pack cannot load it:

   ```
   error CS1705: Assembly 'wslcsdkcs' ... uses 'Microsoft.Windows.SDK.NET, Version=10.0.26100.79'
   which has a higher version than referenced assembly ... 'Version=10.0.19041.38'
   ```

   Bumping the TFM to 26100 is **not sufficient** — a TFM selects a pack by major revision, and
   the .NET 9 SDK's default for 26100 is `10.0.26100.38`, still below the floor. It needs an
   explicit `<WindowsSdkPackageVersion>`, and the value is `10.0.26100.80`, because .79 — the
   version the error names — was never published to nuget.org. Three separate traps in one
   two-line property group, and the reflection-only `WslcApiDump` spike hits none of them (it
   binds no SDK type at compile time), which is why this surfaced only when real code was written.

#### ANSWERED ON HARDWARE, 2026-07-29: the internal arm has an entry point

`WslcApiDump --internal` on Windows 11 amd64 (WSL 2.9.4):

| Class | `IWSLCSessionManager` |
| --- | --- |
| **`WSLCCompatSessionManager`** `{a9b7a1b9-0671-405c-95f1-e0612cb4ce8f}` | **YES** |
| `WSLCCompatSessionManagerFactory` `{9fcd2067-…}` | `E_NOINTERFACE` |
| `LxssUserSession` `{a9b7a1b9-…-ce7e}` | `E_NOINTERFACE` |
| `LxssUserSessionInBox` `{4f476546-…}` | `REGDB_E_CLASSNOTREG` (not registered on this box) |

**So the entry point is the compat coclass itself** — the same class `wslcsdkcs.dll` activates for
the stable SDK surface also implements the service-internal manager. One object, two faces, which
is the ordinary COM pattern and exactly what `wslc.exe` must be doing. `CoCreateInstance` the
compat CLSID, then `QueryInterface` for `82A7ABC8-6B50-43FC-AB96-15FBBE7E8760`. **Record that
CLSID as the internal arm's entry point; it is not written down in `wslc.idl`, which is what made
this a question at all.**

Two subsidiary results from the same run:

- **`IWSLCVirtualMachine` is refused by every class**, which confirms §13.1's retraction *on
  hardware* rather than by reading an IDL. The hvsocket-primary inversion was genuinely
  unfounded, and §5's gateway-TCP ordering is now evidenced rather than merely restored.
- **`IWSLCSession` is refused by the manager**, which is expected and not a gap: a session is
  *returned by* `OpenSessionByName` / `EnterSession` / `CreateSession`, not QI'd off the manager.
  It confirms the manager is a manager rather than a do-everything object.

**The vtable matches the IDL — confirmed by `--internal-call` on the same box.**
`GetVersion()` (slot 3) returned **2.9.4**, agreeing with the compat `WslcService.GetVersion()`
printed in the same run — so it is not merely a well-formed call, it is *the same service*
answering through both faces. `ListSessions()` (slot 6) then returned S_OK. Slots 3–6 of
`IWSLCSessionManager` are therefore the ones `wslc.idl` publishes.

**This retires the C++/WinRT shim idea.** §13.1 guessed that "a small C++/WinRT shim inside the
broker is likely cheaper and safer than hand-written `ComImport` interop". For the manager that is
now measured, and hand-written `ComImport` simply works — `[PreserveSig]`, `IntPtr` placeholders
for the parameters we don't use, and slot order taken from the IDL. Keep the shim in reserve for
the one case that would still justify it (see the handoff question below), not as the default.

**Enumeration is proven end-to-end.** Re-run as `--session --keep --internal-call` (create a
session, then re-adopt it through the internal interface — §2.3's reattach story in one process),
`ListSessions` returned `#6 "nucleic-spike" (creator pid 10720)`. The name came back **correct**,
so the `WSLCSessionListEntry` layout — two DWORDs followed by two *inline* `wchar_t` buffers, not
pointers — marshals as declared. `WSLCContainerEntry` (`Name[256]`, `Image[256]`, `Id[65]`, two
`ULONGLONG`s, a state enum) is the same shape behind the same `size_is(, *Count)` double pointer,
so `ListContainers` should follow directly.

##### `OpenSessionByName` needs IMPERSONATE — brokerd will hit this

The same run failed `OpenSessionByName("nucleic-spike")` with **`0x80070542`** — on the session
`ListSessions` had *just listed by name*. That is `HRESULT_FROM_WIN32(1346)` =
**`ERROR_BAD_IMPERSONATION_LEVEL`**, and it is a COM security failure, not a missing object: the
service impersonates the caller to resolve a **per-user** session, and a .NET COM client is handed
`RPC_C_IMP_LEVEL_IDENTIFY` by default — enough for the server to check who we are, not to act as
us. `GetVersion` and `ListSessions` never impersonate, which is precisely why those two succeeded
and made this look like a per-method capability gap.

Two fixes, and the choice differs by process:

- **The spike** now calls `CoSetProxyBlanket(proxy, …, RPC_C_IMP_LEVEL_IMPERSONATE, …)` on the
  manager right after the QI. Surgical, per-proxy, and works after marshalling has happened.
- **`nucleic-brokerd` should call `CoInitializeSecurity` with
  `RPC_C_IMP_LEVEL_IMPERSONATE` once at startup, before its first COM call** — it covers every
  proxy the process will ever hold, including ones handed back by other calls. It must genuinely
  come first, or it fails `RPC_E_TOO_LATE`. Note the ordering hazard: the compat SDK makes COM
  calls of its own, so this has to precede any `WslcService`/`Session` use in `Program.cs`, not
  merely precede the internal arm.

This is worth flagging loudly because the failure is so misleading — a security error that reads
as "not found", on a per-user object, only on the methods that matter for reattach. The probe used
to print "expected if no session of that name is running" underneath it; it now decodes the code
and refuses to blame absence.

##### The handoff question — what actually sizes the internal arm

`IWSLCSession` (internal, `EF0661E4-…`) and `IWSLCCompatSession` (SDK-facing, `DD7B2EF9-…`) are
distinct interfaces. `WSLCCompatSessionManager` has already proved that one object can wear both
faces, so the question is whether the session handed back by `OpenSessionByName` does too:

- **If it QIs to `IWSLCCompatSession`** → pass the pointer to `Session.FromAbi()` and a re-adopted
  session is driven by the **existing** facade code. The internal arm shrinks to *find, then hand
  off*: `OpenSessionByName` + `ListContainers` + `OpenContainer`, and nothing else in
  `WslcFacade` changes. Same trick should then apply to containers via `IWSLCCompatContainer`
  (`8C3C91FA-D550-41B9-AD9D-23DCBF96F549`) and `Container.FromAbi()`.
- **If it does not** → every re-adopted handle is internal-only, and start/stop/delete/exec each
  need a second hand-marshalled path. That is a far bigger arm, and the case where the C++/WinRT
  shim earns its keep after all.

**Partly answered, and the encouraging half was misleading.** With the proxy blanket raised,
`OpenSessionByName("nucleic-spike")` opened the running session and the returned `IWSLCSession`
**QI'd successfully to `IWSLCCompatSession`**. But the step that actually matters —
`Session.FromAbi(ptr)` — threw `InvalidCastException`, so the handoff **does not work as
written**, and the "small shape" below is not yet available.

The likely cause is that there are **three** layers, not the two this document has assumed
throughout:

| Layer | Where it lives | Interface |
| --- | --- | --- |
| service-internal COM | the WSL service | `IWSLCSessionManager`, `IWSLCSession` (`wslc.idl`) |
| compat COM | the WSL service | `IWSLCCompatSession` (`WSLCCompat.idl`) — **our QI hit this** |
| **WinRT** | **`wslcsdk.dll`, client-side** | what `Microsoft.WSL.Containers.Session` projects |

`wslcsdk.dll` exports `DllGetActivationFactory`, so its `Session` is an **in-process wrapper**
that *holds* compat COM proxies rather than being one — and `FromAbi` QIs for the WinRT default
interface, which a service-side pointer will never implement. The C# projection is a projection of
layer 3, not of layer 2, and nothing in the SDK converts a compat pointer into a WinRT wrapper
(`Session`'s only constructor takes `SessionSettings`).

The probe now settles this decisively without needing the WinRT IID: it compares **COM identity**
(the canonical `IUnknown` pointer) of the SDK-created `Session` against the one
`OpenSessionByName` returned, and separately asks whether the SDK's own object implements
`IWSLCCompatSession`. Different objects confirms the client-side-wrapper reading; the same object
means `FromAbi` failed for some other reason and the hypothesis is wrong.

**Sequence the arm in two tiers so M2 does not wait on this.** Tier 1 needs nothing that is not
already proven on hardware; only Tier 2 depends on the question above.

| Tier | What it does | Status |
| --- | --- | --- |
| **1 — recover** | On broker restart: `OpenSessionByName` the orphaned session, `ListContainers` for reporting, then `IWSLCSession::Terminate` and create a fresh session through the compat SDK. Turns "broker restart ⇒ dead sandbox needing a manual `wsl --shutdown`" into automatic clean recovery. Containers do not survive — but they do not survive today either, and `ContainerManager.reconcile` already handles an empty sandbox. | **DONE and CONFIRMED ON HARDWARE** (§13.3): killed a broker mid-session, the next one enumerated 5 containers, terminated the orphan and restarted clean. |
| **2 — adopt** | Keep containers *running* across a broker restart and re-attach to their stdio. | Blocked on the handoff above. If the identity test says "different objects", this needs exec/stdio driven entirely through internal COM (`IWSLCContainer::Exec`, `IWSLCProcess::GetStdHandle`) — handle-based rather than event-based, and the point at which the C++/WinRT shim earns its keep after all. |

**Tier 1 as built** (`Wslc/WslcInternal.cs`), and the four things in it that are not obvious:

1. **Entry point is the *compat* coclass.** `CoCreateInstance(WSLCCompatSessionManager,
   a9b7a1b9-0671-405c-95f1-e0612cb4ce8f)` asking directly for
   `IID_IWSLCSessionManager` — `wslc.idl` has no activatable class of its own.
2. **`CoInitializeSecurity(RPC_C_IMP_LEVEL_IMPERSONATE)` runs before the first COM call**, from
   `WslcFacade.Create()` rather than `Program.cs`, so the ordering constraint sits next to the
   code that depends on it. `Program.cs` calls `Create()`, never `new WslcFacade()` — the latter
   compiles fine and silently costs recovery. A per-proxy `CoSetProxyBlanket` backs it up, since
   the process-wide call is order-dependent and the per-proxy one is not.
3. **`ListContainers` is method #19 behind 18 placeholders.** Vtable slots are fixed by
   declaration order, so the four predecessors taking a by-value `WSLCHandle` union are declared
   as `IntPtr` and never marshalled. The interface carries a "do not reorder, do not delete an
   unused entry" warning, because either silently shifts every slot below it. Options are passed
   with `Flags = All`, or stopped containers vanish from the recovery report.
4. **Every failure degrades to "no recovery."** `TryBind` returning null is normal on a machine
   without WSL (`REGDB_E_CLASSNOTREG` is reported as the onboarding state, not a fault), and the
   facade then throws `session_exists` naming `wsl --shutdown` exactly as before. `recover` joins
   the `hello` capabilities only when the arm actually bound.

Restart is retried after `Terminate()` rather than reported as a failure: the service tears the VM
down asynchronously, so the next `Start()` can still see the old name for a moment.

Had the handoff worked, the arm would have taken this shape — kept here because Tier 2 revisits it:

```
CoInitializeSecurity(RPC_C_IMP_LEVEL_IMPERSONATE)          // brokerd startup, before ANY COM call
CoCreateInstance(WSLCCompatSessionManager) → QI IWSLCSessionManager
  OpenSessionByName(name)     → IWSLCSession → QI IWSLCCompatSession → Session.FromAbi()
  IWSLCSession::ListContainers                                      → the roster
  IWSLCSession::OpenContainer(id) → IWSLCContainer → QI IWSLCCompatContainer → Container.FromAbi()
```

Internal COM would be used **only to find things**, with everything then driven by the existing
compat code and nothing in `WslcFacade`'s operation paths changing. `FromAbi` is the load-bearing
step, and it is the one that failed.

Two implementation notes that survive regardless of tier, both cheap to get wrong:

- **Vtable slots are fixed by declaration ORDER, not by signature.** `ListContainers` is
  `IWSLCSession`'s 19th method, and the 18 ahead of it include four taking `WSLCHandle` — a
  tagged union — by value. None of that matters, because a method never called is never
  marshalled: declare the predecessors as `IntPtr` placeholders and give precise signatures only
  to `OpenContainer` and `ListContainers`. The probe already relies on this (it called
  `ListSessions` at slot 6 with placeholders ahead of it).
- **A successful QI is not a usable object.** `IWSLCCompatSession` QI'd fine and `FromAbi` still
  threw — the lesson being that these three layers share an object graph in ways that QI alone
  does not reveal. Any future "we can just project it" step needs the same end-to-end check
  (construct it, then *call* something on it) rather than stopping at the cast.

The container-level handoff (`IWSLCContainer` → `IWSLCCompatContainer`,
`8C3C91FA-D550-41B9-AD9D-23DCBF96F549`) was going to be the same trick one level down, and is now
expected to fail the same way. It is only worth re-testing if the identity probe overturns the
client-side-wrapper reading.

```powershell
dotnet run --project windows\spikes\WslcApiDump -- --session --keep --internal-call
```

Tear down afterwards with `wsl --shutdown`.

#### Why the arm was blocked before that run

D13 routes five capabilities to the service-internal `wslc.idl`, and reading that IDL directly (it
and `WSLCCompat.idl` are in the open-source WSL repo, and both were fetched and read for this
pass) turned up two obstacles it had assumed away:

- **There is no CLSID for `IWSLCSessionManager`.** `wslc.idl` declares interfaces and no
  activatable class, so `CoCreateInstance` had nothing to name. The only registered coclasses
  anywhere in the set belong to the compat surface and to the WSL service proper. **Resolved by
  the run above:** the compat coclass answers the QI.
- **`IWSLCVirtualMachine` is not reachable from a client** — only
  `IWSLCVirtualMachineFactory::CreateVirtualMachine` produces one, and the SYSTEM service owns the
  factory. **Confirmed by the run above**, so the control-plane row of D13's table has
  no route in the shipped IDL — it would take Microsoft exposing the VM object, not work on our
  side.

The other four rows (enumeration, stats, reattach, pty) were always correct in *what* they need
and sound in *where* it lives. Note that stats has since been served another way — the facade's
cgroup read — so the internal arm's remaining value is **enumeration, reattach and the Terminal
panel**.

**What to do next, in order.** The probe is written and checked in
(`windows/spikes/WslcApiDump/InternalComProbe.cs`):

```powershell
dotnet run --project windows\spikes\WslcApiDump -- --internal        # QI only — cannot crash
dotnet run --project windows\spikes\WslcApiDump -- --internal-call   # also calls through
```

> **The first run of this probe (2026-07-29) is VOID — do not treat its output as evidence.** It
> reported "IWSLCSessionManager is NOT reachable from any known coclass", and that conclusion was
> a bug in the probe, not a fact about WSL. It activated with `typeof(object).GUID` believing that
> to be `IID_IUnknown`; it is not (it is the CLR's type GUID for `System.Object`,
> `ff77e388-6558-35eb-89cb-5ef50f4b9be2`), so every class factory was asked for an interface that
> does not exist and correctly answered `E_NOINTERFACE` **at activation**. That reads exactly like
> a real capability answer, which is what made it dangerous. Fixed, plus the probe now shouts if
> it ever sees `E_NOINTERFACE` from an IUnknown activation — an impossible combination, since
> every COM object implements `IUnknown` — and falls back to activating each internal IID
> directly, because a class factory may legitimately refuse IUnknown-first activation.
> The one salvageable line from that run: `LxssUserSessionInBox` answered `REGDB_E_CLASSNOTREG`,
> which precedes any IID check, so that class really is unregistered on the dev box.

`--internal` activates each of the four registered coclasses in WSL's IDLs
(`WSLCCompatSessionManager`, its factory, `LxssUserSession`, `LxssUserSessionInBox`) and QIs each
for `IWSLCSessionManager`, `IWSLCSession` and `IWSLCVirtualMachine`. It never calls a method, so a
vtable mismatch cannot take it down, and QI alone answers the entry-point question.
`--internal-call` then calls `GetVersion` (slot 3) and `ListSessions` to confirm the vtable really
is the IDL's — that one *can* crash, which is itself the finding, and is why it is opt-in.

**Both have now been run** — see the answer above. `--internal` said yes (via
`WSLCCompatSessionManager`); `--internal-call` is the remaining step, and it must precede any
interop, because a QI proves the IID is recognised and says nothing about the method layout.

### 13.3 First execution of the container subsystem (2026-07-29)

`windows/spikes/WslcSpike` driving `nucleic-brokerd` against the live wslc service on Windows 11
amd64 / WSL 2.9.4. Everything in §3 had compiled and **none of it had ever run**; this is the
first evidence any of it works. Run over ssh from the dev Mac to the Windows box, with the broker
built from a staged copy (the files were not yet on `origin/dev`).

**Confirmed working, in order:**

| Step | Result |
| --- | --- |
| `hello` | `brokerd 1.0.0, wslc 2.9.4`; capabilities `components, session, image, container, proc, stats, recover` |
| **D13 Tier 1 bound** | `recover` is present — `WSLCCompatSessionManager` → QI `IWSLCSessionManager` works from the *broker*, not just the spike, and `CoInitializeSecurity` ran early enough |
| `components.missing` | empty |
| `session.ensure` | started a session and **resolved the §5 gateway to `172.30.16.1`** |
| `image.pull` | `docker.io/library/alpine:latest` in 4.5s, with progress phases `Pulling → Downloading → Verifying → Extracting → Complete` arriving as notifications |
| `container.create` | succeeded with an NTFS `ContainerVolume` and `NetworkingMode = Bridged` |

**§5's gateway question is answered: `172.30.16.1`.** No wslc API surfaces this — the facade
derives it from the WSL vEthernet adapter — and the whole control plane depends on it. It is a
real, private, non-loopback address, which is what the gateway-TCP transport needs. What remains
for M1 (b) was only whether a `Bridged` container could *reach* the host there under the default
firewall — and it can (below).

**The error-translation design is validated by an unhappy path.** A failed `proc.exec` came back
as `kind=not_running` even though the COM message was the useless *"The text associated with this
error code could not be found."* — because `Translate` keys on the HRESULT number and never on
the message text. That was a deliberate choice in §13.2; this is the run that shows it mattering.

**One bug, in the spike rather than the port:** the run stopped at the first `exec` with
`not_running`, because alpine's PID 1 is `/bin/sh`, which exits immediately without a tty — so the
container was `Exited` before the exec, and `container.state` had already said `stopped`. narOS
does not have this problem (`naros-init` is PID 1 and stays up, `docs/NAROS.md`). Fixed two ways:
a `--sleep-init` flag that supplies a long-running init, and a state check immediately after
`container.start` that fails with that explanation — because "PID 1 exited" and "the container
failed to start" look identical and have nothing in common as remedies.

**The rest of the path now works too**, after the three bugs below were fixed. Confirmed on
hardware: `exec` with stdout **and** stderr captured and the `HostName` setting honoured
(`hello from nucleic-spike`); the NTFS bind mount readable at `/work` with the repo's real files
and `.git` present; `container.stats` returning live cgroup counters
(`cpuUsageUsec: 32322679, memoryUsedBytes: 34697216, memoryLimitBytes: -1, oomKills: 0` — note
`-1` is the "unlimited" sentinel working as designed); `container.stop` + `container.delete`.

**D13 Tier 1 recovery is CONFIRMED END TO END.** The spike killed the broker with a session up,
started a fresh one, and it recovered:

```
wslc: recovered orphaned session 'nucleic-spike' — 5 container(s)
      [nucleic-spike-c6, c5, c4, c2, c1], terminated=True
wslc: session 'nucleic-spike' restarted after recovery
RECOVERED — fresh session up, gateway 172.30.16.1
```

Three things that proves at once: `IWSLCSessionManager::OpenSessionByName` works from the broker;
`IWSLCSession::ListContainers` works **and returned real container names**, so the hand-written
`WSLCContainerEntry` marshalling — inline `char[256]` buffers behind a `size_is(, *Count)` double
pointer — is correct; and `Terminate` + restart-with-retry clears the orphan. The whole
hand-written `ComImport` approach is validated on live data rather than by a QI alone.

It also incidentally cleaned up the five stale containers earlier runs had leaked, which is the
same "roster is process-local" gap in a different costume.

#### Three bugs the run found, all now fixed

1. **`proc.exec` responded after starting the process.** A command that finishes instantly
   (`echo`) put `proc.stdout`/`proc.exit` on the ordered outbound queue *ahead* of the response
   carrying the `procId` that names them, so a client waiting for that exit waited forever. Fixed
   by enqueueing the response before `IWslcProcess.StartAsync` — the same reason wslc itself splits
   `CreateProcess` from `Start`, applied at the RPC boundary. **The mirror-image hazard was then
   confirmed and fixed in `WslcBrokerClient.swift`** (see item 6): the Swift client had the same
   hang for the same reason, because completing the response's continuation only *schedules* it
   while the notification drain is a separate task. It now buffers events for procIds it has not
   yet learned. The C# spike, being single-flight, takes the simpler route of accepting any procId.
2. **That fix introduced a double response.** When `StartAsync` then failed, the generic handler
   emitted a JSON-RPC *error* under an id that had already been answered — two responses for one
   id. Now a post-response start failure is reported the way the process itself would: the reason
   on `proc.stderr`, then `proc.exit` with **126** (the shell's "found but not executable").
3. **`ERROR_ALREADY_EXISTS` was mapped to `session_exists` unconditionally.** wslc returns that
   code for a *container* name conflict too, so a stale container reported itself as a stuck
   session — wrong diagnosis, wrong remedy (`wsl --shutdown` instead of removing one container).
   Split out as `WslcError.AlreadyExists`; the session paths catch the code by number before
   reaching `Translate`, so anything arriving there is the container kind.

#### `setpriv` does not exist on BusyBox, and the facade now refuses rather than escalating

The uid-drop wrapper assumed util-linux. BusyBox ships a `setpriv` that supports only capability
flags — no `--reuid`/`--regid` at all — so on an alpine-based image the wrapper failed with
`unrecognized option: reuid=501`. narOS is unaffected (`naros-tier-agent` depends on
`util-linux-extra`), but a user-supplied image would have been.

The facade now probes once per container (`setpriv --reuid=0 --regid=0 --init-groups -- true`,
cached on the roster entry) and **refuses the exec** with `kind=unsupported` when it cannot drop
privileges. Probing for the *binary* is not enough — BusyBox has one, it just cannot do this. The
refusal is deliberate: the alternative is running the agent as **root** in the one place the
sandbox's user separation is enforced, which is a silent privilege escalation, not a degradation.

#### The real naros-agent image works, and needs no auth

`ghcr.io/abkslm/naros-agent:26.07` pulls **publicly** (76 s, no credentials) and resolves on
amd64, so the `--registry-token` plumbing is not needed and `Session.Authenticate` stays untested.
On that image:

- **`naros-init` runs as PID 1** and the container stays up. The image carries no default CMD —
  wslc answers `no command specified` without one — which is exactly why both engines name init
  explicitly (`WslcContainerEngine` passes `initArgv: ["/usr/sbin/naros-init"]` and sets
  `NAROS_BRIDGE=1`).
- **The `setpriv` uid drop WORKS**: `id -u; id -g` inside the container returned **501/501**. The
  §3.2 fallback for wslc's missing `ProcessSettings.UserId` is confirmed on the supported image.
- **The control plane is reachable from naros** using `node` as the client — which is the real one,
  since `control-bridge.js` is node. The host saw `PING` and the guest read the response back.

**One divergence from the macOS engine, not yet closed:** `ContainerEngine` *probes* for
`/usr/sbin/naros-init` and falls back to `sleep infinity` plus a supervised `node control-bridge.js`
for non-narOS images (legacy v7, custom). `WslcContainerEngine` hardcodes the naros path, so a
custom image without it fails to start. Deferred, not excluded (§9.4a): it needs the same
`/bin/sh -c "if [ -x … ]"` shape, and nothing before custom-image support depends on it.

**Still not exercised:** `proc.signal`, `proc.stdin`, and anything tty (`unsupported` for now).

#### M1 (b) ANSWERED: the guest reaches the host at the gateway — M2 is unblocked

```
==> §5 control plane: can the guest reach the host at the gateway?
    host listening on 172.30.16.1:52668 (interface-scoped, not 0.0.0.0)
    guest: printf 'GET / HTTP/1.0\r\n\r\n' | nc -w 5 172.30.16.1 52668
    REACHABLE — the guest completed a TCP round trip to the host.
    host saw: GET / HTTP/1.0
```

The listener was bound to the WSL-facing address **only**, never `0.0.0.0` — the posture §5
requires — and a container opened a TCP connection to it and got a response back, under the
machine's **default** firewall policy with no rule added. That is the whole premise of §5's
primary transport, and every agent session rides it: MCP approvals, the git/gh interceptor
endpoints, nash's shell reports.

Consequences:

- **§5's gateway-TCP transport is validated end to end**, so the ordering restored in §13.1's
  retraction is not merely the fallback — it is a working design.
- **The AF_HYPERV path is not needed.** Which is fortunate, because §13.2 found no client route to
  the VM GUID it would require. Had this test failed, the port would have needed a VMID from
  outside wslc entirely (HCS enumeration) before an agent could run at all.
- **§5 step 5's firewall remediation stays as onboarding insurance**, not a prerequisite. One
  machine passing is not every machine passing — enterprise policy is exactly the variance §5
  flags — so the connectivity self-test and its `New-NetFirewallRule` remedy still earn their
  place, now with a known-good baseline to compare against.

What is still untested above the transport: an actual HTTP round trip through
`control-bridge.js` to `MCPApprovalServer` with a bearer token, which is M2's own gate rather than
M1's.

#### 9P costs ~20x, not ~170x — D8 survives, with a caveat on writes

**The first pass on this overstated the problem by an order of magnitude, and the corrected numbers
are below.** It measured `find . -type f` on an alpine container, giving 164x and then 177x on a
re-run, and reported that as the 9P cost. Two things were wrong with it: `find` walks *everything*
(titan's checkout includes `.build`, so 29,836 files against the 1,945 `git status` actually stats)
and it has no index, so it pays a per-file lstat that git avoids. The stated caveat — "the ratio is
the robust finding" — was itself wrong: the *ratio* was the inflated part.

Re-measured on the real **naros-agent** image with real commands, median of 5:

| Operation | `/work` (NTFS via `ContainerVolume`) | `/tmp/ext4` (container-local) | ratio |
| --- | --- | --- | --- |
| `git status --porcelain` | **1,510 ms** (1,471–1,563) | **65 ms** | **23x** |
| write 2,000 small files (the `npm install` shape) | **3,864 ms** | **253 ms** | **15x** |

So the honest figure is **~15–23x, and ~1.5 s per `git status`** — a visible pause on every
operation rather than the 40-second stall the proxy implied. Writes cost ~1.9 ms per file across
the mount, which extrapolates to roughly a minute for a 30,000-file `npm install` against ~4 s
locally. Copying the tree in (`cp -a /work /tmp/ext4`) took 153 s, consistent with that.

**D8 stands.** §15 says surface this rather than silently relocating repos, so it is recorded, not
acted on — but the reading has changed from "possibly untenable" to "workable with the mitigation
§15 already anticipated":

- **`node_modules`, build output and other write-hot directories belong on a
  `ContainerNamedVolume`.** That is where the 15x write cost concentrates and where it hurts most;
  moving them off the mount is the single highest-value change and does not disturb D8.
- **The working tree stays on NTFS.** `git status` at 1.5 s is a pause, not a blocker, and keeping
  it host-visible is what lets `GitRunner`/`WorktreeManager` work unchanged — which is D8's whole
  point. `.git` cannot move for the same reason.
- **Moving the clone into the session's ext4** (host access over `\\wsl$`) stays available if
  dogfooding shows 1.5 s is intolerable, but it inverts D8's premise and is no longer indicated by
  the numbers.
- **Still unmeasured:** a real `npm install`, and a repo substantially larger than this one. The
  write benchmark is a proxy, if a much closer one than `find` was.

The lesson worth keeping: a proxy metric chosen for portability (`find`, so it ran on any image)
produced a number that would have driven a wrong decision about a locked one. The re-measure on the
actual image was the right call.

No wslc knob exists to soften this: `ContainerVolume` carries only `(windowsPath, containerPath,
readOnly)` — no cache mode, no metadata option.

---

## 14. Verification strategy

### 14.0 There is no Windows GitHub CI — the gate is `windows/build.ps1` (decision, 2026-07-29)

`.github/workflows/windows.yml` was authored (item 14), never enabled, and has now been
**deleted**. Do not recreate it without asking. The reasons it is off:

- Hosted Windows minutes bill at **2x** and the workflow fanned out to five jobs — §14.1 called
  this "the leg that empties the budget" before a single run had happened.
- The self-hosted alternative (D11) is a machine to own, patch and trust; the same box is far
  more useful running `build.ps1` interactively, where a failure is inspected in seconds rather
  than in a log tail.
- The Windows legs are not, today, guarding against unattended drift from many contributors.
  They guard one port under active development, on one machine, by whoever is doing the porting.

**The gate is therefore `windows/build.ps1` on the dev box**, run before landing anything that
touches the Windows legs:

```powershell
./windows/build.ps1 -Target core -Test      # every declared target + NucleicCarbonTests
./windows/build.ps1 -Target protocol -Test  # the wire layer under the protocol-only manifest
./windows/build.ps1 -Target broker          # the C# contract tests
```

It exits non-zero on any failing leg — nothing is excused. That matters more than it sounds:
the script used to report a failing `core -Test` as "FAIL (expected — items 10+ gap list)" and
exit 0, and a real `git`-spawn regression read as a known gap for a day because of it.

SwiftPM 6.3.3 has two clean-test index-store failures on Windows: disabling the store still
schedules test-discovery units and later fails opening files it never emitted; enabling it with
parallel frontend jobs races on shared SDK/module unit files and reports `permission denied`.
`build.ps1 -Test` therefore keeps indexing enabled and uses `-j 1`; ordinary build legs remain
parallel. This is slower on a cold checkout, but it makes the documented gate deterministic.

The macOS suite, which *does* run unattended, remains the regression gate for everything
cross-platform; the Darwin-first refactors (SecretStore, SandboxEngine, `SecretFile`,
`NucleicPaths`, `resolvedSpawnTarget`) are deliberately shaped so most Windows work is verifiable
there first. §14.1 below is kept as the design record for whenever a runner becomes worth it.

### 14.1-and-below: the original CI plan (retained, not in force)

- **Every CI run (from M0):** `NucleicProtocolTests` + `NucleicCarbonTests` on Windows
  x64 + ARM64; the full macOS suite must stay green through the Darwin-first
  SandboxEngine/SecretStore refactors; DLL golden fixtures from Swift and C#.
- **Broker:** C# unit tests against a fake `IWslc`; Swift `WslcBrokerClient` tests against
  a scripted fake broker exe (mirrors the `fake-claude` adapter-contract pattern).
- **M2 gate (self-hosted runner):** `nucleic-smoke` e2e — full Claude session with
  approval + git-interceptor assertions from the transcript; hostd kill → restart →
  reconcile test; container idle-teardown test.
- **M3 gate:** transcript-parity harness (recorded HostMsg replay, Swift vs. DLL
  projection JSON diff); renderer store unit tests over fixture event streams.
- **M4/M5 manual matrix:** iPhone QR pairing over LAN / relay-only / tailnet; MSIX
  install → update → rollback across channels on clean x64 + ARM64 VMs with default
  firewall; accessibility spot-checks (high-contrast, palettes, text sizes); a longpath +
  LF-sensitive fixture repo through a full session + Autoship.

### 14.1 On-prem Windows runner (D11)

Hosted Windows minutes bill at **2x** and this workflow fans out to five Windows jobs, so
this is the leg that empties the budget; self-hosted runners are never billed. Every
`runs-on` in `windows.yml` reads a repository variable that falls back to the hosted label,
so switching is a settings change rather than a PR — and reverting is instant.

**1. Prepare the machine** (Windows 11 or Server 2025, x64; the Swift toolchain plus the
BoringSSL/GRDB C++ builds want ≥4 cores, 16 GB RAM, ~60 GB free):

- **Visual Studio Build Tools** with *Desktop development with C++* and the Windows 11 SDK —
  this supplies the MSVC STL and SDK headers Swift's clang compiles against. The MSVC↔clang
  floor from §13 applies here too: VS 2022 (14.4x) and VS 2026 (14.5x) both work with the
  pinned Swift 6.3.x (clang 21); only Swift 6.2.x-and-older trip STL1000.
- **Git for Windows**, then `git config --system core.longpaths true` — SwiftPM checkouts
  nest past MAX_PATH. (The workflow sets `--global` for the service account; `--system` as
  well saves surprises when you reproduce a failure by hand.)
- **.NET 9 SDK** for the broker leg — otherwise `actions/setup-dotnet` re-downloads it
  every run, which works but is pure wall-clock.
- Nothing else. The Swift toolchain is installed per run by `gha-setup-swift` from the
  `windows/props/swift-version.txt` pin, and vcpkg is cloned into `RUNNER_TOOL_CACHE` on
  first use (the hosted images preinstall it; a bare box does not).

**2. Register the runner.** Settings → Actions → Runners → New self-hosted runner (Windows
x64), then run the generated `config.cmd` — its token is single-use. Give it a unique label
and install it as a service so it survives reboot:

```
./config.cmd --url https://github.com/abkslm/nucleic --token <TOKEN> \
             --labels nucleic-win-x64 --runasservice
```

**3. Point the workflow at it.** Settings → Secrets and variables → Actions → Variables:

| Variable | Value | Effect |
| --- | --- | --- |
| `WINDOWS_X64_RUNNER` | `nucleic-win-x64` | x64 `protocol` + `core` + `broker` legs go on-prem |
| `WINDOWS_ARM64_RUNNER` | *(leave unset)* | ARM64 legs stay on hosted `windows-11-arm` |

Use the unique label, NOT the generic `self-hosted`. Deleting a variable sends that leg
straight back to the hosted runner — the escape hatch when the box is down.

**Caveats.**
- **One box serialises.** The five Windows jobs queue behind a single runner. Register two
  or three runner instances from separate directories on the same machine if that hurts.
- **A self-hosted runner is not sandboxed.** Workflow code runs as the service account with
  the machine's credentials, in a workspace that persists between jobs. That is acceptable
  only because this repo is private, so no fork PR can execute on it. Never point a public
  repo at this machine.
- **ARM64 still bills.** With one x64 box the two ARM64 legs stay hosted. If minutes are
  still tight, gate those to `push` + `workflow_dispatch` instead of every PR.
- **No incremental builds by default.** `actions/checkout` runs `git clean -ffdx`, wiping
  `.build/` each run. Setting `clean: false` on the on-prem legs buys a large speedup at
  the cost of stale-artifact risk — worth it only once the leg is otherwise stable.

### 14.1.1 Temporary dependency pins (revert when upstream lands)

Two Windows blockers live in SwiftNIO rather than in this repo. Both are pinned in
`Package.swift`; check them off as upstream catches up.

| Pin | Why | Revert when |
| --- | --- | --- |
| `swift-nio` floor raised to `2.101.3` | 2.101.2's `NIOPosix` doesn't compile on Windows: `HANDLE`'s `Sendable` conformance is unavailable, and `CInt(IPPROTO_UDP)` fails now that WinSDK imports `IPPROTO` as an enum (apple/swift-nio#3433). | Never — this is a released version; the floor just records the requirement. |
| `swift-nio-ssl` → `Joannis/swift-nio-ssl` @ `bc3bd900` | NIOSSL's **Swift** layer has no Windows support upstream, not even on `main` as of 2.37.2 — only the C layer was fixed (#585). PR apple/swift-nio-ssl#567 ports it. | apple/swift-nio-ssl#567 merges → restore `.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0")` and re-resolve. |

While the NIOSSL fork pin is in place, `swift build` prints four **"Conflicting identity for
swift-nio-ssl"** warnings — `third_party/containerization`, `async-http-client`,
`swift-nio-extras` and `grpc-swift-nio-transport` all reach the upstream URL. The root
package's URL wins and the build is correct, but SwiftPM warns this "will be escalated to an
error in future versions", so this pin is not a resting place.

**The BoringSSL duplicate symbol, and `/FORCE:MULTIPLE`.** Every Windows link pulls in two
vendored BoringSSLs — swift-crypto's `CCryptoBoringSSL` (the Noise crypto, since Windows has
no CryptoKit) and swift-nio-ssl's `CNIOBoringSSL` (the relay WebSocket's TLS) — and they
collide on one symbol:

```
lld-link: error: duplicate symbol: p_thread_callback_boringssl
>>> defined at swift-crypto/.../crypto/thread_win.cc:157
>>> defined at swift-nio-ssl/.../crypto/thread_win.cc:159
```

Both copies prefix their symbols (`CCryptoBoringSSL_*` / `CNIOBoringSSL_*`), which is why the
identical pair links clean into `nucleicd` on Linux. The tell is *which* file collides:
`thread_win.cc` compiles only on Windows, and BoringSSL's prefix maps
(`boringssl_prefix_symbols.h`) are generated by building on a POSIX host — so symbols defined
in Windows-only translation units are never in the map, and stay unprefixed in both copies.

Until one copy carries the prefix, every Windows link passes `-Xlinker /FORCE:MULTIPLE`:
`windows/build.ps1`'s `Invoke-Swift`, both `swift test` steps in `windows.yml`, and any bare
`swift build --product …` you run by hand. Two things to know about that flag: it is global,
so it downgrades *any* future duplicate to a warning (the unprefixed-Windows-symbol theory
predicts more as the graph grows — read the warnings); and it resolves this collision by
dropping one copy's `.CRT$XLC` TLS callback, so whichever library loses never registers its
per-thread destructor and leaks that copy's thread-local BoringSSL state (error queue, RNG
buffers) per thread. Each destructor is file-local, so neither can run against the other's
slots.

The real fix is to prefix the symbol in one copy — plausibly a commit on the swift-nio-ssl
fork already pinned above, since that pin has to be revisited anyway. Note it is more than a
`#define`: `thread_win.cc` also names the symbol inside
`#pragma comment(linker, "/include:p_thread_callback_boringssl")`, and a string literal does
not macro-expand, so a rename that misses the pragma turns the duplicate into an undefined
symbol. Worth filing upstream against both packages — anything linking swift-crypto and
swift-nio-ssl together on Windows hits this.

### 14.2 SQLite on Windows, and building locally

Windows has no system SQLite. GRDB's `GRDBSQLite` is a `.systemLibrary` whose only declared
provider is `apt(["libsqlite3-dev"])`, and a systemLibrary resolves through C header and
library **search paths**, not the SwiftPM module graph — so no change to `Package.swift` can
satisfy it, and `NucleicCore` cannot build until SQLite exists on disk and is discoverable.

`windows/build.ps1` does that: it installs `sqlite3` once via vcpkg and runs the requested leg.
It finds an existing vcpkg via `-VcpkgRoot`, `%VCPKG_INSTALLATION_ROOT%`, the
`%LOCALAPPDATA%\vcpkg\vcpkg.path.txt` breadcrumb, `%LOCALAPPDATA%\Programs\vcpkg`,
`%USERPROFILE%\vcpkg` or `C:\vcpkg`, and clones one into `%LOCALAPPDATA%\Programs\vcpkg` if the
machine has none. Note that `%LOCALAPPDATA%\vcpkg` itself is vcpkg's per-user *data* directory
(registries, downloads, that breadcrumb), not an install root — it is only ever read.

```powershell
./windows/build.ps1 -Target protocol -Test    # the required leg
./windows/build.ps1 -Target core -Test         # full manifest + NucleicCarbonTests
./windows/build.ps1 -Target broker            # C# contract tests, no Swift
./windows/build.ps1 -Target core -Persist     # + make plain `swift build` work forever
```

Two mechanisms, and the choice between them is not stylistic:

- **`CPATH` for headers.** clang *appends* it to the header search path and still
  auto-detects the MSVC and Windows SDK headers. `-Persist` writes it to your user
  environment, after which a bare `swift build --target NucleicCore` resolves `sqlite3.h`
  with no flags, from any shell or editor. Do **not** use `%INCLUDE%` for this: clang stops
  auto-detecting the whole platform header set as soon as `%INCLUDE%` names one existing
  directory (`clang/lib/Driver/ToolChains/MSVC.cpp`, `if (Found) return;`).
- **`-Xlinker /LIBPATH:` for `sqlite3.lib`,** passed per invocation. `%LIB%` carries the
  *same* trap in the other direction — clang omits its own `-libpath:` for the VC, UCRT and
  Windows SDK directories the moment `%LIB%` is set (same file, `if (!GetEnv("LIB") || ...)`),
  so a `%LIB%` holding only SQLite silently strips the entire platform link path. Only
  linking needs it (tests and executables); a library build resolves purely through `CPATH`.

  **Spell it `/LIBPATH:`, never `-LIBPATH:`.** The dash form is swallowed by the driver's
  GNU-style `-L` flag: `-LIBPATH:C:\…\lib` is read as `-L` with the value `IBPATH:C:\…\lib`
  and re-emitted to the linker as `-libpath:IBPATH:C:\…\lib`. Nothing warns — the only symptom
  is `lld-link: error: could not open 'sqlite3.lib'`, which reads exactly like a missing
  library, and `-v` shows the mangled path only if you look at the linker's own argument list
  (the `-out:…` line, not the clang line). MSVC-style flags whose first letter isn't a driver
  option — `/FORCE:MULTIPLE` — survive either way, which makes the failure look selective.

The `-static-md` triplet is deliberate: SQLite links statically against the *dynamic* CRT,
which is what the Swift toolchain uses, so the CRT agrees and no `sqlite3.dll` has to be
staged beside every test binary. CI does the same thing with explicit flags rather than
`CPATH`, so hosted runners stay hermetic (`.github/workflows/windows.yml`).

The triplet is ours, not vcpkg's: `windows/vcpkg-triplets/<arch>-windows-static-md-nucleic.cmake`,
passed with `--overlay-triplets`. It is the stock `-static-md` triplet plus
**`SQLITE_ENABLE_SNAPSHOT`**. GRDB's system-SQLite path compiles `WALSnapshot.swift` and
`DatabaseSnapshotPool.swift` unconditionally — it assumes the platform SQLite exposes
`sqlite3_snapshot_*`, which holds for Apple's system SQLite and typical distro builds but not
for a stock vcpkg port, where the API is compiled out. Without the define the library resolves
and links, then fails with four undefined symbols (`sqlite3_snapshot_open/get/free/cmp`) out of
GRDB — a failure that looks nothing like a missing feature flag. The `-nucleic` suffix keeps
this tree from colliding with a stock `<arch>-windows-static-md` install in the same vcpkg root.

### 14.3 Tailnet on Windows: inert by construction, not stubbed

`NucleicTailnet` compiles on Windows and does nothing there, and that needs no Windows-specific
code. The module was already built to work without its binary artifact: TailscaleKit is a
locally built **Apple** xcframework (`scripts/build-tailscalekit.sh`), so every reference to it
sits behind `#if canImport(TailscaleKit)`. On Windows that is false, which means
`TailnetSupport.isBuiltIn` reports false and every entry point throws `TailnetError.notBuiltIn`
— the same path a Mac takes when the artifact hasn't been built.

So the audit answer is: **don't add a Windows stub.** `TailnetNode` and `TailnetListener` have
no unguarded platform code at all (their `poll`/`errno`/`close` calls are all inside the
TailscaleKit guards; the `socketpair` in `TailnetListener` is only in a doc comment). The one
file that genuinely needed Windows work was `FDFrameChannel`, which lives *outside* those
guards on purpose — it wraps any connected fd — and needed two things:

- `closeDescriptor` gained an `os(Windows)` branch calling the CRT's `_close`.
- The `DispatchIO` fd argument goes through `numericCast`: `dispatch_fd_t` is `Int32` on POSIX
  but `Int` on Windows, and the typealias is not in scope on Linux, so it can't be named.

Windows Tailscale does not integrate as an in-process tsnet node, so a future Windows tailnet
backend should implement against this module's existing `isBuiltIn` / `notBuiltIn` surface
rather than porting the libtailscale fd plumbing.

---

## 15. Risks & mitigations

| Risk | Mitigation |
|---|---|
| **wslc preview churn** (API breaks before GA) | All wslc calls isolated in brokerd behind our own RPC; NuGet version-pinned per release; broker `capabilities` hello lets hostd degrade; M1 files gaps early while Microsoft is taking preview feedback. |
| **Internal COM ABI breaks** (D13 — `wslc.idl` states outright that breaking changes are fine, since Microsoft ships both ends together and we are not both ends) | The blast radius is one class: `WslcFacade`, behind `IWslc`. The broker probes the internal interface at bind time and reports the result in its `capabilities` hello, so a break costs reattach, stats and the Terminal panel — not the sandbox. WSL version pinned per release alongside the NuGet, and `WslcApiDump` hashes the pinned `wslc.idl`/`WSLCCompat.idl` so drift is caught by a spike run rather than a user. |
| **Swift-Windows toolchain gaps** (corelibs Foundation holes, DLL export quirks, NIO/GRDB linkage, `FileHandle.bytes`-class issues) | M0 front-loads discovery; the Linux build's known-gap map is the checklist; CMake fallback documented for the DLL; a `WindowsFoundationCompat.swift` for point fixes. |
| **9P NTFS bind-mount performance** (git/npm in mounted worktrees) — **MEASURED on naros: ~15–23x, D8 survives** | §13.3: `git status` **1,510 ms** on the mount vs **65 ms** local (23x); writing 2,000 small files **3,864 ms** vs **253 ms** (15x). An earlier `find`-based proxy said 164–177x and was inflated ~10x — see §13.3 for why. No wslc knob softens it (`ContainerVolume` has no cache or metadata option), so the mitigation is placement: **write-hot dirs (`node_modules`, build output) on a `ContainerNamedVolume`**, working tree stays on NTFS per D8 since `.git` must remain host-visible for `GitRunner`. Still unmeasured: a real `npm install`, and a much larger repo. |
| **hvsocket-in-container reachability unknown** | Gateway-TCP is primary precisely because it's provable in M1; hvsocket is upside, not a dependency. |
| **Firewall / networking-mode variance** (DNS tunneling, enterprise policy) | **Baseline measured (§13.3): reachable on a default-policy Windows 11 box with no rule added**, via an interface-scoped bind to the WSL gateway. That is one machine, not every machine — enterprise policy remains the variance — so the onboarding connectivity self-test through `probeControlPlane` and its `New-NetFirewallRule` remedy stay, now with a known-good baseline to compare against. Note `ContainerNetworkingMode` is `None \| Bridged`, so the NAT/mirrored matrix this row once assumed does not exist. |
| **Published docs disagreeing with the shipped package** (Microsoft Learn's C# sample uses `MemoryMB`, `CmdLine`, `DeleteContainerFlags`; none exist in 2.9.3) | Read the metadata, never the docs: `windows/spikes/WslcApiDump` dumps the real surface and checks every member the facade calls. Run it after any package bump — that is now its whole job. §1.5 records the measured model, not the documented one. |
| **DLL↔C# JSON drift** | Golden fixtures in `fixtures/protocol-abi/` run from both languages on every CI run; DTOs reviewed against `WireMessages.swift` on protocol version bumps. |
| **App Installer UX limits** (no delta updates, prompt fatigue) | In-app update check reads the same feed; keep MSIX payload lean (Swift runtime + MinGit are the big items); winget post-beta. |
| **ARM64 tail risks** (Go c-shared for windows/arm64, wslc-on-ARM less trodden) | ARM64 is in CI from M0 so breakage is visible immediately; tailnet DLL is M5 scope. |
| **CRLF/longpath git edge cases** | MinGit pinned + `core.longpaths`/`core.autocrlf` enforced on managed clones; e2e includes a longpath + LF-sensitive fixture repo. |

---

## 16. Key file index (for implementing agents)

| Purpose | Path |
|---|---|
| Manifest gating to extend | `Package.swift` (`:59-70`, `:219`, `:266`, `:352-381`, `:402`) |
| Stub/shim template | `Sources/NucleicCore/LinuxSupport.swift` |
| Headless bring-up to factor | `Sources/nucleicd/Nucleicd.swift` |
| Container policy (port contract) | `Sources/NucleicCore/Container/ContainerManager.swift` |
| Engine surface to protocolize | `Sources/NucleicCore/Container/ContainerEngine.swift` (`:513,:660,:697,:727-:924`) |
| Process handle pattern to mirror | `Sources/NucleicCore/Container/ContainerizedProcessHandle.swift`, `Sources/NucleicCore/ProcessHost.swift` |
| Data root / owner-only files (shared seams) | `Sources/NucleicCore/{NucleicPaths,SecretFile}.swift` |
| Windows platform shims | `Sources/NucleicCore/Windows/` (see §4.3) |
| ContainerSpec + constants | `Sources/NucleicCore/Backend.swift:54` |
| Spec construction | `Sources/NucleicCore/SessionController.swift:643`; `Sources/NucleicCore/Project.swift:77` |
| Control plane server + ByteConn seam | `Sources/NucleicCore/Claude/MCPApprovalServer.swift`; `Sources/NucleicCore/Claude/ApprovalServerRegistry.swift` |
| Guest bridge | `containers/nucleic-sandbox/control-bridge.js` |
| Interceptors + hook env | `Sources/NucleicCore/Container/ContainerEngine+Rootfs.swift:394,:466`; `Sources/NucleicCore/Container/CommandInterceptor.swift` |
| Backend run loop (container exec path) | `Sources/NucleicCore/Claude/ClaudeCodeBackend.swift:766-1087` |
| Sync authority + bridge | `Sources/NucleicCore/Sync/SyncHost.swift`, `ConnectionHandler.swift`, `SyncHostBridge.swift`; `Sources/NucleicCore/AppStore.swift` (conforms; wires SyncHost ~`:9410`) |
| Wire protocol + client | `Sources/NucleicProtocol/Sync/{MessageEnvelope,WireMessages,SyncClient}.swift`, `CBOR/`, `Noise/`, `FrameChannel` |
| Renderer template | `ios/NucleicRemote/NucleicRemote/Models/{RemoteStore,HostConnection}.swift` |
| Transcript projections (shared) | `ios/NucleicRemote/.../Views/Transcript/{TranscriptProjection,IncrementalTranscriptProjection}.swift` (SPM target `NucleicRemoteProjection`) |
| Git layer | `Sources/NucleicCore/Git/{GitRunner,WorktreeManager,GitHubCredentials}.swift` |
| Persistence | `Sources/NucleicCore/Persistence/GRDBMetadataStore.swift` |
| Intelligence seam | `Sources/NucleicCore/Intelligence.swift`, `AFMRequestQueue.swift` |
| wslc API — measured surface + findings | `docs/WINDOWS_PORT.md` §1.5, §13.1; `windows/spikes/WslcApiDump/FacadeAssumptions.cs` |
| wslc COM interfaces (open source) | `microsoft/WSL`: `src/windows/service/inc/WSLCCompat.idl` (stable, SDK-facing) and `src/windows/service/inc/wslc.idl` (internal, what the CLI uses) |
| Contracts docs | `docs/VSOCK_CONTROL_PLANE.md`, `docs/CONTAINER_ISOLATION.md`, `docs/NAROS.md`, `docs/NASH.md`, `docs/RUNTIME_ARCHITECTURE.md`, `docs/COVALENCE_RUNNER.md` |
| Release pipeline references | `scripts/package-app.sh`, `scripts/release-macos.sh`, `cloud/nucleic-updates`, `.github/workflows/{naros,sandbox-image}.yml` |

External references: [WSL container overview](https://learn.microsoft.com/en-us/windows/wsl/wsl-container) ·
[wslc API reference](https://wsl.dev/api-reference/) · [wslc samples](https://aka.ms/wslc-samples) ·
[Swift Windows install](https://www.swift.org/install/windows/) ·
[GRDB releases (Windows since 7.10)](https://github.com/groue/GRDB.swift/releases)
