diff --git a/.github/workflows/protocol-conformance.yml b/.github/workflows/protocol-conformance.yml
new file mode 100644
index 00000000..0fda5244
--- /dev/null
+++ b/.github/workflows/protocol-conformance.yml
@@ -0,0 +1,49 @@
+name: Protocol Conformance
+
+# Fast gate for the WDXP protocol contract (protocol/). Pure net10.0, zero-dependency, so it runs
+# on hosted Linux CI. Runs on every PR to the target branches (no path filters) so it can be marked
+# a required status check in branch protection.
+on:
+ push:
+ branches: [ "winui-devex" ]
+ pull_request:
+ branches: [ "winui-devex", "main" ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ conformance:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Install .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ # schema-valid + Gate 3 (facade + field totality) + golden traces (recursive) + coverage.
+ # Exit 0 == all checks green.
+ - name: Run conformance suite
+ run: dotnet run --project protocol/conformance/Wdxp.Conformance.csproj -c Release
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+
+ - name: Validate canonical schema (JSON-Schema draft 2020-12 guard)
+ run: |
+ python -m pip install --quiet jsonschema
+ python protocol/scripts/validate-schema.py
+
+ - name: Check license headers
+ shell: pwsh
+ run: ./protocol/scripts/check-license-headers.ps1
+
+ - name: Check public-appropriateness
+ shell: pwsh
+ run: ./protocol/scripts/check-public-appropriateness.ps1
diff --git a/AGENTS.md b/AGENTS.md
index 975139fc..13a9e42f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -101,6 +101,7 @@ Sample & guide tests run via `.github/workflows/test-samples.yml` using a GitHub
| Agent skill templates | `docs/fragments/skills/winapp-cli/` |
| Copilot plugin | `.github/plugin/` |
| Samples | `samples/` (electron, cpp-app, dotnet-app, etc.) |
+| Devtools protocol (W2) | `protocol/` — WDXP schema + generator + conformance fast gate (net10.0) |
## CLI command semantics
diff --git a/protocol/.gitignore b/protocol/.gitignore
new file mode 100644
index 00000000..ca7d2066
--- /dev/null
+++ b/protocol/.gitignore
@@ -0,0 +1,6 @@
+# Build output
+**/bin/
+**/obj/
+
+# Generated facades — reproducible from wdxp.v0.json via `dotnet run --project gen`
+gen/out/
diff --git a/protocol/Protocol.slnx b/protocol/Protocol.slnx
new file mode 100644
index 00000000..bf92c52c
--- /dev/null
+++ b/protocol/Protocol.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/protocol/README.md b/protocol/README.md
new file mode 100644
index 00000000..c3b2a116
--- /dev/null
+++ b/protocol/README.md
@@ -0,0 +1,166 @@
+# WDXP — the WinUI Design-time eXperience Protocol (v0)
+
+**Workstream W2.** This folder is the **contract** the rest of the engine is built against: a
+CDP-shaped, JSON-RPC-framed protocol for reading and mutating a **live WinUI 3 visual tree** from an
+out-of-process client (a CLI, an IDE extension, an AI agent). W1 (daemon), W3 (read
+surface), W5 (hot-reload), W7 (CLI client) and W9 (reference client) all implement or consume
+what is defined here.
+
+Status: **experimental / v0.1.0.** Shapes are stabilizing; the *policy* (families, outcomes, risk
+tiers, provenance honesty) is settled by the nine-agent debate and is meant to hold.
+
+---
+
+## The one idea: author once, generate the rest (DAP-style)
+
+There is exactly **one hand-authored source of truth** — [`wdxp.v0.json`](./wdxp.v0.json). Every
+client-facing surface is **generated** from it. Nobody hand-maintains a second copy of the command
+list.
+
+```
+ wdxp.v0.json ← the ONLY file humans edit
+ (canonical, machine-readable)
+ │
+ ┌──────────┴───────────┐
+ ▼ ▼
+ cli-commands protocol-reference
+ .json .md
+ (W7 CLI) (docs / humans)
+```
+
+This mirrors how **DAP** (`debugAdapterProtocol.json`) and **LSP** (`metaModel.json`) ship: a
+language-neutral JSON contract that every implementation reads, with a small generator producing the
+per-surface facades. We deliberately did **not** invent a DSL (CDP's PDL) or add a TypeSpec/Node
+toolchain — the generator is zero-dependency .NET on the repo's pinned toolchain.
+
+**Why this matters — Gate 3.** Adding a command, event, or field to the protocol must touch **≤ 1
+hand-written surface** (the schema) and flow automatically into the generated facades. The conformance
+suite asserts this as `gate3-facade-totality`: every command and event is present in **both** the CLI
+command-graph and the docs, **and** every declared field is present in the CLI command-graph (the
+structured contract clients bind to) — so a schema field-add can never be silently dropped. If you find
+yourself editing the generator to add a normal command, something is wrong — the generator walks the
+model generically.
+
+---
+
+## Files
+
+| File | Role |
+|---|---|
+| **`wdxp.v0.json`** | **The canonical schema.** 10 domains · 32 commands · 8 events · 14 error codes · 5 risk tiers. Edit this; regenerate everything else. |
+| `wdxp.schema.json` | JSON Schema (draft 2020-12) guard — authoring/IDE aid **and** a CI validation step (`scripts/validate-schema.py`) that structurally validates the canonical file. Its root is closed (`additionalProperties:false`) so a stray top-level key is caught. |
+| `envelope.md` | **Normative framing spec** — transport, message shapes, session, negotiation, cancellation, error taxonomy, security, versioning. Read this to implement a client or the daemon. |
+| `gen/` | The generator (`dotnet run` → emits the facades to `gen/out/`). `Model.cs` = loader + `$ref` resolver; `Program.cs` = validator + emitters. |
+| `golden/*.json` | Golden message traces — the conformance oracle. Realistic request/response/event/error sequences a conformant peer must accept. Every command and event is exercised by at least one trace. |
+| `conformance/` | The **W2 gate**. `dotnet run` → validates the schema, proves Gate-3 facade **+ field** totality, **recursively** conforms the golden traces (framing, `$ref`/enum/array types), and checks golden coverage. Exit 0 = green. |
+| `gen/out/` | Generated facades (git-ignored — reproducible from the schema). |
+| `scripts/` | Gate helpers, all run in CI: `validate-schema.py` (draft 2020-12 guard), `check-license-headers.ps1`, `check-public-appropriateness.ps1`. |
+
+---
+
+## Workflow (verified commands)
+
+```powershell
+# 1. Author: edit wdxp.v0.json (add a domain / command / event / field).
+
+# 2. Generate the facades (CLI + docs):
+cd protocol/gen ; dotnet run -c Debug
+# -> gen/out/cli-commands.json, protocol-reference.md
+
+# 3. Prove it (schema valid + Gate-3 totality + golden traces conform + coverage):
+cd protocol/conformance ; dotnet run -c Debug
+# -> RESULT: PASS (all checks green) [exit 0]
+```
+
+Both projects are pure `net10.0` (no `-windows` TFM, no WinAppSDK) so they build and the gate runs on
+any hosted CI runner — this is a **fast gate**, not a heavy one.
+
+---
+
+## The family map (the 10 domains)
+
+Grouped by the debate's guaranteed **floor** vs. the mutation/best-effort surfaces above it.
+
+| Domain | Capability | Cmds/Evts | Stability | What it does |
+|---|---|---|---|---|
+| `WDXP` | `core` | 2 / 1 | stable | `negotiate` (capability handshake), `cancel`; `sessionClosing`. |
+| `Target` | `target` | 4 / 2 | stable | Enumerate/attach/detach live app targets; attach/detach events. |
+| `VisualTree` | `visualtree` | 5 / 1 | stable | **Floor.** Enumerate the tree, get children, resolve handles; `childrenChanged`. |
+| `Property` | `property` | 4 / 0 | stable | **Floor.** Read/write properties with a 7-value precedence `ValueSource`. |
+| `Resource` | `resource` | 2 / 0 | stable | Resolve `{ThemeResource}` / `{StaticResource}` values. |
+| `Source` | `source` | 2 / 0 | stable | **Confidence-graded** element→source mapping. Best-effort by design (see below). |
+| `Diagnostics` | `diagnostics` | 2 / 1 | stable | Structured reason codes (incl. `release-no-line-info`); a diagnostics stream. |
+| `HotReload` | `hotreload` | 5 / 1 | **experimental** | Transacted mutation with a 10-state `TransactionState` (incl. `refused-unsafe`). |
+| `Selection` | `selection` | 3 / 1 | **experimental** | Out-of-proc select/overlay/highlight; selection events. |
+| `Security` | `security` | 3 / 1 | **experimental** | Consent, risk-tier gating, audit; security events. |
+
+**The floor vs. the flourish (a debate outcome you must not soften):** `VisualTree` + `Property`
+reads are the **guaranteed floor** — every conformant client gets tree + property sight. `Source`
+(select-to-source) is **demoted to confidence-graded best-effort**: source line-info is
+compiler-emitted, `DisableXbfLineInfo`/env-gated, **stripped in Release**, and absent for
+templated/virtualized/`x:Bind`-Fn elements. The Gate-1 source-resolution census confirmed this
+empirically and it is baked into `Source.SourceSpan` (`uri` durable; `lineInfo:false` when line
+info is unavailable) and `Diagnostics.ReasonCode`.
+
+---
+
+## The normative enums (the debate's crown jewels)
+
+These carry the hard-won policy. Do not weaken them without re-opening the debate:
+
+- **`Property.ValueSource`** — 7-value precedence (`local` → `animation` → `template` →
+ `style` → `resource` → `inherited` → `default`). Clients must surface *where* a value came from.
+- **`Source.SourceKind`** — 8 values including the honest `runtime-only` (no source backing).
+- **`HotReload.TransactionState`** — 10 states including `refused-unsafe` (the engine may decline an
+ unsafe apply) and the four applied/inert outcomes.
+- **`Diagnostics.ReasonCode`** — structured, machine-actionable failure reasons.
+- **Protocol-level `Outcome`** — the four-outcome classifier (`applied` / `applied-inert` /
+ `reloaded` / `needs-restart`) reported by a **transactional** apply/commit (`HotReload.commit`/`apply`,
+ and predicted in a `plan`) — the **honesty invariant**: the engine never claims success it can't
+ guarantee. Ephemeral `Property.set`/`setPreview` have no transaction to classify and instead return the
+ resulting `PropertyValue`, not an `Outcome`.
+- **Protocol-level `RiskTier`** — `read` / `mutate-ephemeral` / `structural` / `persist` /
+ `privileged` — drives the `Security` consent gates.
+
+---
+
+## Framing, grounded in the proven substrate
+
+The envelope is **not** new: it is the proven transport that already round-trips as
+newline-delimited **JSON-RPC 2.0**
+(requests/responses + server-initiated notifications) over a `PipeOptions.CurrentUserOnly` named
+pipe. `envelope.md` is the normative write-up of that mechanism; the golden traces are expressed in
+it. A **session is a connection**; capabilities are negotiated per-connection via `WDXP.negotiate`.
+
+---
+
+## Versioning & stability policy
+
+- **Additive is safe:** new domains/commands/events/optional-fields bump the **minor** version and
+ never break a negotiated client. This is the Gate-3 property in practice.
+- **Breaking is loud:** removing/renaming a command, changing a field type, or making an optional
+ field required bumps the **major** version and requires a new `WDXP.negotiate` capability entry.
+- Per-domain `stability` (`stable` / `experimental`) tells clients what may still move. `HotReload`,
+ `Selection`, `Security` are `experimental` in v0 on purpose.
+- Full rules in [`envelope.md` §8](./envelope.md).
+
+---
+
+## Handoff notes for winapp-repo parallel agents
+
+- **This is portable.** `wdxp.v0.json`, `envelope.md`, the golden traces, and the two `net10.0`
+ projects move to the winapp CLI repo's `winui-devex` branch as-is. Nothing here depends on WinUI,
+ Windows, or the TAP — it is the pure contract layer, provable on hosted CI.
+- **Consume, don't re-derive.** If you are building:
+ - **W1 (daemon):** implement `envelope.md` framing + `WDXP.negotiate`/`cancel`; treat the schema's
+ capability list as the negotiation vocabulary.
+ - **W3 (read surface):** implement the `VisualTree` + `Property` + `Resource` floor against the
+ golden traces `02-read-floor.json`.
+ - **W5 (hot-reload):** implement `HotReload` honoring `TransactionState` + the `Outcome` invariant;
+ never report `applied` for an inert change (that is `applied-inert`).
+ - **W7 (CLI):** **generate** your surface from `gen/` — do not hand-write a command list.
+ CLI path = ``.
+ - **W8 (security):** the `Security` domain + `RiskTier` are your gate points.
+- **The gate is real.** `protocol/conformance` is wired to be a required fast-gate check. Add a golden
+ trace when you add a family; keep it green. A schema change that doesn't flow to every generated
+ facade will fail `gate3-facade-totality`.
diff --git a/protocol/conformance/Program.cs b/protocol/conformance/Program.cs
new file mode 100644
index 00000000..807f3702
--- /dev/null
+++ b/protocol/conformance/Program.cs
@@ -0,0 +1,362 @@
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// wdxp-conformance: the W2 conformance suite, runnable now with `dotnet run`:
+// 1. schema-valid — the schema is structurally valid (reuses the generator's Validator);
+// 2. gate3-facade-totality — every command/event/field flows to EVERY generated facade, so a
+// schema field-add can never be silently dropped from a surface;
+// 3. golden:* — the golden traces conform: JSON-RPC framing (jsonrpc 2.0, string ids
+// correlated across request/response/error, result XOR error) and message payloads validated
+// RECURSIVELY against the contract (nested $ref objects, enum value sets, array element types —
+// not merely top-level field presence);
+// 4. golden-coverage — every command and event is exercised by at least one golden trace.
+// Exit 0 = all green. This is the fast-gate oracle; the live-substrate replay against a real target
+// is a later step that needs the W1 daemon.
+using System.Text.Json;
+using Wdxp.Gen;
+
+string? schemaPath = SchemaPaths.FindUp("wdxp.v0.json");
+if (schemaPath is null) { Console.Error.WriteLine("error: could not locate wdxp.v0.json"); return 2; }
+string goldenDir = Path.Combine(Path.GetDirectoryName(schemaPath)!, "golden");
+
+var protocol = SchemaLoader.Load(schemaPath);
+var resolver = new RefResolver(protocol);
+var checks = new List<(string Name, bool Pass, string Detail)>();
+
+// ---- Check 1: schema structurally valid ----
+var schemaErrors = Validator.Validate(protocol, resolver);
+checks.Add(("schema-valid", schemaErrors.Count == 0,
+ schemaErrors.Count == 0 ? $"{protocol.Domains.Count} domains, {protocol.Domains.Sum(d => d.Commands.Count)} commands"
+ : string.Join("; ", schemaErrors)));
+
+// ---- Check 2: Gate 3 — both facades are total over the schema ----
+checks.Add(Gate3(protocol));
+
+// ---- Check 3: golden traces conform (recursively) ----
+var commands = protocol.Domains
+ .SelectMany(d => d.Commands.Select(c => ($"{d.Name}.{c.Name}", (Cmd: c, Dom: d))))
+ .ToDictionary(x => x.Item1, x => x.Item2, StringComparer.Ordinal);
+var events = protocol.Domains
+ .SelectMany(d => d.Events.Select(e => ($"{d.Name}.{e.Name}", (Evt: e, Dom: d))))
+ .ToDictionary(x => x.Item1, x => x.Item2, StringComparer.Ordinal);
+var errorCodes = LoadErrorCodes(schemaPath);
+
+if (!Directory.Exists(goldenDir))
+ checks.Add(("golden-present", false, $"no golden dir at {goldenDir}"));
+else
+{
+ var goldenFiles = Directory.EnumerateFiles(goldenDir, "*.json").OrderBy(f => f, StringComparer.Ordinal).ToList();
+ if (goldenFiles.Count == 0)
+ checks.Add(("golden-present", false, $"golden dir {goldenDir} has no *.json traces — nothing to conform"));
+ foreach (var file in goldenFiles)
+ checks.Add(ConformTrace(file, commands, events, errorCodes, resolver));
+
+ // ---- Check 4: every command and event is exercised by at least one golden trace ----
+ if (goldenFiles.Count > 0)
+ checks.Add(GoldenCoverage(protocol, goldenFiles));
+}
+
+// ---- Report ----
+Console.WriteLine("WDXP conformance");
+Console.WriteLine(new string('-', 60));
+bool allPass = true;
+foreach (var (name, pass, detail) in checks)
+{
+ allPass &= pass;
+ Console.WriteLine($" [{(pass ? "PASS" : "FAIL")}] {name,-26} {detail}");
+}
+Console.WriteLine(new string('-', 60));
+Console.WriteLine($"RESULT: {(allPass ? "PASS" : "FAIL")} ({checks.Count(c => c.Pass)}/{checks.Count} checks)");
+return allPass ? 0 : 1;
+
+// -------------------------------------------------------------------------------------------------
+
+static (string, bool, string) Gate3(Protocol p)
+{
+ var errs = new List();
+ var schemaCommands = p.Domains.SelectMany(d => d.Commands.Select(c => $"{d.Name}.{c.Name}")).ToHashSet(StringComparer.Ordinal);
+ var schemaEvents = p.Domains.SelectMany(d => d.Events.Select(e => $"{d.Name}.{e.Name}")).ToHashSet(StringComparer.Ordinal);
+
+ // Facade 1: the CLI command-graph (structured). Commands and events (as notifications) must both be total.
+ using var cli = JsonDocument.Parse(JsonSerializer.Serialize(Emit.Cli(p)));
+ var cliMethods = cli.RootElement.GetProperty("commands").EnumerateArray().Select(e => e.GetProperty("method").GetString()!).ToHashSet(StringComparer.Ordinal);
+ var cliNotifs = cli.RootElement.GetProperty("notifications").EnumerateArray().Select(e => e.GetProperty("method").GetString()!).ToHashSet(StringComparer.Ordinal);
+
+ // Facade 2: the human-readable docs reference (generated from the same model).
+ var docs = Emit.Docs(p);
+
+ foreach (var m in schemaCommands)
+ {
+ if (!cliMethods.Contains(m)) errs.Add($"command '{m}' missing from CLI facade");
+ if (!docs.Contains($"`{m}`", StringComparison.Ordinal)) errs.Add($"command '{m}' missing from docs facade");
+ }
+ foreach (var e in schemaEvents)
+ {
+ if (!cliNotifs.Contains(e)) errs.Add($"event '{e}' missing from CLI facade notifications");
+ if (!docs.Contains($"`{e}`", StringComparison.Ordinal)) errs.Add($"event '{e}' missing from docs facade");
+ }
+
+ if (cliMethods.Count != schemaCommands.Count) errs.Add($"CLI facade command count {cliMethods.Count} != schema {schemaCommands.Count}");
+ if (cliNotifs.Count != schemaEvents.Count) errs.Add($"CLI facade notification count {cliNotifs.Count} != schema {schemaEvents.Count}");
+
+ // Field-level totality against the CLI facade (the structured contract every client binds to):
+ // every declared param/return field must be emitted for its command/event, and the facade may not
+ // invent an undeclared field. This gives Gate 3 real teeth beyond method presence — a schema
+ // field-add can never be silently dropped from (nor a stray field smuggled into) the surface.
+ var cliByMethod = cli.RootElement.GetProperty("commands").EnumerateArray()
+ .ToDictionary(e => e.GetProperty("method").GetString()!, e => e, StringComparer.Ordinal);
+ var cliByNotif = cli.RootElement.GetProperty("notifications").EnumerateArray()
+ .ToDictionary(e => e.GetProperty("method").GetString()!, e => e, StringComparer.Ordinal);
+ int fieldCount = 0;
+ foreach (var d in p.Domains)
+ {
+ foreach (var c in d.Commands)
+ {
+ var method = $"{d.Name}.{c.Name}";
+ if (!cliByMethod.TryGetValue(method, out var node)) continue; // presence already reported above
+ fieldCount += CheckCliFields(node, "args", c.Parameters, $"command '{method}' params", errs);
+ fieldCount += CheckCliFields(node, "returns", c.Returns, $"command '{method}' returns", errs);
+ }
+ foreach (var ev in d.Events)
+ {
+ var method = $"{d.Name}.{ev.Name}";
+ if (!cliByNotif.TryGetValue(method, out var node)) continue;
+ fieldCount += CheckCliFields(node, "args", ev.Parameters, $"event '{method}' params", errs);
+ }
+ }
+
+ return ("gate3-facade-totality", errs.Count == 0,
+ errs.Count == 0
+ ? $"{schemaCommands.Count} commands + {schemaEvents.Count} events in every facade; {fieldCount} fields total in CLI facade"
+ : string.Join("; ", errs));
+}
+
+// Assert the CLI facade emits exactly the declared field set (by name) for one command/event arg list.
+// Returns the number of declared fields checked (for the totality tally).
+static int CheckCliFields(JsonElement node, string prop, IReadOnlyList declared, string where, List errs)
+{
+ var emitted = node.GetProperty(prop).EnumerateArray()
+ .Select(a => a.GetProperty("name").GetString()!).ToHashSet(StringComparer.Ordinal);
+ foreach (var f in declared)
+ if (!emitted.Contains(f.Name)) errs.Add($"field '{f.Name}' of {where} missing from CLI facade");
+ foreach (var name in emitted)
+ if (declared.All(f => f.Name != name)) errs.Add($"CLI facade emits undeclared field '{name}' in {where}");
+ return declared.Count;
+}
+
+static (string, bool, string) ConformTrace(string file,
+ IReadOnlyDictionary commands,
+ IReadOnlyDictionary events,
+ IReadOnlyDictionary errorCodes, RefResolver resolver)
+{
+ var errs = new List();
+ using var doc = JsonDocument.Parse(File.ReadAllText(file));
+ var root = doc.RootElement;
+ string scenario = root.TryGetProperty("scenario", out var s) ? s.GetString()! : Path.GetFileNameWithoutExtension(file);
+ var idToMethod = new Dictionary(StringComparer.Ordinal);
+ int n = 0;
+
+ foreach (var entry in root.GetProperty("messages").EnumerateArray())
+ {
+ n++;
+ var msg = entry.GetProperty("msg");
+ string where = $"{scenario} msg#{n}";
+
+ // Every framed message MUST carry jsonrpc 2.0 (not merely "if present, correct").
+ if (!msg.TryGetProperty("jsonrpc", out var v) || v.ValueKind != JsonValueKind.String || v.GetString() != "2.0")
+ errs.Add($"{where}: every message MUST carry \"jsonrpc\":\"2.0\"");
+
+ bool hasResult = msg.TryGetProperty("result", out var result);
+ bool hasError = msg.TryGetProperty("error", out var error);
+ bool hasMethod = msg.TryGetProperty("method", out var methodEl);
+ bool hasId = msg.TryGetProperty("id", out var idEl);
+
+ // WDXP pins string ids protocol-wide (envelope §2) so every id is uniformly correlatable.
+ if (hasId && idEl.ValueKind != JsonValueKind.String)
+ errs.Add($"{where}: id must be a JSON string (WDXP pins string ids)");
+ string? id = hasId && idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null;
+
+ if (hasResult && hasError)
+ {
+ errs.Add($"{where}: a response carries BOTH 'result' and 'error' (exactly one is allowed)");
+ }
+ else if (hasError) // error response — correlates to a prior request, like a result
+ {
+ if (id is null || !idToMethod.ContainsKey(id))
+ errs.Add($"{where}: error response for id '{id}' has no prior request");
+ if (!error.TryGetProperty("code", out var codeEl) || codeEl.ValueKind != JsonValueKind.Number)
+ errs.Add($"{where}: error is missing an integer 'code'");
+ else
+ {
+ int code = codeEl.GetInt32();
+ if (!errorCodes.TryGetValue(code, out var declaredName))
+ errs.Add($"{where}: undeclared error code {code}");
+ else if (error.TryGetProperty("name", out var nm) && nm.GetString() != declaredName)
+ errs.Add($"{where}: error name '{nm.GetString()}' != declared '{declaredName}' for code {code}");
+ }
+ }
+ else if (hasResult) // success response
+ {
+ if (id is null || !idToMethod.TryGetValue(id, out var method))
+ errs.Add($"{where}: result for id '{id}' has no prior request");
+ else if (commands.TryGetValue(method, out var c))
+ ValidateObject(result, c.Cmd.Returns, c.Dom, resolver, $"{where} result[{method}]", errs, 0);
+ }
+ else if (hasMethod && hasId) // request
+ {
+ string method = methodEl.GetString()!;
+ if (id is not null) idToMethod[id] = method;
+ if (!commands.TryGetValue(method, out var c))
+ errs.Add($"{where}: unknown command '{method}'");
+ else
+ {
+ var pars = msg.TryGetProperty("params", out var pe) ? pe : default;
+ ValidateObject(pars, c.Cmd.Parameters, c.Dom, resolver, $"{where} params[{method}]", errs, 0);
+ }
+ }
+ else if (hasMethod) // notification / event
+ {
+ string method = methodEl.GetString()!;
+ if (!events.TryGetValue(method, out var e))
+ errs.Add($"{where}: unknown event '{method}'");
+ else
+ {
+ var pars = msg.TryGetProperty("params", out var pe) ? pe : default;
+ ValidateObject(pars, e.Evt.Parameters, e.Dom, resolver, $"{where} event[{method}]", errs, 0);
+ }
+ }
+ else errs.Add($"{where}: unclassifiable message");
+ }
+
+ if (n == 0) errs.Add($"{scenario}: golden trace has no messages (empty or missing 'messages' array) — nothing to conform");
+
+ return ($"golden:{Path.GetFileName(file)}", errs.Count == 0, errs.Count == 0 ? $"{n} messages conform" : string.Join("; ", errs));
+}
+
+// Recursively validate a JSON object against a declared field list: no unknown fields, all required
+// fields present, and every present field's value validated against its declared type — $ref objects
+// recurse into their properties, enums are checked against their value set, and arrays element-wise.
+// This is what makes the golden gate real: a nested payload can't silently drift from the contract.
+static void ValidateObject(JsonElement obj, IReadOnlyList declared, Domain? ctx, RefResolver r,
+ string where, List errs, int depth)
+{
+ if (obj.ValueKind != JsonValueKind.Object)
+ {
+ // An omitted/empty object is only an error when a required field was expected
+ // (zero-parameter commands may omit `params` entirely — envelope §2).
+ foreach (var f in declared)
+ if (!f.Optional) errs.Add($"{where}: missing required field '{f.Name}'");
+ return;
+ }
+ if (depth > 64) { errs.Add($"{where}: type nesting too deep"); return; }
+
+ var byName = declared.ToDictionary(f => f.Name, StringComparer.Ordinal);
+ foreach (var prop in obj.EnumerateObject())
+ if (!byName.ContainsKey(prop.Name))
+ errs.Add($"{where}: unknown field '{prop.Name}'");
+
+ foreach (var f in declared)
+ {
+ if (!obj.TryGetProperty(f.Name, out var val))
+ {
+ if (!f.Optional) errs.Add($"{where}: missing required field '{f.Name}'");
+ continue;
+ }
+ ValidateValue(val, f, ctx, r, $"{where}.{f.Name}", errs, depth);
+ }
+}
+
+// Validate one field value: arrays are checked element-wise, everything else as a scalar.
+static void ValidateValue(JsonElement v, Field f, Domain? ctx, RefResolver r, string where, List errs, int depth)
+{
+ if (f.Array)
+ {
+ if (v.ValueKind != JsonValueKind.Array) { errs.Add($"{where}: expected an array"); return; }
+ int i = 0;
+ foreach (var el in v.EnumerateArray())
+ ValidateScalar(el, f, ctx, r, $"{where}[{i++}]", errs, depth);
+ return;
+ }
+ ValidateScalar(v, f, ctx, r, where, errs, depth);
+}
+
+// Validate a single (non-array) value against its declared type: resolve $refs to enum/object/primitive.
+static void ValidateScalar(JsonElement v, Field f, Domain? ctx, RefResolver r, string where, List errs, int depth)
+{
+ if (f.Ref is not null)
+ {
+ var (td, owner) = r.ResolveWithOwner(f.Ref, ctx);
+ if (td is null) { errs.Add($"{where}: unresolved $ref '{f.Ref}'"); return; }
+ switch (td.Kind)
+ {
+ case "enum":
+ if (v.ValueKind != JsonValueKind.String || !td.Values.Contains(v.GetString()!))
+ errs.Add($"{where}: {Describe(v)} is not a valid {f.Ref} value (one of: {string.Join("|", td.Values)})");
+ break;
+ case "object":
+ ValidateObject(v, td.Properties, owner, r, where, errs, depth + 1);
+ break;
+ case "primitive":
+ CheckPrimitive(v, td.Primitive, f.Ref, where, errs);
+ break;
+ }
+ return;
+ }
+ CheckPrimitive(v, f.Type, f.Type ?? "value", where, errs);
+}
+
+static void CheckPrimitive(JsonElement v, string? baseType, string label, string where, List errs)
+{
+ bool ok = baseType switch
+ {
+ "string" => v.ValueKind == JsonValueKind.String,
+ "integer" or "number" => v.ValueKind == JsonValueKind.Number,
+ "boolean" => v.ValueKind is JsonValueKind.True or JsonValueKind.False,
+ "object" => v.ValueKind == JsonValueKind.Object,
+ _ => true, // no/unknown base type: don't over-constrain
+ };
+ if (!ok) errs.Add($"{where}: expected {label}, got {Describe(v)}");
+}
+
+static string Describe(JsonElement v) => v.ValueKind switch
+{
+ JsonValueKind.String => $"\"{v.GetString()}\"",
+ JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.GetRawText(),
+ _ => v.ValueKind.ToString().ToLowerInvariant(),
+};
+
+// Coverage: every command and event must be exercised (as a request/notification `method`) by at
+// least one golden trace. Results correlate by id, so a command's presence is proven by its request.
+static (string, bool, string) GoldenCoverage(Protocol p, IReadOnlyList files)
+{
+ var used = new HashSet(StringComparer.Ordinal);
+ foreach (var file in files)
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(file));
+ if (!doc.RootElement.TryGetProperty("messages", out var msgs) || msgs.ValueKind != JsonValueKind.Array) continue;
+ foreach (var entry in msgs.EnumerateArray())
+ if (entry.TryGetProperty("msg", out var msg) && msg.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
+ used.Add(m.GetString()!);
+ }
+
+ var errs = new List();
+ foreach (var d in p.Domains)
+ {
+ foreach (var c in d.Commands)
+ if (!used.Contains($"{d.Name}.{c.Name}")) errs.Add($"command '{d.Name}.{c.Name}' not exercised by any golden trace");
+ foreach (var e in d.Events)
+ if (!used.Contains($"{d.Name}.{e.Name}")) errs.Add($"event '{d.Name}.{e.Name}' not exercised by any golden trace");
+ }
+
+ int total = p.Domains.Sum(d => d.Commands.Count + d.Events.Count);
+ return ("golden-coverage", errs.Count == 0,
+ errs.Count == 0 ? $"all {total} commands+events exercised by golden traces" : string.Join("; ", errs));
+}
+
+static Dictionary LoadErrorCodes(string schemaPath)
+{
+ using var doc = JsonDocument.Parse(File.ReadAllText(schemaPath));
+ var map = new Dictionary();
+ foreach (var e in doc.RootElement.GetProperty("errorCodes").EnumerateArray())
+ map[e.GetProperty("code").GetInt32()] = e.GetProperty("name").GetString()!;
+ return map;
+}
diff --git a/protocol/conformance/Wdxp.Conformance.csproj b/protocol/conformance/Wdxp.Conformance.csproj
new file mode 100644
index 00000000..1e6bfa00
--- /dev/null
+++ b/protocol/conformance/Wdxp.Conformance.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ wdxp-conformance
+ Wdxp.Conformance
+ true
+
+
+
+
+
+
+
diff --git a/protocol/envelope.md b/protocol/envelope.md
new file mode 100644
index 00000000..e19731c5
--- /dev/null
+++ b/protocol/envelope.md
@@ -0,0 +1,172 @@
+
+# WDXP envelope — the normative framing spec
+
+This is the wire contract that carries every WDXP domain. The domains (methods, events, types)
+live in [`wdxp.v0.json`](./wdxp.v0.json); this document specifies how those messages are framed,
+routed, negotiated, cancelled, and how errors are reported. It is **normative**: a conformant engine
+or client MUST follow it.
+
+It is grounded in the proven substrate, not invented: the round-trip below is exactly what
+the proven transport already demonstrates — newline-delimited JSON-RPC 2.0 with a
+request/response pair **and** a server-initiated notification over a `CurrentUserOnly` named pipe.
+
+---
+
+## 1. Transport
+
+- **Named pipe**, per target: `wdxp-` (the injected engine is the server; clients connect).
+- **`PipeOptions.CurrentUserOnly`** — the OS enforces same-user access. This is the first line of the
+ security model (see §7); it is not optional, because the channel can trigger live app mutation.
+- **UTF-8, no BOM.** One JSON-RPC message per line; `\n` terminates a message. Messages MUST NOT
+ contain a raw newline (JSON string escaping handles embedded newlines). No `Content-Length` framing —
+ the delimiter is the newline (this is the proof-of-concept-proven framing, and it keeps the CLI trivially
+ scriptable with line-oriented tools).
+- **Discovery is out of band (for now).** The pipe name embeds the target pid, so opening the pipe already
+ selects one engine. Enumerating *which* pids are attachable — a machine-wide broker/discovery endpoint —
+ is **deferred to W1 (phase 3)**; until it lands a launcher supplies the pid (e.g. from `run --inspect`).
+ Consequently `Target.list` on a `wdxp-` pipe returns **only that one target** (see §3).
+
+## 2. Message shapes (JSON-RPC 2.0)
+
+Three message kinds, all with `"jsonrpc": "2.0"`:
+
+**Request** (client → engine, or engine → client for reverse calls like `Selection.pick`):
+```json
+{ "jsonrpc": "2.0", "id": "42", "method": "VisualTree.search", "params": { "name": "Title" } }
+```
+- `id` is a **string**, unique per connection while in flight. WDXP pins **string ids protocol-wide** so
+ every id is uniformly correlatable and cancellable — a numeric id can exceed 2^53 and lose precision as a
+ JSON number. A request always gets exactly one response with the same `id`; `WDXP.cancel` takes that same string.
+- `method` is `"."` (dot-qualified, matching `wdxp.v0.json`).
+- `params` is an **object** keyed by the parameter `name`s in the schema (named params, not positional).
+ Omit `params` for zero-parameter commands.
+
+**Response** (success):
+```json
+{ "jsonrpc": "2.0", "id": "42", "result": { "matches": [ { "handle": "12", "generation": 1 } ] } }
+```
+- `result` is an **object** keyed by the command's `returns` field `name`s. A command with an empty
+ `returns` still returns `"result": {}` on success.
+- **Opaque handles are strings.** A `NodeHandle.handle` is a uint64 encoded as a decimal string (e.g.
+ `"handle": "42"`), never a JSON number, so values above 2^53 keep full precision. Treat it as opaque —
+ correlate and compare it, never do arithmetic on it.
+
+**Response** (error) — see §6:
+```json
+{ "jsonrpc": "2.0", "id": "42", "error": { "code": -32001, "name": "StaleHandle", "message": "..." } }
+```
+
+**Notification** (engine → client, no `id`, no response):
+```json
+{ "jsonrpc": "2.0", "method": "VisualTree.childrenChanged",
+ "params": { "node": { "handle": "3", "generation": 2 }, "added": [], "removed": [] } }
+```
+- `method` is `"."`. Events are one-way; a client subscribes via the domain's
+ `subscribe` command and receives these until it `unsubscribe`s or the session closes.
+
+## 3. Session = connection
+
+There is **no separate session object**: one pipe connection is one session. `Target.attach` binds the
+connection to exactly one target, so "which app" is the connection, never a per-call argument. This is
+why no command takes a target/window selector the way `winapp ui` does with `-a`/`-w`.
+
+- A connection begins **unattached**. Only `WDXP.*` and `Target.list`/`Target.attach` (and
+ `Security.authenticate`) are valid before attach. Because the pipe is already per-target (§1),
+ `Target.list` here returns just the engine you connected to; machine-wide discovery across pipes is a
+ broker concern **deferred to W1 (phase 3)**.
+- **Pre-auth trust boundary.** Binding a target before `Security.authenticate` is gated solely by the
+ `CurrentUserOnly` pipe ACL (§1): only the same OS user can open the pipe, so a connected-but-unauthenticated
+ client is already proven same-user. On that basis `attach` grants **read/attach** reach; privileged families
+ (persist/privileged tiers) still require `Security.authenticate` + `Security.grant`. The exact pre-auth
+ capability set is finalized in W8.
+- `Target.attach` performs **loud-fail-before-unsafe-attach**: on version mismatch, missing capability,
+ or a missing required runtime component, it returns an error and leaves the connection unattached — it never
+ half-attaches.
+- `Target.reconnect` re-binds a dropped transport to a still-living target, preserving session identity
+ (the proof-of-concept regression oracle: a handle from op-1 must resolve at op-20, including across a reconnect).
+
+## 4. Capability negotiation
+
+`WDXP.negotiate` is the first call on a session. The client sends the capabilities and max versions it
+understands; the engine replies with the **intersection** it will honor. Thereafter:
+
+- Calling a command in a **non-negotiated** capability returns `CapabilityUnsupported` (-32003) — a
+ clean, typed refusal, **never** a crash or a silent no-op.
+- Capabilities are **versioned independently** (`{ "name": "visualtree", "version": "0.1.0" }`). A domain
+ may advance its version without bumping the whole protocol, so surfaces upgrade piecemeal.
+- `stability` (`stable` | `experimental`) travels with each capability so a client can refuse to bind an
+ experimental family in production.
+
+The negotiated set is also the **authorization surface**: `Security.grant` grants by capability family,
+so negotiation and consent share one vocabulary.
+
+## 5. Cancellation
+
+`WDXP.cancel { "id": "" }` requests best-effort cancellation. Long reads (deep
+`enumerate`) and applies (`HotReload.commit`) MUST check for cancellation at safe points and, if
+cancelled, complete the original request with error `Cancelled` (-32008). Cancellation never leaves the
+app in a torn state: an apply either reaches a defined `TransactionState` or rolls back.
+
+## 6. Error taxonomy
+
+Errors are **structured and typed**, not free text — agents branch on `code`/`name`. The `error` object
+is `{ code, name, message, data? }`. Codes are fixed in `wdxp.v0.json` (`errorCodes`); the canonical set:
+
+| Range | Meaning |
+|---|---|
+| `-32700 … -32603` | Standard JSON-RPC (parse / invalid request / method-not-found / invalid-params / internal). |
+| `-32000 … -32008` | WDXP application errors. |
+
+WDXP application errors and how a client should react:
+
+| code | name | recoverable | client action |
+|---|---|:--:|---|
+| -32000 | `TargetLost` | no | The app exited; re-`attach`. |
+| -32001 | `StaleHandle` | yes | Re-`enumerate`; handles carry a fresh `generation`. |
+| -32002 | `NotOnDispatcher` | yes | Engine bug — a mutation was routed off the UI dispatcher (the `RPC_E_WRONG_THREAD` analog). Clients should never see this; the engine MUST marshal. |
+| -32003 | `CapabilityUnsupported` | yes | Negotiate the capability, or degrade. |
+| -32004 | `Unauthorized` | yes | Request a grant (`Security.grant`) or re-`authenticate`. |
+| -32005 | `RefusedUnsafe` | yes | The edit was classified unsafe to apply in place; fall back to scoped reload. |
+| -32006 | `UnreachableGate` | yes | An apply gate (e.g. render verify) was not reached; retry or restart. |
+| -32007 | `SourceUnavailable` | yes | No provenance for this element; use the `Anchor`. Expected for runtime-only/stripped elements — not a failure to fear. |
+| -32008 | `Cancelled` | yes | The request was cancelled via `WDXP.cancel`. |
+
+**Honesty invariant.** The engine MUST NOT report a stronger outcome than it achieved. A **transactional**
+apply or commit (`HotReload.commit` / `HotReload.apply`, and the prediction in a `HotReload.plan`) reports
+one of the four `Outcome`s (`applied` / `applied-inert` / `reloaded` / `needs-restart`) plus a real
+`TransactionState` including the honest terminal-failure states (`refused-unsafe`, `stale-handle`,
+`target-lost`, `unreachable-gate`). An **ephemeral** mutation (`Property.set` / `Property.setPreview`) has no
+transaction to classify, so it instead returns the resulting `PropertyValue` with its live value-source. Either
+way these are **results, not exceptions** — they arrive as a normal `result`, not an `error`, because they are
+expected outcomes an agent branches on.
+
+## 7. Security posture (framing-level)
+
+Full policy is the Security domain (W8); the envelope pins the parts that live at the wire:
+
+- `CurrentUserOnly` pipe ACL + same-user process auth (§1) — this is the pre-auth trust boundary (§3).
+- `Security.authenticate` issues a **per-session `token`** (returned to the client, with an optional
+ `expiresAt`) bound to the resolved host identity; a client presents it on `Target.reconnect` or to
+ re-authenticate. Token issuance, any nonce, and replay-prevention policy are **host-defined and finalized
+ in W8** — the envelope pins only that a same-user-scoped token exists (the pipe ACL is what proves
+ same-user). Every command then carries the session's authorization implicitly (it is the connection).
+- Every command declares a **risk tier** (`read` / `mutate-ephemeral` / `structural` / `persist` /
+ `privileged`). Tiers ≥ `persist` require **explicit** consent, not the session default.
+- All non-`read` commands are written to a **tamper-evident local audit** (`Security.audit`).
+
+## 8. Versioning & stability
+
+- The whole file carries a SemVer `version`; each capability carries its own SemVer.
+- `experimental` domains/commands may change shape between minor versions; `stable` ones may only add.
+- **Additive rule (Gate 3):** adding a field/command/event to `wdxp.v0.json` MUST flow to every
+ generated surface (CLI command-graph, docs) with **zero** hand edits. The conformance suite enforces
+ this: every command and event must appear in every facade, and every declared field must appear in the
+ CLI command-graph — so a field-add can never be silently dropped from the surface clients bind to. If a
+ change needs a manual edit in more than one generated surface, the codegen — not the schema — is wrong.
+
+---
+
+*Reference: the proven transport. Generated facades: `protocol/gen`. Golden traces that exercise this
+envelope: `protocol/golden`.*
diff --git a/protocol/gen/Model.cs b/protocol/gen/Model.cs
new file mode 100644
index 00000000..498ac7c7
--- /dev/null
+++ b/protocol/gen/Model.cs
@@ -0,0 +1,115 @@
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+using System.Text.Json;
+
+namespace Wdxp.Gen;
+
+/// A command/return/property field: exactly one of or is set.
+public sealed record Field(string Name, string? Type, string? Ref, bool Array, bool Optional, string? Summary);
+
+public sealed record TypeDef(string Id, string Kind, string? Summary, IReadOnlyList Values, string? Primitive, IReadOnlyList Properties);
+
+public sealed record Command(string Name, string Risk, string? Stability, string Summary, IReadOnlyList Parameters, IReadOnlyList Returns);
+
+public sealed record EventDef(string Name, string? Stability, string Summary, IReadOnlyList Parameters);
+
+public sealed record Domain(string Name, string Capability, string Stability, string Summary,
+ IReadOnlyList Types, IReadOnlyList Commands, IReadOnlyList Events);
+
+public sealed record Protocol(string Name, string Version, string Stability, string Summary,
+ IReadOnlyList Types, IReadOnlyList Domains);
+
+/// Parses wdxp.v0.json into the model. Nothing here is command-specific — the whole point of
+/// the DAP-style approach is that every surface is generated by walking this one model.
+public static class SchemaLoader
+{
+ public static Protocol Load(string path)
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(path), new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip });
+ var root = doc.RootElement;
+
+ return new Protocol(
+ Name: Str(root, "protocol"),
+ Version: Str(root, "version"),
+ Stability: Str(root, "stability"),
+ Summary: OptStr(root, "summary") ?? "",
+ Types: ReadTypes(root, "types"),
+ Domains: root.GetProperty("domains").EnumerateArray().Select(ReadDomain).ToList());
+ }
+
+ private static Domain ReadDomain(JsonElement d) => new(
+ Name: Str(d, "domain"),
+ Capability: Str(d, "capability"),
+ Stability: Str(d, "stability"),
+ Summary: Str(d, "summary"),
+ Types: ReadTypes(d, "types"),
+ Commands: Arr(d, "commands").Select(ReadCommand).ToList(),
+ Events: Arr(d, "events").Select(ReadEvent).ToList());
+
+ private static Command ReadCommand(JsonElement c) => new(
+ Name: Str(c, "name"),
+ Risk: Str(c, "risk"),
+ Stability: OptStr(c, "stability"),
+ Summary: Str(c, "summary"),
+ Parameters: ReadFields(c, "parameters"),
+ Returns: ReadFields(c, "returns"));
+
+ private static EventDef ReadEvent(JsonElement e) => new(
+ Name: Str(e, "name"),
+ Stability: OptStr(e, "stability"),
+ Summary: Str(e, "summary"),
+ Parameters: ReadFields(e, "parameters"));
+
+ private static List ReadTypes(JsonElement parent, string prop) =>
+ Arr(parent, prop).Select(t => new TypeDef(
+ Id: Str(t, "id"),
+ Kind: Str(t, "kind"),
+ Summary: OptStr(t, "summary"),
+ Values: t.TryGetProperty("values", out var v) ? v.EnumerateArray().Select(x => x.GetString()!).ToList() : new List(),
+ Primitive: OptStr(t, "type"),
+ Properties: ReadFields(t, "properties"))).ToList();
+
+ private static List ReadFields(JsonElement parent, string prop) =>
+ Arr(parent, prop).Select(f => new Field(
+ Name: Str(f, "name"),
+ Type: OptStr(f, "type"),
+ Ref: OptStr(f, "$ref"),
+ Array: f.TryGetProperty("array", out var a) && a.GetBoolean(),
+ Optional: f.TryGetProperty("optional", out var o) && o.GetBoolean(),
+ Summary: OptStr(f, "summary"))).ToList();
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1859", Justification = "Two branches return different concrete enumerables; the interface is the honest return type.")]
+ private static IEnumerable Arr(JsonElement parent, string prop) =>
+ parent.TryGetProperty(prop, out var e) && e.ValueKind == JsonValueKind.Array ? e.EnumerateArray() : [];
+
+ private static string Str(JsonElement e, string prop) => e.GetProperty(prop).GetString()!;
+ private static string? OptStr(JsonElement e, string prop) => e.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+}
+
+/// Resolves a $ref ("TypeName" domain-local | "Domain.TypeName" cross-domain,
+/// falling back to protocol-level types) against the loaded model.
+public sealed class RefResolver
+{
+ // Owner is null for protocol-level types; the owning Domain otherwise (needed to inline nested short $refs).
+ private readonly Dictionary _byQualified = new(StringComparer.Ordinal);
+ private readonly Dictionary _protocol = new(StringComparer.Ordinal);
+
+ public RefResolver(Protocol p)
+ {
+ foreach (var t in p.Types) _protocol[t.Id] = (t, null); // protocol-level, unqualified
+ foreach (var d in p.Domains)
+ foreach (var t in d.Types) _byQualified[$"{d.Name}.{t.Id}"] = (t, d); // domain-qualified
+ }
+
+ /// Resolves within first, then cross-domain "Domain.Type",
+ /// then protocol-level. Returns (null, null) when unresolved (a validation error).
+ public (TypeDef? Td, Domain? Owner) ResolveWithOwner(string @ref, Domain? currentDomain)
+ {
+ if (@ref.Contains('.'))
+ return _byQualified.TryGetValue(@ref, out var q) ? q : (null, null);
+ if (currentDomain is not null && _byQualified.TryGetValue($"{currentDomain.Name}.{@ref}", out var local))
+ return local;
+ return _protocol.TryGetValue(@ref, out var pt) ? pt : (null, null);
+ }
+
+ public TypeDef? Resolve(string @ref, Domain? currentDomain) => ResolveWithOwner(@ref, currentDomain).Td;
+}
diff --git a/protocol/gen/Program.cs b/protocol/gen/Program.cs
new file mode 100644
index 00000000..d5e1bf1e
--- /dev/null
+++ b/protocol/gen/Program.cs
@@ -0,0 +1,223 @@
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// wdxp-gen: the DAP-style code generator. Loads the one hand-authored source of truth
+// (wdxp.v0.json), structurally validates it, and emits every downstream surface by walking the
+// same model with ZERO per-command special-casing. That property is what makes Gate 3 real:
+// a schema field-add flows to all generated surfaces with no hand edits.
+using System.Text;
+using System.Text.Json;
+using Wdxp.Gen;
+
+var schemaPath = ArgValue(args, "--schema") ?? SchemaPaths.FindUp("wdxp.v0.json");
+if (schemaPath is null) { Console.Error.WriteLine("error: could not locate wdxp.v0.json (pass --schema )"); return 2; }
+schemaPath = Path.GetFullPath(schemaPath);
+var outDir = Path.GetFullPath(ArgValue(args, "--out") ?? Path.Combine(Path.GetDirectoryName(schemaPath)!, "gen", "out"));
+
+Console.WriteLine($"wdxp-gen: schema = {schemaPath}");
+var protocol = SchemaLoader.Load(schemaPath);
+var resolver = new RefResolver(protocol);
+
+var errors = Validator.Validate(protocol, resolver);
+if (errors.Count > 0)
+{
+ Console.Error.WriteLine($"SCHEMA INVALID — {errors.Count} error(s):");
+ foreach (var e in errors) Console.Error.WriteLine(" - " + e);
+ return 1;
+}
+Console.WriteLine($"schema valid: {protocol.Domains.Count} domains, "
+ + $"{protocol.Domains.Sum(d => d.Commands.Count)} commands, {protocol.Domains.Sum(d => d.Events.Count)} events.");
+
+Directory.CreateDirectory(outDir);
+var opts = new JsonSerializerOptions { WriteIndented = true };
+
+var cli = Emit.Cli(protocol);
+File.WriteAllText(Path.Combine(outDir, "cli-commands.json"), JsonSerializer.Serialize(cli, opts));
+
+File.WriteAllText(Path.Combine(outDir, "protocol-reference.md"), Emit.Docs(protocol));
+
+Console.WriteLine($"emitted 2 surfaces -> {outDir}");
+Console.WriteLine(" cli-commands.json protocol-reference.md");
+return 0;
+
+static string? ArgValue(string[] a, string name)
+{
+ var i = Array.IndexOf(a, name);
+ return i >= 0 && i + 1 < a.Length ? a[i + 1] : null;
+}
+
+namespace Wdxp.Gen
+{
+ /// Structural gate enforced in CI. The JSON Schema (wdxp.schema.json) is the authoring aid;
+ /// these checks are the ones we actually fail the build on.
+ public static class Validator
+ {
+ public static List Validate(Protocol p, RefResolver r)
+ {
+ var errors = new List();
+ var tiers = new HashSet { "read", "mutate-ephemeral", "structural", "persist", "privileged" };
+ var caps = new HashSet(StringComparer.Ordinal);
+ var methods = new HashSet(StringComparer.Ordinal);
+
+ foreach (var d in p.Domains)
+ {
+ if (!caps.Add(d.Capability)) errors.Add($"duplicate capability '{d.Capability}' on domain {d.Name}");
+
+ foreach (var t in d.Types) CheckType(t, d, errors, r);
+
+ foreach (var c in d.Commands)
+ {
+ var m = $"{d.Name}.{c.Name}";
+ if (!methods.Add(m)) errors.Add($"duplicate method '{m}'");
+ if (!tiers.Contains(c.Risk)) errors.Add($"{m}: unknown risk tier '{c.Risk}'");
+ foreach (var f in c.Parameters) CheckField(f, d, $"{m} param '{f.Name}'", errors, r);
+ foreach (var f in c.Returns) CheckField(f, d, $"{m} return '{f.Name}'", errors, r);
+ }
+
+ foreach (var e in d.Events)
+ foreach (var f in e.Parameters) CheckField(f, d, $"{d.Name}.{e.Name} param '{f.Name}'", errors, r);
+ }
+
+ // Cross-cutting invariants the whole architecture leans on.
+ RequireEnum(p, r, "Source", "SourceKind", "source-backed", errors);
+ RequireEnum(p, r, "Property", "ValueSource", "local", errors);
+ RequireEnum(p, r, "HotReload", "TransactionState", "refused-unsafe", errors);
+ RequireEnum(p, r, "Diagnostics", "ReasonCode", "release-no-line-info", errors);
+ if (r.Resolve("Outcome", null) is null) errors.Add("missing protocol-level enum 'Outcome' (the four-outcome classifier)");
+ if (r.Resolve("RiskTier", null) is null) errors.Add("missing protocol-level enum 'RiskTier'");
+ return errors;
+ }
+
+ private static void CheckType(TypeDef t, Domain d, List errors, RefResolver r)
+ {
+ switch (t.Kind)
+ {
+ case "enum" when t.Values.Count == 0: errors.Add($"{d.Name}.{t.Id}: enum has no values"); break;
+ case "primitive" when t.Primitive is null: errors.Add($"{d.Name}.{t.Id}: primitive has no base type"); break;
+ case "object" when t.Properties.Count == 0: errors.Add($"{d.Name}.{t.Id}: object has no properties"); break;
+ }
+ // An object type's properties are fields too: their $refs must resolve, or a dangling ref
+ // ships unflagged into every facade. (No-op for enum/primitive types, which have no properties.)
+ foreach (var f in t.Properties) CheckField(f, d, $"{d.Name}.{t.Id} property '{f.Name}'", errors, r);
+ }
+
+ private static void CheckField(Field f, Domain d, string where, List errors, RefResolver r)
+ {
+ if (f.Type is null && f.Ref is null) errors.Add($"{where}: needs either 'type' or '$ref'");
+ if (f.Type is not null && f.Ref is not null) errors.Add($"{where}: has both 'type' and '$ref'");
+ if (f.Ref is not null && r.Resolve(f.Ref, d) is null) errors.Add($"{where}: unresolved $ref '{f.Ref}'");
+ }
+
+ private static void RequireEnum(Protocol p, RefResolver r, string domain, string type, string mustContain, List errors)
+ {
+ var td = r.Resolve($"{domain}.{type}", null);
+ if (td is null) { errors.Add($"missing normative enum {domain}.{type}"); return; }
+ if (!td.Values.Contains(mustContain)) errors.Add($"{domain}.{type} must contain '{mustContain}'");
+ }
+ }
+
+ /// The emitters. Each walks the model generically — the map from a schema field to a
+ /// CLI arg / doc row is type-driven, never command-driven.
+ public static class Emit
+ {
+ // ---- CLI facade: the `winapp --cli-schema`-shaped command graph (+ events as notifications) ----
+ public static object Cli(Protocol p)
+ {
+ var commands = new List