diff --git a/docs/plans/2026-07-27-001-feat-windows-toolchain-ci-com-bootstrap-plan.md b/docs/plans/2026-07-27-001-feat-windows-toolchain-ci-com-bootstrap-plan.md new file mode 100644 index 0000000..d40d4cc --- /dev/null +++ b/docs/plans/2026-07-27-001-feat-windows-toolchain-ci-com-bootstrap-plan.md @@ -0,0 +1,423 @@ +--- +title: Windows Toolchain, CI & COM Bootstrap (Sub-phase 2.1) - Plan +type: feat +date: 2026-07-27 +origin: docs/phases.md +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: docs/phases.md §Phase 2 sub-phase 2.1 +execution: code +--- + +# Windows Toolchain, CI & COM Bootstrap (Sub-phase 2.1) - Plan + +## Goal Capsule + +- **Objective:** Stand up the Windows build, CI, and session substrate so every later sub-phase lands on green CI and a constructible `WindowsAdapter` — and rebuild the private-file layer that v0.5.0 shipped wrong, this time against measured evidence and behind a seam that actually exists. +- **Authority hierarchy:** `docs/phases.md` §2.1 (as corrected in `31ffd5f` and `4206c72`) > this plan > implementer judgment. The four session-settled decisions — **KTD1, KTD3, KTD4, and the deferral of self-hosted runner registration to 2.12** — are product law for this PR. KTD2 and KTD5–KTD9 are evidence-driven and may be revised if implementation disproves them. +- **Stop conditions:** Do not register a self-hosted runner — that moved to 2.12. Do not add `uiautomation` or `windows-capture`; they are first consumed in 2.2/2.10. Do not implement UIA tree walking, element wrapping, or any observation capability — that is 2.2+. Do not put Win32 bindings or `#[cfg(windows)]` platform logic in `agent-desktop-core`. If evidence contradicts a settled decision above, stop and surface it. +- **Execution profile:** One PR into `feat/windows-adapter`, never `main`. Target ≈1.6k LOC. Conventional Commits. +- **Tail ownership:** The implementer opens the PR against `feat/windows-adapter` and reports the Verification Contract results. + +--- + +## Product Contract + +### Summary + +Sub-phase 2.0 proved what Windows actually does. 2.1 is the first Rust sub-phase: it extends the Windows CI lane from a single `--lib` invocation to the full Windows package surface, establishes COM apartment and DPI state at process start with different primitives for the CLI and the cdylib, gives `WindowsAdapter` a session type that owns that apartment, adds a truthful permission probe, and rebuilds private-file hardening from scratch behind a new core seam. + +### Problem Frame + +`crates/windows` is a 63-line stub: a unit struct with four empty capability impls (`crates/windows/src/adapter.rs:3-20`). Everything later sub-phases need — a COM apartment, DPI awareness, a session lifetime, a truthful permission report, a safe private-artifact write path — does not exist. + +Two of those are not greenfield. The private-file layer **already shipped once and was deleted**: 1,062 LOC of Win32 in `agent-desktop-core` that failed 225 of 940 tests on first contact with Windows and was removed in `8ad66b8` (PR #106). Its four failure clusters are documented, its worst defect is recovered verbatim, and sub-phase 2.0 measured the correct behaviour for every question it got wrong. And the CI lane that would have caught it (`ci.yml:223-261`) still runs only `cargo test -p agent-desktop-core -p agent-desktop-windows --lib` — no clippy, no binary-crate tests, no isolation check, no size gate. + +### Requirements + +- **R1.** The `test-windows` lane runs clippy `-D warnings` over `agent-desktop-core`, `agent-desktop-windows`, `agent-desktop`, and `agent-desktop-ffi`; the binary-crate tests (`cargo test -p agent-desktop`); the core-isolation check; and a Windows-native release-binary size check against the 15 MiB cap. Every step in the lane runs under an isolated `HOME`. +- **R2.** COM apartment and DPI awareness are established at process start for both consumers, at a call site the consumer cannot skip (KTD2, KTD6). +- **R3.** `WindowsAdapterSession` implements `AdapterSession` via `open_session` and owns COM apartment lifetime, so 2.2+ consume an apartment rather than creating one. +- **R4.** `WindowsAdapter` constructs and satisfies `PlatformAdapter`; every **adapter-backed** command returns honest `PLATFORM_NOT_SUPPORTED`; the commands that reach no adapter (`version`, `skills`, `session`, `trace`, `status`, `permissions`) continue to succeed. +- **R5.** A Windows permission probe reports truthfully and is unit-tested against mocked security state, on a host-independent seam. +- **R6.** Windows private-file hardening exists behind a core-defined seam implemented in `agent-desktop-windows` (KTD1), is **installed at a site no consumer can bypass**, and satisfies every measured behaviour in R7. +- **R7.** The hardening implements, against 2.0's evidence: `ReplaceFile` — not `MoveFileEx` — for an atomic replace whose destination a validation handle holds open; `FILE_SHARE_DELETE` on every concurrently-open handle, necessary but never sufficient; **error 5, not 32**, as the expected destination-side failure; owner validation against `TokenOwner`, not `TokenUser`; locality inference from `FileRemoteProtocolInfo` (class 13) only behind a control call on a known-good class; and ancestor-walk ACL validation (KTD3). +- **R8.** The hardening **authors** the security state it validates: private roots and artifacts are created with an owner-only `SE_DACL_PROTECTED` descriptor, and every path component is rejected if it carries `FILE_ATTRIBUTE_REPARSE_POINT`. +- **R9.** The dependency pins for `uiautomation` and `windows-capture` are recorded but not added. Win32 bindings needed by 2.1's own scope **are** added, to the platform crate only (KTD4). +- **R10.** No Win32 binding crate and no unallowlisted `#[cfg(windows)]` platform logic enters `agent-desktop-core`. **Both halves are mechanically gated**, not merely asserted. + +### Key Decisions + +- **Private-file code lives behind a core-defined trait, implemented in the platform crate.** (session-settled: user-directed — chosen over `#[cfg(windows)]` inside core, which is the exact shape PR #106 deleted, and over deferring the hardening entirely.) Governs R6, R10. +- **Windows ACL validation walks to the nearest protected ancestor, diverging from the unix leaf-only rule on the record.** (session-settled: user-directed — chosen over leaf-only parity, because 2.0 measured that a Windows leaf carries no explicit ACE and a leaf-only check is structurally blind to whether the grant is anchored or re-derivable.) Governs R7. +- **Win32 bindings come from `windows-sys` in `crates/windows`, target-gated.** (session-settled: user-directed — chosen over hand-written `extern "system"` declarations, because the struct-layout transcription that hand-binding requires is the precise bug class that killed the previous layer.) Governs R9. +- **Self-hosted runner registration is not in 2.1.** (session-settled: user-directed — chosen over registering it here or standing up an ephemeral/JIT runner now: a security-sensitive persistent runner on a public repository should be registered by the sub-phase that needs an interactive desktop, not left idle from 2.1 through 2.11.) Moved to 2.12 along with ledger row A10-2's closure. + +### Scope Boundaries + +- **Out:** self-hosted runner registration, the RDP/session-transition measurement, and ledger row A10-2's closure — all now 2.12. +- **Out:** `uiautomation`, `windows-capture`, and any UIA tree, element, resolution, action, or input capability — 2.2+. This includes the native-handle downcast guard macOS has at `crates/macos/src/adapter.rs:15-31`: it exists to validate wrapped elements, and Windows has none yet. +- **Out:** repairing workspace `default-members` so bare `cargo build --release` works off-macOS. Recorded as ledger row R6-3 and left alone here: it changes what an unqualified cargo invocation does on every platform, which is wider than this sub-phase's mandate. See Open Questions — it has a working consequence for this PR, handled in the Verification Contract. +- **Out:** `.gitattributes`. The index is clean — `git ls-files --eol` reports 1158 files `i/lf` and zero CRLF — so it is a determinism measure, not a repair. +- **Deferred to follow-up:** correcting the `MockAdapter` fiction in `CLAUDE.md:382`, `AGENTS.md:395`, `CONTRIBUTING.md:58`, and the stale `FileRemoteProtocolInfo` claim in `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md:92`. `docs/phases.md` is already correct on both. + +--- + +## Planning Contract + +### Key Technical Decisions + +- **KTD1. The private-file seam is a core-defined trait implemented in the platform crate.** (session-settled: user-directed — chosen over `#[cfg(windows)]` in core.) Governs R6, R10. `docs/phases.md:978` previously prescribed "behind `PlatformAdapter` or as a Windows-gated dependency of `agent-desktop-windows`", and neither is reachable: every private-artifact write site is in core with no adapter handle, and core may not depend on the platform crate (`ci.yml:114-121`). Core therefore defines a narrow trait with a **default implementation equal to today's portable behaviour**, so unix and macOS are byte-for-byte unchanged and the risk is confined to the Windows arm. + +- **KTD2. The seam and the apartment install where a consumer cannot skip them.** Governs R2, R6. **`ad_init` is not an install point.** Its own doc says so — `crates/ffi/src/abi_version.rs:34-36`: *"No global state is initialised by this call — skipping it does not prevent adapter functions from operating"* — and the committed header labels it **"(Optional)"** at `crates/ffi/include/agent_desktop.h:10`. Installing there would mean any host that skips it silently runs every private-artifact write through the portable default, with nothing observable distinguishing that state from the hardened one. The repo already solved this shape: `crates/ffi/src/adapter.rs:107-118` calls `ensure_cocoa_multithreaded()?` in the macOS arm of `build_adapter()` immediately before constructing the adapter, while the Windows arm is a bare `Ok(Box::new(WindowsAdapter::new()))`. Both the apartment bootstrap and the seam install go **there** for the cdylib, and at the top of `run()` for the CLI — before `resolve_active_session`, which reads private files at `src/main.rs:118`. `ad_init` stays a pure version check so the committed header and the `ffi-header-drift` job are untouched. + +- **KTD3. ACL validation walks the whole chain, anchored at the nearest protected ancestor.** (session-settled: user-directed on the ancestor-walk direction.) Governs R7, R8. Ledger row A11-4 measured that a Windows leaf carries **zero explicit ACEs** — the plain leaf has 3, all `INHERITED_ACE`. The restriction is authored at the nearest `SE_DACL_PROTECTED` ancestor and projected downward. But `SE_DACL_PROTECTED` only means *inheritance stops here*; it says nothing about how permissive that node's own DACL is, and it does not protect the node's **name**. So the walk is two-part: validate the anchor's **own** DACL for untrusted write grants, and continue **past** the anchor to the volume root checking every remaining component for `DELETE` / `FILE_DELETE_CHILD` grants to non-admin principals — otherwise a principal who can rename an ancestor substitutes an attacker-owned subtree that then walks clean. This is a deliberate divergence from the unix leaf-only rule and must be documented in the module. + +- **KTD4. Bindings come from `windows-sys 0.61` in `crates/windows` only.** (session-settled: user-directed.) Governs R9, R10. Measured: `cargo tree -p agent-desktop --target x86_64-pc-windows-msvc -i windows-sys` shows **`windows-sys 0.61.2` is already compiled into every Windows build**, transitively via `clap`'s colour support and `tracing-subscriber`; it is in `Cargo.lock:596`. Adding it to the platform crate adds **zero crates and zero supply-chain delta**. It carries everything 2.1 needs. The `windows` crate is not needed until 2.2, where `uiautomation 0.25.0` pulls it transitively. + +- **KTD5. Win32 struct reads are extent-checked, not size-pinned.** Governs R7. `docs/solutions/best-practices/ffi-repr-c-struct-size-pinning.md` documents this repo's discipline for fixed-layout structs, and its motivating incident is the same shape as the defect that killed the previous layer: a layout assumption that **passed CI**. Apply `const _: () = assert!(size_of::() == N)` pins to the **fixed-layout** structs (`TOKEN_OWNER`, `FILE_REMOTE_PROTOCOL_INFO`). Do **not** rely on a size pin for `ACE_HEADER`/`ACCESS_ALLOWED_ACE`: those are **variable-length**, so a pin covers only the fixed prefix while the dangerous read is the trailing SID. Those get runtime extent arithmetic instead (see U8). + +- **KTD6. Nothing initialises COM or touches User32 at library load.** Governs R2. Microsoft's DLL best-practices list names both halves explicitly: *"Initialize COM threads by using CoInitializeEx"* and *"Call functions in User32.dll"* are both prohibited inside `DllMain`, because it runs under the loader lock. `#[ctor]` inherits the prohibition — CRT initialisers run in the same context. Use one-time lazy init at the call sites KTD2 names. Repo precedent: `crates/macos/src/system/cocoa_runtime.rs:4-15` (`OnceLock>`). + +- **KTD7. The DPI bootstrap does not verify itself by read-back.** Governs R2. Ledger row A10-4 measured that `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)` **succeeds** on build 17763, but the read-back reports the V1 string `PROCESS_PER_MONITOR_DPI_AWARE` because `GetProcessDpiAwareness` has no V2 enumerant. Assert on the call's return, not on a query. A second call returns `ERROR_ACCESS_DENIED`, which means "already set" and must be tolerated rather than fatal. + +- **KTD8. Portable assertions only in CI tests.** Governs R5, R7. `windows-latest` is now Server 2025 with VS2026 (claims-audit row C-11), while every A11 measurement was taken on Server 2019 / 17763. Rows A11-1 and A11-3 are `api-contract` and travel; **A11-2 and A11-4 are `app/provider` and do not** — A11-2 records `EnvironmentLimit: "single built-in Administrator account on this VM; a non-admin CI account could not be exercised here"`. Assert *owner equals `TokenOwner`*, never *owner differs from `TokenUser`*. Never hardcode `S-1-5-32-544` or this box's ancestor chain. + +- **KTD9. The seam is routed at the primitives, not at the call sites.** Governs R6. Core's private-file operations already funnel through a small set of definitions — `write_atomic`, `open_private_append`, `open_private_lock`, `read_private_bounded` in `crates/core/src/private_file.rs`, and `ensure_private` in `crates/core/src/private_file_parent.rs`. `crates/core/src/refs.rs:204-206` (`write_private_file`) alone funnels most write sites. Routing at the primitives covers every current and future caller — including `file_lock.rs` and `session/liveness.rs:48`, which a call-site list misses — with zero call-site edits. The seam's write operation is the **whole** `write_atomic(path, bytes)`, not a `(source, destination)` replace primitive, so the Windows implementation owns temp creation, handle lifetime, and replace ordering end to end. + +### High-Level Technical Design + +The seam, routed at core's primitives: + +```mermaid +flowchart TB + subgraph callers["callers (unchanged)"] + RS["RefStore"] + SM["session manifest"] + TR["trace writer"] + TB["artifact budget"] + CG["clipboard image"] + FL["file_lock / liveness"] + end + subgraph core["agent-desktop-core (no Win32, no windows-sys)"] + P["private_file.rs / private_file_parent.rs
write_atomic · open_private_append
open_private_lock · read_private_bounded · ensure_private"] + T["trait PrivateFileOps
default impl = today's portable behaviour"] + P --> T + end + RS --> P + SM --> P + TR --> P + TB --> P + CG --> P + FL --> P + subgraph win["agent-desktop-windows"] + W["WindowsPrivateFile
descriptor · replace · owner · locality · acl"] + end + subgraph hosts["install sites a consumer cannot skip"] + B["binary: top of run()"] + F["cdylib: build_adapter() windows arm"] + end + W -. implements .-> T + B -- installs --> W + F -- installs --> W +``` + +Apartment ownership across the two consumers: + +```mermaid +flowchart LR + CLI["CLI run()"] -->|"CoInitializeEx(MTA)
owns its process"| A["MTA"] + FFI["cdylib build_adapter()
windows arm"] -->|"CoIncrementMTAUsage
cookie, thread-agnostic"| A + FFI -.->|"RPC_E_CHANGED_MODE:
borrow host apartment,
never uninitialise"| A + A --> S["WindowsAdapterSession
owns lifetime, releases once"] + S --> N["2.2+ consume via
UIAutomation::new_direct()"] +``` + +### Output Structure + +``` +crates/windows/src/ +├── adapter.rs # thin: unit struct only (mirror macos/src/adapter.rs) +├── system/ +│ ├── mod.rs +│ ├── adapter.rs # SystemOps impl (mirror macos/src/system/adapter.rs) +│ ├── com_runtime.rs # KTD2/KTD6 apartment bootstrap, OnceLock +│ ├── dpi.rs # KTD7 DPI awareness +│ ├── session.rs # WindowsAdapterSession +│ ├── permissions.rs # cfg-split mod imp + pure mapping fns +│ ├── permissions_tests.rs +│ └── private_file/ +│ ├── mod.rs # WindowsPrivateFile: impl of core's trait +│ ├── descriptor.rs # R8: owner-only SE_DACL_PROTECTED creation +│ ├── replace.rs # whole write_atomic: temp → drop → ReplaceFileW +│ ├── owner.rs # TokenOwner validation +│ ├── locality.rs # class 13 behind a control call +│ ├── acl.rs # KTD3 walk + extent-checked ACE read + reparse rejection +│ └── tests.rs +crates/core/src/ +└── private_file_ops.rs # trait + portable default impl + install/accessor +``` + +Per-unit `**Files:**` lists are authoritative; this tree is a scope declaration. + +--- + +## Implementation Units + +### U1. Make the binary-crate tests runnable on Windows + +- **Goal:** `cargo test -p agent-desktop` runs on Windows, which it does not today. Hard prerequisite for U2 — without it the extended lane lands red on its first run. +- **Requirements:** R1, R4. +- **Dependencies:** none. +- **Files:** `src/tests/snapshot_test.rs`. +- **Approach:** `src/tests/snapshot_test.rs:6-16` resolves the binary by walking `current_exe()` up two levels and pushing `"agent-desktop"`, with no `std::env::consts::EXE_SUFFIX`. On Windows the artifact is `agent-desktop.exe`, so the `assert!(path.is_file(), …)` fires and **3 of 128 tests panic before running**: `version_command_outputs_json`, `snapshot_invalid_root_ref_format_returns_invalid_args`, and `list_apps_on_non_macos_errors_gracefully` — the last of which is precisely the R4 honesty assertion. The correct pattern is one file over: `src/tests/cli_process.rs:4` uses `env!("CARGO_BIN_EXE_agent-desktop")`, which Cargo resolves per-platform. Adopt it rather than appending `EXE_SUFFIX` by hand — it removes the `current_exe()` walk entirely and cannot drift. +- **Patterns to follow:** `src/tests/cli_process.rs:4`. +- **Test scenarios:** + - `cargo test -p agent-desktop` passes 128/128 on Windows. + - `list_apps_on_non_macos_errors_gracefully` actually executes and observes `PLATFORM_NOT_SUPPORTED` — it is currently dead on Windows, and it is what demonstrates R4's adapter-backed clause. + - The same target still passes on macOS, proving the helper change is platform-neutral. +- **Verification:** the three named tests pass on Windows; no test target regresses on macOS. + +### U2. Extend the Windows CI lane to the full package surface + +- **Goal:** `test-windows` enforces what `docs/phases.md:973` requires, so later sub-phases cannot land Windows regressions no lane executes. +- **Requirements:** R1, R10. +- **Dependencies:** U1. +- **Files:** `.github/workflows/ci.yml`, `src/cli/contract_tests.rs`. +- **Approach:** + 1. **Hoist HOME isolation to a lane-wide step.** Today `$env:HOME`/`$env:USERPROFILE` are set inside the single test step's `run:` block (`ci.yml:253-261`), and **environment set in one `run:` block does not survive into the next step**. Add a first step that creates the temp home and exports `HOME`, `USERPROFILE`, `CARGO_HOME`, `RUSTUP_HOME` via `Add-Content -Path $env:GITHUB_ENV`, then drop the inline assignments — otherwise the newly added `cargo test -p agent-desktop`, which spawns the real binary and writes refmaps and trace segments, runs against the runner's real profile. + 2. Add clippy over the four crates. Measured green on Windows today at exit 0. `agent-desktop-ffi` is a workspace member but **not** in `default-members` (`Cargo.toml:3`) and must be named explicitly. No `rustup component add` is needed — `rust-toolchain.toml` declares `clippy` and `rustfmt`. + 3. Add `cargo test --locked -p agent-desktop` (not `--lib`: that crate has no lib target, so `--lib` silently skips it). + 4. Add the core-isolation check, widened per R10 in **both** directions the current one misses: run `cargo tree` with `--edges normal,build,dev` (the existing `--edges normal,build` excludes dev-dependencies, so a `windows-sys` dev-dependency supporting `#[cfg(windows)]` test code passes clean) and with `--target x86_64-pc-windows-msvc`; then add a **source-level** gate over `crates/core/src/**` failing on `extern "system"`, `std::os::windows`, or `cfg(windows)` / `cfg(target_os = "windows")` outside an allowlist naming the two existing portable shims at `private_file.rs:207` and `:224`. Without the second gate, R10's `#[cfg(windows)]` half is asserted in the DoD but mechanized nowhere, and hand-written externs need no binding crate to slip through. + 5. Add a `cargo build --locked --release -p agent-desktop` step (the size check has nothing to measure without it), then a Windows-native size check. The existing gate (`ci.yml:167-178`) uses BSD `stat -f%z`, which **fails under Git Bash** — write it in pwsh, consistent with `release.yml:341-347`: `(Get-Item target/release/agent-desktop.exe).Length` against `15MB`. There is **no helper binary on Windows**, so the step checks one file. Current headroom: 1,920,512 B, 12.2% of cap. + 6. Add a `target/` cache mirroring `ci.yml:105-112` and raise the 20-minute timeout — clippy `--all-targets` plus binary tests plus an LTO release build will not fit. +- **Execution note:** `src/cli/contract_tests.rs:83` `include_str!`s `ci.yml`, so any edit forces a binary-crate rebuild. Its four assertions cover the macOS version-check step at `ci.yml:154-163`, which this unit must **not** alter. Run `cargo test -p agent-desktop` after editing the workflow. +- **Patterns to follow:** `ci.yml:114-121` (isolation, as the base to widen), `release.yml:341-347` (pwsh size measurement), `ci.yml:105-112` (target cache). +- **Test scenarios:** + - The lane passes on a PR with no Rust changes. + - A deliberate clippy warning in `crates/windows` fails the lane. + - A stub adding `windows-sys` to `crates/core/Cargo.toml` as a **dev-dependency** fails the widened tree check — the case the current `--edges normal,build` misses. + - A stub adding `extern "system"` to a core source file fails the source-level gate, while the two allowlisted shims still pass. + - The size step reports a byte count and passes; artificially lowering the cap fails it. + - A test that writes a private artifact observes it under `$RUNNER_TEMP`, not the runner profile — proving the hoisted HOME reached every step. +- **Verification:** all new checks appear in the lane and **each has been observed failing** when its condition is violated. + +### U3. Add target-gated Win32 bindings to the platform crate + +- **Goal:** `crates/windows` can call Win32 without any binding crate reaching core. +- **Requirements:** R9, R10. +- **Dependencies:** none. +- **Files:** `crates/windows/Cargo.toml`. +- **Approach:** Add `windows-sys` under `[target.'cfg(target_os = "windows")'.dependencies]`, mirroring `crates/macos/Cargo.toml`. Version `0.61` — already in `Cargo.lock:596`, so this resolves to the same compiled crate. Features: `Win32_Foundation`, `Win32_System_Com`, `Win32_UI_HiDpi`, `Win32_Storage_FileSystem`, `Win32_Security`, `Win32_Security_Authorization`. Do **not** add `uiautomation` or `windows-capture` (R9). +- **Patterns to follow:** `crates/macos/Cargo.toml` target-gated dependency block. +- **Test scenarios:** + - `cargo tree -p agent-desktop-core` still shows the same 8 dependencies and no binding crate, on both `--edges normal,build,dev` and the Windows target. + - `cargo check -p agent-desktop-windows --target x86_64-pc-windows-msvc` succeeds. +- **Verification:** the widened isolation check from U2 passes; the platform crate compiles against the new features. + +### U4. Establish the COM apartment and DPI awareness at an unskippable call site + +- **Goal:** A Windows process running this product is in the MTA and DPI-aware before any UIA or window work, with the CLI and cdylib each using the primitive their constraints require — installed where a consumer cannot skip it. +- **Requirements:** R2. +- **Dependencies:** U3. +- **Files:** `crates/windows/src/system/com_runtime.rs`, `crates/windows/src/system/dpi.rs`, `crates/windows/src/system/mod.rs`, `crates/windows/src/lib.rs`, `src/main.rs`, `crates/ffi/src/adapter.rs`. +- **Approach:** + - **Apartment (KTD2).** Expose two entry points from `com_runtime.rs`: one for a process the product owns (CLI) calling `CoInitializeEx(null, COINIT_MULTITHREADED)`, and one for a hosted library (cdylib) calling `CoIncrementMTAUsage` and retaining the cookie. `RPC_E_CHANGED_MODE` from the CLI path is **not** an error — COM is already initialised on this thread in another mode; record the apartment as borrowed and do not schedule a `CoUninitialize`. Guard each with `OnceLock>`, and document the asymmetry that makes this sound: `CoIncrementMTAUsage`'s cookie is **process-wide**, so a process-wide `Once` is correct; `CoInitializeEx`'s effect is **thread-local**, so the CLI path's `Once` is only valid because the CLI initialises on its main thread and does not hand the apartment to other threads. + - **DPI (KTD7).** `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`. Treat `ERROR_ACCESS_DENIED` as benign "already set". **Do not** verify by reading awareness back. + - **Wiring (KTD2, KTD6).** Export `ensure_com_mta_and_dpi()` from `agent-desktop-windows`. Call it from the top of `run()` in `src/main.rs`, and from the `#[cfg(target_os = "windows")]` arm of `build_adapter()` at `crates/ffi/src/adapter.rs:115-118` — **not** from `ad_init`, which is documented optional and initialises nothing. Leave `ad_init` untouched so the committed header and the `ffi-header-drift` job are unaffected. **Never** call from `DllMain` or a `#[ctor]`. +- **Execution note:** Write the `RPC_E_CHANGED_MODE` and `ERROR_ACCESS_DENIED` tolerance paths test-first. They are the branches a happy-path implementation silently gets wrong, and they are the reason the cdylib works inside an STA host. +- **Patterns to follow:** `crates/macos/src/system/cocoa_runtime.rs:4-15` for the `OnceLock` shape; `crates/ffi/src/adapter.rs:107-118` for the macOS arm calling `ensure_cocoa_multithreaded()?` before adapter construction — mirror it exactly; `crates/macos/src/system/permissions.rs:12-130` for the `#[cfg]`-split `mod imp` so the crate unit-tests on any host. +- **Test scenarios:** + - Calling the CLI bootstrap twice on the same thread succeeds both times and schedules exactly one release. + - A simulated `RPC_E_CHANGED_MODE` yields success with "apartment borrowed" recorded and **no** uninitialise scheduled. + - A simulated `ERROR_ACCESS_DENIED` from the DPI call is reported as success-with-already-set, not failure. + - No test asserts V2 via read-back (KTD7) — assert on the call result. + - Constructing an FFI adapter **without any prior `ad_init` call** still observes an initialised apartment. + - The non-Windows `mod imp` arm compiles and returns canned values, so these tests run on any lane. +- **Verification:** the bootstrap runs on the Windows CI lane; no code path calls into COM or User32 from `DllMain` or a constructor; `ad_init` is unchanged. + +### U5. Give the adapter a session that owns apartment lifetime + +- **Goal:** `WindowsAdapterSession` implements `AdapterSession` and owns the COM apartment, so 2.2+ consume an established apartment. +- **Requirements:** R3. +- **Dependencies:** U4. +- **Files:** `crates/windows/src/system/session.rs`, `crates/windows/src/system/adapter.rs`, `crates/windows/src/system/mod.rs`. +- **Approach:** `AdapterSession` is one method — `close(self: Box) -> Result<(), AdapterError>` (`crates/core/src/adapter_session.rs:9-11`) — with **no `Drop` bound, no documented drop contract, and no test** covering a dropped-without-`close` box. Release must happen exactly once on either path. Note the Rust constraint: **a type implementing `Drop` cannot have fields moved out of it**, so `close(self: Box)` cannot simply take the cookie by value if `Drop` is also implemented. Hold the cookie as `Option` and have both `close()` and `Drop` take it via `Option::take()`, so the release is once-only by construction of the `Option`, not by a claim. This is also the second reason KTD2's `CoIncrementMTAUsage` is right for the hosted path: its cookie may be released from a different thread than acquired it, so a `Drop` impl is sound where `CoUninitialize` from the wrong thread would not be. +- **Execution note:** `open_session` currently has **zero production callers** — confirmed across `src/`, `crates/ffi/`, and every platform crate. This unit ships a type reachable from its own tests and from a future FFI caller; `crates/ffi/src/adapter.rs:19-23` (`AdAdapter`) is its natural future home, driven from `ad_adapter_destroy`. State that in the code so a later reader does not mistake it for dead code. +- **Patterns to follow:** `crates/core/src/adapter_session_tests.rs:8-36` (`FlagSession`) — the only existing implementation. +- **Test scenarios:** + - `open_session` returns a session rather than `PLATFORM_NOT_SUPPORTED` on Windows. + - `close()` releases exactly once, asserted via a fake release counter. + - A session dropped **without** `close()` releases exactly once. + - A session `close()`d and then dropped releases exactly once total, not twice. + - The returned box satisfies `Send + Sync`. +- **Verification:** construct/close/drop paths unit-tested on the Windows lane; the release counter proves once-only on every path. + +### U6. Report Windows permissions truthfully + +- **Goal:** A permission probe that reports what Windows actually constrains, unit-tested against mocked security state on a seam needing no desktop. +- **Requirements:** R4, R5. +- **Dependencies:** U3. +- **Files:** `crates/windows/src/system/permissions.rs`, `crates/windows/src/system/permissions_tests.rs`, `crates/windows/src/system/adapter.rs`. +- **Approach:** Mirror the macOS structure: a `#[cfg(target_os = "windows")] mod imp` with a `#[cfg(not(…))]` twin returning canned values, plus **pure mapping functions** taking integers and returning `PermissionState`. Those pure functions are what "unit-tested against mocked COM security state" can mean here — there is no COM mocking framework in this repo, and `crates/macos/src/system/permissions.rs:237-246` tested at `permissions_tests.rs:11-19` against literal `OSStatus` values is the only shape with prior art. Write `map_uia_access(hresult: i32) -> PermissionState` and test against literal `0x80070005`. + What to report: **accessibility** — UIA needs no special permission for most apps, so `NotRequired` or `Granted` rather than `Unknown`; **screen_recording** — no macOS-style TCC field, so derive from capture-API availability per `docs/phases.md:940`; **automation** — `NotRequired`. + Two constraints from 2.0's evidence. Row **A9-2** measured that across a real Medium→High integrity boundary, **UIA reads succeed byte-identically** — so an integrity mismatch must **not** flip `accessibility` to `Denied`; it is an input-time `PERM_DENIED` that 2.6 owns. Row **A9-3** measured `SendInput` returning "6 events accepted, lastError 0" in **both** arms — never model permission off a Win32 return value. + Also override `unknown_accessibility_means_unsupported`. It is consulted **only by the FFI** (`crates/ffi/src/adapter.rs:276-293`); `src/command_policy/mod.rs` never calls it, so CLI and FFI diverge for an unprobed adapter. Overriding it makes them agree. +- **Patterns to follow:** `crates/macos/src/system/permissions.rs` in full — the `mod imp` split (`:12-130`), the pure mappers (`:194-246`), deadline checks around every native read (`:295-301`), and dependency injection for testability (`:147-172`). +- **Test scenarios:** + - `map_uia_access` maps `E_ACCESSDENIED (0x80070005)` to `Denied` with a non-empty suggestion, and `S_OK` to `Granted`. + - An unrecognised HRESULT maps to `Unknown`, not to a guess. + - `permission_report` on the non-Windows arm returns the canned shape, so the test runs on any lane. + - `automation` reports `NotRequired` on Windows. + - A denial's `platform_detail` matches the Invariant-8 HRESULT format (`docs/phases.md:833`). + - `unknown_accessibility_means_unsupported` returns the value that makes CLI and FFI agree. +- **Verification:** mapping functions unit-tested against literal HRESULTs on the Windows lane; no test requires an interactive desktop. + +### U7. Introduce the private-file seam in core and install it + +- **Goal:** Core routes private-file operations through a trait it defines, with a default implementation identical to today's behaviour, **and both consumers install the platform implementation at a site they cannot skip**. +- **Requirements:** R6, R10. +- **Dependencies:** none for the seam; the install step depends on U8 existing to install. +- **Files:** `crates/core/src/private_file_ops.rs`, `crates/core/src/lib.rs`, `crates/core/src/private_file.rs`, `crates/core/src/private_file_parent.rs`, `src/main.rs`, `crates/ffi/src/adapter.rs`. +- **Approach:** + - **Route at the primitives, not the call sites (KTD9).** Define the trait over `write_atomic`, `open_private_append`, `open_private_lock`, `read_private_bounded` (all in `private_file.rs`) and `ensure_private` (`private_file_parent.rs`), then have those five functions dispatch through it. Every caller is covered with **zero call-site edits** — including `crates/core/src/file_lock.rs` and `crates/core/src/session/liveness.rs:48`, which a call-site enumeration misses, and every read path. `crates/core/src/refs.rs:204-206` (`write_private_file`) already funnels most writes. + - **The write operation is the whole `write_atomic(path, bytes)`**, not a `(source, destination)` replace primitive — the Windows implementation must own temp creation, handle lifetime, and replace ordering together, because `ReplaceFile` fails error 32 over an open **source** in 10 of 10 measured cases. + - **Own the temp-handle reorder.** `write_atomic_with` (`private_file.rs:84-94`) holds the source temp handle open across `replace_atomic` and drops it only afterward. Restructure so the handle is dropped **before** the replace on the portable path too — safe on unix, required on Windows. Note this also moves `write_user_atomic`, which shares the same helper. + - **Default implementation = today's portable code**, so unix and macOS behaviour is unchanged. + - **Install (KTD2).** From the top of `run()` in `src/main.rs`, before `resolve_active_session` reads private files at `src/main.rs:118`; and from the `#[cfg(target_os = "windows")]` arm of `build_adapter()` at `crates/ffi/src/adapter.rs:115-118`. **Not** `ad_init`. + - **Test override.** Production install is a `OnceLock`. A process-global `OnceLock` alone cannot support the fake-implementation test — `cargo test -p agent-desktop-core --lib` runs every core test in one process in parallel, so a global fake would be observed by sibling tests that write real private files. Add a `#[cfg(test)] thread_local!` override consulted first by the accessor, mirroring `crates/core/src/trace_artifact_budget.rs:20-35` and `crates/core/src/refs.rs:18-20`. +- **Execution note:** Characterize before changing. `crates/core/src/private_file_tests.rs:1` is `#![cfg(unix)]` — all 10 behaviour tests are unix-only. Run them before and after to prove the default implementation is behaviour-identical. When lifting `#![cfg(unix)]` for the portable subset, the module's unix-only imports at lines 4-7 (`std::os::fd::AsRawFd`, `std::os::unix::fs::{OpenOptionsExt, PermissionsExt}`) must be cfg-gated too, or the file will not compile on Windows. +- **Patterns to follow:** `crates/core/src/trace_artifact_budget.rs:20-35` (thread-local test override); `crates/core/src/adapter/mod.rs:26-28` (core defining a trait it does not implement). +- **Test scenarios:** + - Every existing unix private-file test passes through the default implementation — the assertion is "existing unix tests pass", not "the code path is unchanged", since the temp-handle reorder deliberately changes it. + - A fake implementation installed via the thread-local override observes the calls each primitive makes, while sibling tests in the same binary keep the portable default. + - The portable subset of `private_file_tests.rs` runs and passes on Windows. + - `cargo tree -p agent-desktop-core` is unchanged — no new dependency. + - Installing twice is either rejected or idempotent, and the behaviour is asserted. + - **A spawned `agent-desktop` process on Windows exercises the installed implementation** — the never-installed path is otherwise silently successful via the default, so without this the whole hardening can ship unreachable. +- **Verification:** core has no `#[cfg(windows)]` platform logic and no binding crate; all pre-existing tests pass on macOS, Linux and Windows; the install is proven reachable from both consumers. + +### U8. Implement Windows private-file hardening behind the seam + +- **Goal:** The Windows arm, built against 2.0's measured evidence, that **authors** the security state it validates rather than hoping to find it. +- **Requirements:** R6, R7, R8. +- **Dependencies:** U3, U7. +- **Files:** `crates/windows/src/system/private_file/{mod,descriptor,replace,owner,locality,acl,tests}.rs`. +- **Approach:** Five measured or required behaviours drive five modules. + + 1. **Creation-side security (`descriptor.rs`, R8).** The deleted layer built an owner-only `SE_DACL_PROTECTED` descriptor and passed it via `SECURITY_ATTRIBUTES` at creation; a read-only rebuild would validate an anchor it never authored, and on a permissive profile chain would either hard-fail every write or pass vacuously. Construct that descriptor from `TokenUser` and apply it when creating the private root, its subdirectories, and every temp file. The ancestor walk in `acl.rs` then validates an anchor this code owns. + + 2. **Atomic replace (`replace.rs`).** Use `ReplaceFileW`, **not** `MoveFileEx`. Measured matrix (42/42 definite, `SuccessWithoutShareDelete: []`): + + | op | open handle on | share mode | result | + |---|---|---|---| + | `MoveFileEx` | target | any, incl. `0x4`/`0x7` | **`ERROR_ACCESS_DENIED (5)`** | + | `MoveFileEx` | source | `0x4`/`0x7` | success | + | `ReplaceFile` | target | `0x0`/`0x1`/`0x3` | `ERROR_SHARING_VIOLATION (32)` | + | `ReplaceFile` | **target** | **`0x4`/`0x7`** | **success**, held handle still reads old bytes | + | `ReplaceFile` | source | any, incl. `0x4`/`0x7` | `ERROR_SHARING_VIOLATION (32)` | + + Opposite tolerances on opposite sides. Classify **error 5** as the expected destination-side sharing failure, not 32. + + Dropping the source handle before the replace (required — see the last row) opens a window in which a fully-written temp sits closed on disk. Create temps inside a **per-process subdirectory of the private root carrying the descriptor from module 1**, so the closed temp is unreachable to other principals for that window, and sweep stale per-process temp directories at startup so an aborted process leaves no readable orphan. + + **A trap in the other direction:** plain `OpenOptions` on Windows already defaults to `FILE_SHARE_READ|WRITE|DELETE`, which is why readers holding a destination open work today. Any hardened `CreateFileW` that *narrows* that share mask re-introduces the 122-failure sharing cluster. Widen deliberately; never narrow silently. + + 3. **Ownership (`owner.rs`).** Validate against `TokenOwner`, never `TokenUser`. Measured at **both** High and Medium integrity: owner `S-1-5-32-544`, `OwnerMatchesTokenUser: false`, `OwnerMatchesTokenOwner: true`. **Integrity is not the variable — group membership is.** Document plainly in the module that on an admin-group account `TokenOwner` resolves to `BUILTIN\Administrators`, so this check rules out a *foreign* owner and **is not an isolation boundary between admin processes**. The confidentiality control is the DACL from module 1, not the owner field. + + 4. **Locality (`locality.rs`).** `GetFileInformationByHandleEx(FileRemoteProtocolInfo)` — class **13** — distinguishes local from remote, but signals *local* by **failing** with `ERROR_INVALID_PARAMETER (87)` (0/6 local returned data; 3/3 remote did). The trap: an out-of-range class (tested with 55) returns **the same 87** on all 9 targets. So 87 is a locality signal only **behind a control call on a known-good class** — issue `FileBasicInfo` (class 0) on the same handle first and require it to succeed. **Third state:** if the control call itself fails, the verdict is `Unknown`, and `Unknown` is treated as **remote (refused)** for private artifacts — failing open would write private data to SMB storage on a redirected profile. + + 5. **ACL and path integrity (`acl.rs`, KTD3, R8).** + + **Reparse rejection first.** The unix path rejects user-controlled symlinks per component (`private_file_parent.rs:68-72`); the deleted Windows layer rejected `FILE_ATTRIBUTE_REPARSE_POINT`; the portable Windows path today is a bare `create_dir_all`. A junction is creatable by an unprivileged user without `SeCreateSymbolicLinkPrivilege`, and one planted in the private root redirects both the write and the walk. Open each component with `FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS` and reject any carrying `FILE_ATTRIBUTE_REPARSE_POINT`. + + **Two-part walk (KTD3).** Validate the anchor's **own** DACL for untrusted write grants — `SE_DACL_PROTECTED` stops inheritance, it does not mean the node is restrictive — then continue **past** the anchor to the volume root checking every remaining component for `DELETE` / `FILE_DELETE_CHILD` grants to non-admin principals, since a principal who can rename an ancestor substitutes a subtree that walks clean. Document the divergence from unix leaf-only. + + **Extent-checked ACE reads.** The bug this module exists not to repeat, at `crates/core/src/private_file_windows_security.rs:87-101` in `8ad66b8^`: + + ```rust + let ace = unsafe { &*raw_ace.cast::() }; // 91: blind cast + let ace_sid = (&raw const ace.SidStart).cast_mut().cast(); // 92: interior pointer + if ace.Header.AceType != ACCESS_ALLOWED_ACE_TYPE as u8 // 93: type checked AFTER + ``` + + Checking `AceType` before forming the SID pointer is **not sufficient** — reading `AceType` is itself a dereference of `ACE_HEADER` at an offset nothing has bounds-checked, which moves the same defect four bytes earlier. Required order: derive the extent from the ACL header after `IsValidAcl`; then per ACE — assert `size_of::()` fits the remaining extent; read `AceSize` with `ptr::read_unaligned`; assert `AceSize >= size_of::()`, `AceSize % 4 == 0`, and `AceSize <= remaining` using **checked** arithmetic on the cumulative offset; only then branch on `AceType`; then assert `AceSize >= offset_of!(ACCESS_ALLOWED_ACE, SidStart) + SECURITY_SID_MIN_LENGTH` before forming the SID pointer, and require `IsValidSid` plus `GetLengthSid(sid) <= AceSize - offset_of!(SidStart)`. Treat `AceCount` as **advisory** — stop at the extent, not at the count. + + Apply KTD5 size pins to the fixed-layout structs (`TOKEN_OWNER`, `FILE_REMOTE_PROTOCOL_INFO`) only; `ACE_HEADER`/`ACCESS_ALLOWED_ACE` are variable-length and get the extent arithmetic above instead. +- **Execution note:** Write the malformed-ACL rejection test **first**, before any ACL reading code. It is the regression test for the defect this sub-phase exists to avoid, it needs no privilege and no desktop, and it is the single highest-value test in the plan. +- **Patterns to follow:** `docs/solutions/best-practices/ffi-repr-c-struct-size-pinning.md` (KTD5, fixed-layout structs only); `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md` — a `#[cfg]` branch CI cannot execute is a hypothesis, and a test comparing constants to constants can never fail. +- **Test scenarios:** + - **Malformed ACL is rejected**: an ACE whose `AceSize` does not contain its SID; one whose `AceSize` exceeds the remaining extent; one whose `AceSize` is not DWORD-aligned; a non-`ACCESS_ALLOWED_ACE_TYPE` ACE; an `AceCount` larger than the extent supports. Each refused without reading through the wrong layout. No privilege required. + - A path component carrying `FILE_ATTRIBUTE_REPARSE_POINT` is refused — plant a junction in a temp private root and assert the write is refused. + - A newly created private root has exactly the owner-only protected DACL module 1 authored (`AceCount == 1`, `InheritanceProtected == true`). + - `ReplaceFileW` succeeds over a destination held open **with** `FILE_SHARE_DELETE`, and the held handle still reads the **old** bytes. + - `ReplaceFileW` fails **32** over a destination held open **without** share-delete, and **32** over an open **source** even with share-delete. + - `MoveFileExW` fails **5**, not 32, over an open target even at share mask `0x7`. + - Class 13 returns 87 on a local NTFS temp file **while** control class 0 succeeds on the same handle; an out-of-range class also returns 87; a forced control-call failure yields `Unknown` and the write is refused. + - A freshly created file's owner **equals `TokenOwner`** (KTD8 — never assert inequality to `TokenUser`). + - The two-part walk rejects an anchor whose own DACL grants an untrusted principal write, and rejects a chain where a component above the anchor grants `FILE_DELETE_CHILD` to a non-admin principal. Asserted structurally, never against this VM's SIDs (KTD8). + - Size pins fail the build if an upstream fixed-layout struct changes. +- **Verification:** every scenario runs on the `windows-latest` lane and is observed failing when its condition is violated; no assertion hardcodes a machine-specific SID or ancestor chain. + +--- + +## Verification Contract + +| Gate | Command / check | Applies to | +|---|---|---| +| Repo gates (Windows dev box) | `cargo fmt --all -- --check`; `cargo clippy --locked -p agent-desktop-core -p agent-desktop-windows -p agent-desktop -p agent-desktop-ffi --all-targets -- -D warnings`; `cargo test --locked -p agent-desktop-core -p agent-desktop-windows --lib` | whole PR | +| Windows lane | clippy over four crates, `cargo test -p agent-desktop`, widened isolation, release build + native size check — each observed failing when violated; every step under an isolated `HOME` | U1, U2 | +| Core isolation | `cargo tree -p agent-desktop-core --edges normal,build,dev` on the Windows target contains no platform crate and no binding crate; the source-level gate rejects unallowlisted `cfg(windows)` / `extern "system"` in core | U2, U3, U7 | +| Install reachability | a spawned `agent-desktop` process and an FFI adapter constructed **without** `ad_init` both exercise the installed Windows implementation | U4, U7 | +| Bootstrap honesty | `RPC_E_CHANGED_MODE` and `ERROR_ACCESS_DENIED` tolerated with tests; no read-back assertion of DPI V2; no COM or User32 call from `DllMain`/`#[ctor]`; `ad_init` unchanged | U4 | +| Session lifetime | release runs exactly once on close, on drop, and on close-then-drop | U5 | +| Private-file evidence | every measured behaviour in U8 has a test that can fail; malformed-ACL and reparse-point rejection covered; creation-side DACL asserted | U8 | +| Portability of assertions | no test asserts an `app/provider` fact — no hardcoded SID, no hardcoded ancestor chain, no inequality-to-`TokenUser` | U6, U8 | +| Behaviour preservation | all pre-existing unix private-file tests pass through the new seam | U7 | +| Size | Windows release binary under 15 MiB (currently 1,920,512 B, 12.2%) | U2 | +| PR is green | every required check on a PR into `feat/windows-adapter`, never `main` | whole PR | + +**Pre-commit note.** `.githooks/pre-commit` runs unqualified `cargo clippy --all-targets -- -D warnings` and `cargo test --lib --workspace`, both of which resolve through `default-members` and therefore **fail on a Windows dev box** (112 and 186 errors) until ledger row R6-3 lands. Commit with `SKIP_PRECOMMIT=1` on Windows and run the package-scoped forms in the Repo gates row manually in its place. + +## Definition of Done + +- A PR from `feat/windows-2.1-bootstrap` into `feat/windows-adapter` is open and green. +- The Windows lane enforces clippy over the four crates, binary-crate tests, widened core-isolation, and a native size check — each observed failing when its condition is violated — with every step under an isolated `HOME`. +- `WindowsAdapter` constructs and satisfies `PlatformAdapter`; every **adapter-backed** command returns honest `PLATFORM_NOT_SUPPORTED`, while `version`, `skills`, `session`, `trace`, `status` and `permissions` continue to succeed. +- COM apartment and DPI awareness are established at a call site no consumer can skip, with the borrowed-apartment and already-set paths tested, and `ad_init` unchanged. +- `WindowsAdapterSession` owns apartment lifetime and releases exactly once on every path. +- The permission probe reports truthfully and its mapping functions are unit-tested against literal HRESULTs. +- Private-file hardening exists behind a core-defined seam, is **proven reachable from both consumers**, authors the security state it validates, and implements every measured behaviour in R7 — **unit-tested on the `windows-latest` lane**, not merely `cargo check`-clean. +- `agent-desktop-core` contains no Win32 binding crate and no unallowlisted `#[cfg(windows)]` platform logic, **both mechanically gated**. +- No self-hosted runner was registered; ledger row A10-2 remains open and owned by 2.12. + +--- + +## Risks & Dependencies + +- **The seam is the largest structural change.** Routing at the five primitives keeps call sites untouched, and the default implementation is today's code — but the temp-handle reorder deliberately changes the portable path, so "existing unix tests pass" is the real assertion, not "nothing changed." +- **`unsafe_op_in_unsafe_fn = "warn"` becomes an error under `-D warnings`.** U2's "clippy exits 0" measurement predates U3 and U8. Once real `unsafe` Win32 code lands in `crates/windows`, that workspace lint (`Cargo.toml:26`) bites. Budget for it rather than discovering it in CI. +- **Eleven new files under `crates/windows/src/system/` in one PR**, all subject to the 400-LOC rule enforced by `scripts/check-rust-file-size.sh`. `acl.rs` is the one most likely to exceed it. +- **`windows-latest` is Server 2025; 2.0 measured Server 2019.** API-contract rows travel; `app/provider` rows do not. KTD8 is the guard, and it is the most likely place a well-meaning test hardcodes an environment artifact. +- **The CI lane runs as a single administrator**, so every ACL and ownership assertion is exercised from a token that already dominates the objects under test. No lane demonstrates the controls deny a lower-privileged principal. The controls are still worth having; the coverage claim must not be overstated. +- **The previous layer failed on second-order effects.** Every one of its four clusters was a correct-looking call with a wrong assumption underneath. If a case is not in the measured matrices, measure it with the 2.0 probes rather than reasoning about it. +- **`uiautomation` will re-initialise the apartment in 2.2** unless it uses `new_direct()`. `docs/phases.md:999` and `:1105` now specify that; restate it at 2.2's start. + +## Open Questions + +- **Workspace `default-members` includes `crates/macos`** (`Cargo.toml:3`), so `cargo build --release` and `cargo test --lib --workspace` — both documented in `CLAUDE.md` — fail on Windows and Linux. Recorded as ledger row R6-3, out of scope here because it changes unqualified cargo behaviour on every platform. Its working consequence for this PR is handled by the Verification Contract's pre-commit note. +- **`trace_read/html.rs:253` bypasses the private-file primitives entirely**, hand-rolling temp+rename with a predictable temp name (`html.rs:227`) instead of the hashed nonce (`private_file.rs:188`). Routing at the primitives does not catch it because it never calls them. Real defect; needs its own decision about whether trace HTML export is a private artifact. +- **Does the seam cover `write_user_atomic` / `write_user_file`** (`crates/core/src/refs.rs:208`)? Both share `write_atomic_with`, so U7's temp-handle reorder moves the user-output path whether or not it is in scope. +- **Is cross-admin isolation in scope at all?** If not, the ownership and DACL contract should say so explicitly so the leaf checks are calibrated to the boundary the product actually claims. +- **Whether the FFI should wire `open_session`.** U5 ships a session reachable only from tests; `crates/ffi/src/adapter.rs:19-23` is its natural home. Deferred rather than widening 2.1 into `crates/ffi` beyond the install call. + +## Sources & Research + +- `docs/phases.md` §2.1 (`:968-986`), Cross-cutting DoD (`:942-952`), Windows Engineering Invariants (`:826-838`), recorded pins (`:1314-1344`) — corrected in `31ffd5f` and `4206c72`. +- `probes/windows/FINDINGS.md` rows A8-3, A8-4, A9-1, A9-2, A9-3, A10-1, A10-2, A10-4, A11-1, A11-2, A11-3, A11-4, and session-evidence rows R6-1…R6-11, C-11, C-13. +- `probes/windows/captures/12-private-file-io/*.json` — the share-mode matrix, ownership readings, locality results and ACL chain transcribed in U8. +- Commit `8ad66b8` (PR #106) — the deleted layer; `private_file_windows_security.rs:87-101` at `8ad66b8^` for the recovered defect; `private_file_windows.rs:81-83` for the reparse-point rejection it carried. +- `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md`; `docs/solutions/best-practices/ffi-repr-c-struct-size-pinning.md`; `docs/solutions/best-practices/real-app-tests-are-the-platform-adapter-gate.md`. +- Microsoft Learn, fetched 2026-07-26: [CoIncrementMTAUsage](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coincrementmtausage), [CoInitializeEx](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex), [UIA threading](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-threading), [SetProcessDpiAwarenessContext](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setprocessdpiawarenesscontext), [DLL best practices](https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-best-practices). +- crates.io and docs.rs, fetched 2026-07-26: `windows-sys` 0.61.2 feature list and function inventory; `uiautomation` 0.25.0 constructor documentation. +- Measured locally 2026-07-26: `cargo tree -p agent-desktop --target x86_64-pc-windows-msvc -i windows-sys`; clippy over the four crates (exit 0); release binary 1,920,512 B.