From 3c0df75555e7ce655e34666bda3dcbdd36b6d35b Mon Sep 17 00:00:00 2001
From: Nikola Metulev <711864+nmetulev@users.noreply.github.com>
Date: Sat, 11 Jul 2026 22:08:20 -0700
Subject: [PATCH 1/3] Port WDXP protocol schema + wire conformance fast gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Land the proof-of-concept WDXP protocol (W2, the keystone contract) into
winappCli under protocol/, scrubbed for public release, with its fast gate
wired into CI. Scope: spec §11 phases 1-2 only (phases 3-4 are follow-ups).
- protocol/: canonical schema (wdxp.v0.json - 10 domains / 32 commands /
8 events / 14 error codes / 5 risk tiers), JSON-Schema 2020-12 guard,
envelope framing spec, zero-dep net10.0 generator (CLI-JSON + docs facades)
and conformance suite, plus 3 golden traces.
- Scrub for public release: dropped the internal agent tool-manifest facade
entirely (generator emitter path, its output, and the schema mention), and
neutralized internal probe labels, transport source-path refs, the runtime
component name, provenance language, and internal repo names. A standing
gate script enforces zero regressions.
- CI: .github/workflows/protocol-conformance.yml (ubuntu-latest, net10.0) runs
the conformance suite (PASS 5/5: schema-valid + Gate-3 facade-totality + 3
golden traces), a license-header check, and the public-appropriateness scan.
- Gate 3 preserved after facade removal: the CLI facade gains a notifications
array so command + event totality is asserted across both remaining facades.
- Docs: protocol spec §11 marks phases 1-2 landed; overview W2 row + AGENTS.md
updated with a protocol/ pointer.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d376fce1-8858-45ae-a496-245835e5879f
---
.github/workflows/protocol-conformance.yml | 38 ++
AGENTS.md | 1 +
protocol/.gitignore | 6 +
protocol/Protocol.slnx | 8 +
protocol/README.md | 160 ++++++
protocol/conformance/Program.cs | 197 ++++++++
protocol/conformance/Wdxp.Conformance.csproj | 17 +
protocol/envelope.md | 150 ++++++
protocol/gen/Model.cs | 115 +++++
protocol/gen/Program.cs | 235 +++++++++
protocol/gen/Wdxp.Gen.csproj | 14 +
protocol/golden/01-negotiate-attach.json | 39 ++
protocol/golden/02-read-floor.json | 41 ++
protocol/golden/03-subscribe-and-errors.json | 29 ++
protocol/scripts/check-license-headers.ps1 | 32 ++
.../scripts/check-public-appropriateness.ps1 | 50 ++
protocol/wdxp.schema.json | 142 ++++++
protocol/wdxp.v0.json | 463 ++++++++++++++++++
specs/winapp-devtools-overview.md | 2 +-
specs/winapp-devtools-protocol.md | 27 +-
20 files changed, 1755 insertions(+), 11 deletions(-)
create mode 100644 .github/workflows/protocol-conformance.yml
create mode 100644 protocol/.gitignore
create mode 100644 protocol/Protocol.slnx
create mode 100644 protocol/README.md
create mode 100644 protocol/conformance/Program.cs
create mode 100644 protocol/conformance/Wdxp.Conformance.csproj
create mode 100644 protocol/envelope.md
create mode 100644 protocol/gen/Model.cs
create mode 100644 protocol/gen/Program.cs
create mode 100644 protocol/gen/Wdxp.Gen.csproj
create mode 100644 protocol/golden/01-negotiate-attach.json
create mode 100644 protocol/golden/02-read-floor.json
create mode 100644 protocol/golden/03-subscribe-and-errors.json
create mode 100644 protocol/scripts/check-license-headers.ps1
create mode 100644 protocol/scripts/check-public-appropriateness.ps1
create mode 100644 protocol/wdxp.schema.json
create mode 100644 protocol/wdxp.v0.json
diff --git a/.github/workflows/protocol-conformance.yml b/.github/workflows/protocol-conformance.yml
new file mode 100644
index 00000000..0b4d3fa9
--- /dev/null
+++ b/.github/workflows/protocol-conformance.yml
@@ -0,0 +1,38 @@
+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
+
+ # Gate 3 (facade totality) + schema-valid + 3 golden traces. Exit 0 == PASS (5/5).
+ - name: Run conformance suite
+ run: dotnet run --project protocol/conformance/Wdxp.Conformance.csproj -c Release
+
+ - 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..a03917c2
--- /dev/null
+++ b/protocol/README.md
@@ -0,0 +1,160 @@
+# 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 or a field to the protocol must touch **≤ 1
+hand-written surface** (the schema) and flow automatically into the CLI facade *and* the docs. The
+conformance suite asserts this as `gate3-facade-totality`. 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 + structural validation of the canonical file. |
+| `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. |
+| `conformance/` | The **W2 gate**. `dotnet run` → validates the schema, proves Gate-3 facade totality, and checks the golden traces. Exit 0 = green. |
+| `gen/out/` | Generated facades (git-ignored — reproducible from the schema). |
+
+---
+
+## 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):
+cd protocol/conformance ; dotnet run -c Debug
+# -> RESULT: PASS (5/5 checks) [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`; `sessionEnded`. |
+| `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; `treeChanged`. |
+| `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` → `templateBinding` →
+ `style` → `builtInStyle` → `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` / `AppliedInert` /
+ `ReloadRequired` / `NeedsRestart`) — the **honesty invariant**: the engine never claims success it
+ can't guarantee.
+- **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.
+ - **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..9f7a3b25
--- /dev/null
+++ b/protocol/conformance/Program.cs
@@ -0,0 +1,197 @@
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// wdxp-conformance: the W2 conformance suite. Three checks, runnable now with `dotnet run`:
+// 1. the schema is structurally valid (reuses the generator's Validator);
+// 2. Gate 3 totality — every command/event in the schema appears in EVERY generated facade
+// (the CLI command-graph + the docs reference), so a schema field-add can never be silently
+// dropped from a surface;
+// 3. the golden traces conform to the schema (methods exist; message fields match the contract;
+// error codes are declared).
+// 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 = 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 ----
+var commands = protocol.Domains.SelectMany(d => d.Commands.Select(c => ($"{d.Name}.{c.Name}", c))).ToDictionary(x => x.Item1, x => x.Item2, StringComparer.Ordinal);
+var events = protocol.Domains.SelectMany(d => d.Events.Select(e => ($"{d.Name}.{e.Name}", e))).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
+ foreach (var file in Directory.EnumerateFiles(goldenDir, "*.json").OrderBy(f => f))
+ checks.Add(ConformTrace(file, commands, events, errorCodes));
+
+// ---- 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}");
+
+ return ("gate3-facade-totality", errs.Count == 0,
+ errs.Count == 0 ? $"{schemaCommands.Count} commands + {schemaEvents.Count} events present in every facade" : string.Join("; ", errs));
+}
+
+static (string, bool, string) ConformTrace(string file, IReadOnlyDictionary commands,
+ IReadOnlyDictionary events, IReadOnlyDictionary errorCodes)
+{
+ 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}";
+
+ if (msg.TryGetProperty("jsonrpc", out var v) && v.GetString() != "2.0")
+ errs.Add($"{where}: jsonrpc must be '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);
+ string? id = hasId ? (idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : idEl.ToString()) : null;
+
+ if (hasError)
+ {
+ int code = error.GetProperty("code").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)
+ {
+ 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 cmd))
+ ValidateFields(result, cmd.Returns, $"{where} result[{method}]", errs);
+ }
+ else if (hasMethod && hasId) // request
+ {
+ string method = methodEl.GetString()!;
+ id ??= "";
+ idToMethod[id] = method;
+ if (!commands.TryGetValue(method, out var cmd))
+ errs.Add($"{where}: unknown command '{method}'");
+ else
+ {
+ var pars = msg.TryGetProperty("params", out var pe) && pe.ValueKind == JsonValueKind.Object ? pe : default;
+ ValidateFields(pars, cmd.Parameters, $"{where} params[{method}]", errs);
+ }
+ }
+ else if (hasMethod) // notification / event
+ {
+ string method = methodEl.GetString()!;
+ if (!events.TryGetValue(method, out var evt))
+ errs.Add($"{where}: unknown event '{method}'");
+ else
+ {
+ var pars = msg.TryGetProperty("params", out var pe) && pe.ValueKind == JsonValueKind.Object ? pe : default;
+ ValidateFields(pars, evt.Parameters, $"{where} event[{method}]", errs);
+ }
+ }
+ else errs.Add($"{where}: unclassifiable message");
+ }
+
+ return ($"golden:{Path.GetFileName(file)}", errs.Count == 0, errs.Count == 0 ? $"{n} messages conform" : string.Join("; ", errs));
+}
+
+// Validate the TOP-LEVEL fields of a message object against the declared contract fields:
+// every provided field is declared, and every required (non-optional) field is present.
+static void ValidateFields(JsonElement obj, IReadOnlyList declared, string where, List errs)
+{
+ var byName = declared.ToDictionary(f => f.Name, StringComparer.Ordinal);
+ if (obj.ValueKind == JsonValueKind.Object)
+ foreach (var prop in obj.EnumerateObject())
+ if (!byName.ContainsKey(prop.Name))
+ errs.Add($"{where}: unknown field '{prop.Name}'");
+
+ foreach (var f in declared)
+ if (!f.Optional && !(obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(f.Name, out _)))
+ errs.Add($"{where}: missing required field '{f.Name}'");
+}
+
+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;
+}
+
+static string? FindUp(string fileName)
+{
+ foreach (var start in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory })
+ {
+ var dir = new DirectoryInfo(start);
+ while (dir is not null)
+ {
+ foreach (var c in new[] { Path.Combine(dir.FullName, "protocol", fileName), Path.Combine(dir.FullName, fileName) })
+ if (File.Exists(c)) return c;
+ dir = dir.Parent;
+ }
+ }
+ return null;
+}
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..c8f664c8
--- /dev/null
+++ b/protocol/envelope.md
@@ -0,0 +1,150 @@
+
+# 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).
+
+## 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 or number, unique per connection while in flight. A request always gets exactly one
+ response with the same `id`.
+- `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.
+
+**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.
+- `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 mutating
+command reports one of the four `Outcome`s (`applied` / `applied-inert` / `reloaded` / `needs-restart`)
+and, for transactions, a real `TransactionState` including the honest terminal-failure states
+(`refused-unsafe`, `stale-handle`, `target-lost`, `unreachable-gate`). 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).
+- `Security.authenticate` establishes a **per-session nonce/token** with **replay prevention**; every
+ command 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. 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..e88ae0a4
--- /dev/null
+++ b/protocol/gen/Program.cs
@@ -0,0 +1,235 @@
+// 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") ?? FindSchema();
+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;
+}
+
+static string? FindSchema()
+{
+ foreach (var start in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory })
+ {
+ var dir = new DirectoryInfo(start);
+ while (dir is not null)
+ {
+ foreach (var candidate in new[] { Path.Combine(dir.FullName, "protocol", "wdxp.v0.json"), Path.Combine(dir.FullName, "wdxp.v0.json") })
+ if (File.Exists(candidate)) return candidate;
+ dir = dir.Parent;
+ }
+ }
+ return 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);
+
+ 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)
+ {
+ 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;
+ }
+ }
+
+ 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