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(); + var notifications = new List(); + foreach (var d in p.Domains) + { + foreach (var c in d.Commands) + commands.Add(new Dictionary + { + ["cliPath"] = $"{d.Capability} {Kebab(c.Name)}", + ["method"] = $"{d.Name}.{c.Name}", + ["capability"] = d.Capability, + ["risk"] = c.Risk, + ["stability"] = c.Stability ?? d.Stability, + ["summary"] = c.Summary, + ["args"] = c.Parameters.Select(ArgDescriptor).ToList(), + ["returns"] = c.Returns.Select(ArgDescriptor).ToList(), + }); + foreach (var e in d.Events) + notifications.Add(new Dictionary + { + ["method"] = $"{d.Name}.{e.Name}", + ["capability"] = d.Capability, + ["stability"] = e.Stability ?? d.Stability, + ["summary"] = e.Summary, + ["args"] = e.Parameters.Select(ArgDescriptor).ToList(), + }); + } + return new Dictionary + { + ["protocol"] = p.Name, + ["version"] = p.Version, + ["generatedBy"] = "wdxp-gen", + ["surface"] = "cli-command-graph", + ["commandCount"] = commands.Count, + ["commands"] = commands, + ["notificationCount"] = notifications.Count, + ["notifications"] = notifications, + }; + } + + private static object ArgDescriptor(Field f) => new Dictionary + { + ["name"] = f.Name, + ["type"] = f.Ref ?? f.Type, + ["isRef"] = f.Ref is not null, + ["array"] = f.Array, + ["optional"] = f.Optional, + ["summary"] = f.Summary, + }; + + // ---- Docs facade: a human-readable reference generated from the same model ---- + public static string Docs(Protocol p) + { + var sb = new StringBuilder(); + sb.AppendLine($"# {p.Name} v{p.Version} — protocol reference (generated)"); + sb.AppendLine(); + sb.AppendLine("> Generated by `wdxp-gen` from `wdxp.v0.json`. Do not edit by hand."); + sb.AppendLine(); + foreach (var d in p.Domains) + { + sb.AppendLine($"## {d.Name} `{d.Capability}` ({d.Stability})"); + sb.AppendLine(); + sb.AppendLine(d.Summary); + sb.AppendLine(); + if (d.Commands.Count > 0) + { + sb.AppendLine("| Command | Risk | Params → Returns |"); + sb.AppendLine("|---|---|---|"); + foreach (var c in d.Commands) + sb.AppendLine($"| `{d.Name}.{c.Name}` | {c.Risk} | {Sig(c.Parameters)} → {Sig(c.Returns)} |"); + sb.AppendLine(); + } + if (d.Events.Count > 0) + { + sb.AppendLine("Events: " + string.Join(", ", d.Events.Select(e => $"`{d.Name}.{e.Name}`")) + "."); + sb.AppendLine(); + } + } + return sb.ToString(); + } + + private static string Sig(IReadOnlyList fs) => + fs.Count == 0 ? "()" : "(" + string.Join(", ", fs.Select(f => $"{f.Name}: {(f.Ref ?? f.Type)}{(f.Array ? "[]" : "")}{(f.Optional ? "?" : "")}")) + ")"; + + private static string[] Words(string name) + { + var words = new List(); + var cur = new StringBuilder(); + foreach (var ch in name) + { + if (char.IsUpper(ch) && cur.Length > 0) { words.Add(cur.ToString()); cur.Clear(); } + cur.Append(char.ToLowerInvariant(ch)); + } + if (cur.Length > 0) words.Add(cur.ToString()); + return words.ToArray(); + } + + private static string Kebab(string name) => string.Join("-", Words(name)); + } +} diff --git a/protocol/gen/Wdxp.Gen.csproj b/protocol/gen/Wdxp.Gen.csproj new file mode 100644 index 00000000..7ebe4e32 --- /dev/null +++ b/protocol/gen/Wdxp.Gen.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + wdxp-gen + Wdxp.Gen + true + true + + + diff --git a/protocol/golden/01-negotiate-attach.json b/protocol/golden/01-negotiate-attach.json new file mode 100644 index 00000000..01d12441 --- /dev/null +++ b/protocol/golden/01-negotiate-attach.json @@ -0,0 +1,39 @@ +{ + "scenario": "01-negotiate-attach", + "summary": "Session bring-up: negotiate capabilities, list targets, attach to one (session = connection), receive the attached event. Exercises the WDXP + Target domains and the loud-fail-before-unsafe-attach contract.", + "messages": [ + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "1", "method": "WDXP.negotiate", + "params": { "client": "winapp-vsc/1.2.0", + "capabilities": [ + { "name": "target", "version": "0.1.0", "stability": "stable" }, + { "name": "visualtree", "version": "0.1.0", "stability": "stable" }, + { "name": "property", "version": "0.1.0", "stability": "stable" }, + { "name": "source", "version": "0.1.0", "stability": "stable" } + ] } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "1", + "result": { "protocolVersion": "0.1.0", + "agreed": [ + { "name": "target", "version": "0.1.0", "stability": "stable" }, + { "name": "visualtree", "version": "0.1.0", "stability": "stable" }, + { "name": "property", "version": "0.1.0", "stability": "stable" }, + { "name": "source", "version": "0.1.0", "stability": "stable" } + ] } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "2", "method": "Target.list", "params": {} } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "2", + "result": { "targets": [ + { "targetId": "t-8123", "pid": 8123, "title": "SmokeFixture", "rootCount": 1, "protocolVersion": "0.1.0", + "package": { "packaged": false, "path": "C:\\...\\SmokeFixture.exe" } } + ] } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "3", "method": "Target.attach", + "params": { "targetId": "t-8123", "requiredCapabilities": ["visualtree", "property"] } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "3", + "result": { "sessionId": "s-1", "granted": ["target", "visualtree", "property", "source"], + "affinity": { "threadId": 4460 } } } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "Target.attached", + "params": { "target": { "targetId": "t-8123", "pid": 8123, "title": "SmokeFixture", "rootCount": 1, + "protocolVersion": "0.1.0", "package": { "packaged": false } } } } } + ] +} diff --git a/protocol/golden/02-read-floor.json b/protocol/golden/02-read-floor.json new file mode 100644 index 00000000..1806e761 --- /dev/null +++ b/protocol/golden/02-read-floor.json @@ -0,0 +1,41 @@ +{ + "scenario": "02-read-floor", + "summary": "The guaranteed read floor on the attached SmokeFixture: search by x:Name, enumerate, read a property with value-source precedence, resolve source (confidence-graded). Values mirror the census: authored elements resolve to ms-appx URIs with exact line info; a runtime-only element degrades honestly to kind=runtime-only with the anchor as the never-wrong fallback.", + "messages": [ + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "10", "method": "VisualTree.search", "params": { "name": "Title" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "10", + "result": { "matches": [ { "handle": 42, "generation": 1 } ] } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "11", "method": "VisualTree.enumerate", + "params": { "node": { "handle": 42, "generation": 1 }, "depth": 1 } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "11", + "result": { + "root": [ { "node": { "handle": 42, "generation": 1 }, "type": "TextBlock", "name": "Title", "childCount": 0 } ], + "nodes": [] } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "12", "method": "Property.get", + "params": { "node": { "handle": 42, "generation": 1 }, "names": ["Opacity", "Text"] } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "12", + "result": { "properties": [ + { "name": "Opacity", "value": "1", "valueType": "Double", "source": "default", "isDefault": true }, + { "name": "Text", "value": "Smoke", "valueType": "String", "source": "local", "isDefault": false } + ] } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "13", "method": "Source.resolve", + "params": { "node": { "handle": 42, "generation": 1 } } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "13", + "result": { "resolution": { + "kind": "source-backed", "confidence": "exact", + "span": { "uri": "ms-appx:///SmokePage.xaml", "line": 13, "column": 10, "lineInfo": true }, + "anchor": { "name": "Title", "treePath": "Page/Grid/StackPanel[0]/TextBlock[0]", "propertySignature": "Text" } + } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "14", "method": "Source.resolve", + "params": { "node": { "handle": 77, "generation": 1 } } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "14", + "result": { "resolution": { + "kind": "runtime-only", "confidence": "none", + "anchor": { "treePath": "Page/Grid/Border[2]" } + } } } } + ] +} diff --git a/protocol/golden/03-subscribe-and-errors.json b/protocol/golden/03-subscribe-and-errors.json new file mode 100644 index 00000000..e4dce779 --- /dev/null +++ b/protocol/golden/03-subscribe-and-errors.json @@ -0,0 +1,29 @@ +{ + "scenario": "03-subscribe-and-errors", + "summary": "Structural subscription with a server notification, plus the two most common recoverable errors: a stale handle after the tree mutated (re-enumerate), and a capability that was never negotiated (clean typed refusal, never a crash). Exercises the notification model and the error taxonomy.", + "messages": [ + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "20", "method": "VisualTree.subscribe", + "params": { "node": { "handle": 5, "generation": 1 } } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "20", "result": { "subscriptionId": "sub-1" } } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "VisualTree.childrenChanged", + "params": { "node": { "handle": 5, "generation": 2 }, + "added": [ { "handle": 91, "generation": 1 } ], "removed": [] } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "21", "method": "Property.get", + "params": { "node": { "handle": 5, "generation": 1 } } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "21", + "error": { "code": -32001, "name": "StaleHandle", + "message": "Handle 5 generation 1 is stale (current 2). Re-enumerate to refresh handles." } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "22", "method": "HotReload.plan", + "params": { "edits": [ { "kind": "xaml", "uri": "ms-appx:///SmokePage.xaml", "detail": "..." } ] } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "22", + "error": { "code": -32003, "name": "CapabilityUnsupported", + "message": "Capability 'hotreload' was not negotiated for this session." } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "23", "method": "VisualTree.unsubscribe", + "params": { "subscriptionId": "sub-1" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "23", "result": {} } } + ] +} diff --git a/protocol/scripts/check-license-headers.ps1 b/protocol/scripts/check-license-headers.ps1 new file mode 100644 index 00000000..847178ff --- /dev/null +++ b/protocol/scripts/check-license-headers.ps1 @@ -0,0 +1,32 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. Licensed under the MIT License. +# +# Verifies that every C# source file under protocol/ carries the MIT license header +# on its first line. Runs cross-platform (Windows / hosted Linux CI) under pwsh. +# Exit 0 = all good; exit 1 = one or more files missing the header. +[CmdletBinding()] +param( + # Defaults to the protocol/ folder (the parent of this scripts/ directory). + [string]$Root = (Split-Path -Parent $PSScriptRoot) +) + +$ErrorActionPreference = 'Stop' +$expected = '// Copyright (c) Microsoft Corporation. Licensed under the MIT License.' + +$missing = @() +Get-ChildItem -Path $Root -Recurse -File -Filter *.cs | + Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' } | + ForEach-Object { + $first = Get-Content -LiteralPath $_.FullName -TotalCount 1 + if ($first -ne $expected) { $missing += $_.FullName } + } + +if ($missing.Count -gt 0) { + Write-Host "License-header check FAILED - $($missing.Count) file(s) missing the MIT header:" + $missing | ForEach-Object { Write-Host " $_" } + Write-Host "Expected first line: $expected" + exit 1 +} + +Write-Host "License-header check passed (all protocol C# files carry the MIT header)." +exit 0 diff --git a/protocol/scripts/check-public-appropriateness.ps1 b/protocol/scripts/check-public-appropriateness.ps1 new file mode 100644 index 00000000..e7d297d2 --- /dev/null +++ b/protocol/scripts/check-public-appropriateness.ps1 @@ -0,0 +1,50 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. Licensed under the MIT License. +# +# Public-appropriateness scan for the ported protocol assets. This repo is public-bound, so the +# ported contract must not reference internal-only artifacts. Fails (exit 1) on any hit for: +# * the dropped agent tool-manifest facade, +# * internal probe labels, +# * origin/provenance disclaimer language, +# * internal repository names, +# * the injection/platform runtime component name, +# * internal transport source-path references. +# The search terms are assembled from fragments so this scanner never matches itself. +[CmdletBinding()] +param( + # Defaults to the protocol/ folder (the parent of this scripts/ directory). + [string]$Root = (Split-Path -Parent $PSScriptRoot) +) + +$ErrorActionPreference = 'Stop' + +$terms = @( + 'M' + 'CP' + 'SM' + '-D' + 'clean' + '-room' + 'nikol' + 'ame' + 'win' + '-devex' + 'Framework' + 'Udk' + 'Initialize' + 'Xaml' + 'src[\\/]' + 'Transport' +) +$rx = ($terms -join '|') + +$hits = Get-ChildItem -Path $Root -Recurse -File | + Where-Object { + $_.FullName -notmatch '[\\/](bin|obj)[\\/]' -and + $_.FullName -notmatch '[\\/]gen[\\/]out[\\/]' + } | + Select-String -Pattern $rx + +if ($hits) { + Write-Host "Public-appropriateness scan FAILED - $($hits.Count) forbidden reference(s):" + foreach ($h in $hits) { + $rel = $h.Path.Substring($Root.Length).TrimStart('\', '/') + Write-Host (" {0}:{1}: {2}" -f $rel, $h.LineNumber, $h.Line.Trim()) + } + exit 1 +} + +Write-Host "Public-appropriateness scan passed (zero forbidden references under protocol/)." +exit 0 diff --git a/protocol/wdxp.schema.json b/protocol/wdxp.schema.json new file mode 100644 index 00000000..c21eb091 --- /dev/null +++ b/protocol/wdxp.schema.json @@ -0,0 +1,142 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/nmetulev/winui-devex/main/protocol/wdxp.schema.json", + "title": "WDXP protocol definition guard", + "description": "Validates the hand-authored wdxp.v0.json (DAP-style single source of truth). Also serves as the IDE authoring aid via the $schema reference in the protocol file.", + "type": "object", + "required": ["protocol", "version", "stability", "framing", "errorCodes", "riskTiers", "types", "domains"], + "additionalProperties": true, + "properties": { + "protocol": { "const": "wdxp" }, + "title": { "type": "string" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "stability": { "$ref": "#/$defs/stability" }, + "summary": { "type": "string" }, + "framing": { + "type": "object", + "required": ["transport", "rpc", "delimiter", "sessionModel"], + "properties": { + "transport": { "type": "string" }, + "rpc": { "const": "json-rpc-2.0" }, + "delimiter": { "const": "newline" }, + "sessionModel": { "const": "session-equals-connection" }, + "security": { "type": "string" } + } + }, + "errorCodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["code", "name", "category", "recoverable", "summary"], + "properties": { + "code": { "type": "integer" }, + "name": { "type": "string", "pattern": "^[A-Z][A-Za-z0-9]*$" }, + "category": { "type": "string" }, + "recoverable": { "type": "boolean" }, + "summary": { "type": "string" } + }, + "additionalProperties": false + } + }, + "riskTiers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["tier", "level", "consent", "summary"], + "properties": { + "tier": { "$ref": "#/$defs/riskTier" }, + "level": { "type": "integer", "minimum": 0 }, + "consent": { "type": "string", "enum": ["session-grant", "explicit"] }, + "summary": { "type": "string" } + }, + "additionalProperties": false + } + }, + "types": { "type": "array", "items": { "$ref": "#/$defs/typeDef" } }, + "domains": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/domain" } + } + }, + "$defs": { + "stability": { "type": "string", "enum": ["stable", "experimental", "deprecated"] }, + "riskTier": { "type": "string", "enum": ["read", "mutate-ephemeral", "structural", "persist", "privileged"] }, + "identifier": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9]*$" }, + "field": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "$ref": "#/$defs/identifier" }, + "type": { "type": "string", "enum": ["string", "integer", "number", "boolean", "object"] }, + "$ref": { "type": "string" }, + "array": { "type": "boolean" }, + "optional": { "type": "boolean" }, + "summary": { "type": "string" } + }, + "oneOf": [ + { "required": ["type"] }, + { "required": ["$ref"] } + ], + "additionalProperties": false + }, + "typeDef": { + "type": "object", + "required": ["id", "kind"], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "kind": { "type": "string", "enum": ["enum", "object", "primitive"] }, + "summary": { "type": "string" }, + "values": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "type": { "type": "string", "enum": ["string", "integer", "number", "boolean"] }, + "properties": { "type": "array", "items": { "$ref": "#/$defs/field" } } + }, + "allOf": [ + { "if": { "properties": { "kind": { "const": "enum" } } }, "then": { "required": ["values"] } }, + { "if": { "properties": { "kind": { "const": "object" } } }, "then": { "required": ["properties"] } }, + { "if": { "properties": { "kind": { "const": "primitive" } } }, "then": { "required": ["type"] } } + ], + "additionalProperties": false + }, + "command": { + "type": "object", + "required": ["name", "risk", "summary", "parameters", "returns"], + "properties": { + "name": { "$ref": "#/$defs/identifier" }, + "risk": { "$ref": "#/$defs/riskTier" }, + "stability": { "$ref": "#/$defs/stability" }, + "summary": { "type": "string" }, + "parameters": { "type": "array", "items": { "$ref": "#/$defs/field" } }, + "returns": { "type": "array", "items": { "$ref": "#/$defs/field" } } + }, + "additionalProperties": false + }, + "event": { + "type": "object", + "required": ["name", "summary", "parameters"], + "properties": { + "name": { "$ref": "#/$defs/identifier" }, + "stability": { "$ref": "#/$defs/stability" }, + "summary": { "type": "string" }, + "parameters": { "type": "array", "items": { "$ref": "#/$defs/field" } } + }, + "additionalProperties": false + }, + "domain": { + "type": "object", + "required": ["domain", "capability", "stability", "summary", "types", "commands", "events"], + "properties": { + "domain": { "$ref": "#/$defs/identifier" }, + "capability": { "type": "string", "pattern": "^[a-z][a-z0-9]*$" }, + "stability": { "$ref": "#/$defs/stability" }, + "summary": { "type": "string" }, + "types": { "type": "array", "items": { "$ref": "#/$defs/typeDef" } }, + "commands": { "type": "array", "items": { "$ref": "#/$defs/command" } }, + "events": { "type": "array", "items": { "$ref": "#/$defs/event" } } + }, + "additionalProperties": false + } + } +} diff --git a/protocol/wdxp.v0.json b/protocol/wdxp.v0.json new file mode 100644 index 00000000..8bda32ef --- /dev/null +++ b/protocol/wdxp.v0.json @@ -0,0 +1,463 @@ +{ + "$schema": "./wdxp.schema.json", + "protocol": "wdxp", + "title": "WinUI Runtime DevTools Protocol", + "version": "0.1.0", + "stability": "experimental", + "summary": "Canonical, CDP-shaped source of truth for the WinUI design-time engine. Hand-authored (DAP-style); the CLI command-graph and docs facades are generated from this file. Grounded in the proven substrate: the proven transport (newline JSON-RPC 2.0 + server notifications) is the envelope; the proof-of-concept's live ops (findname/children/getchild/setprop/census) are the first VisualTree/Property/Source methods.", + "framing": { + "transport": "named-pipe", + "pipeNameTemplate": "wdxp-", + "security": "CurrentUserOnly", + "rpc": "json-rpc-2.0", + "delimiter": "newline", + "sessionModel": "session-equals-connection", + "negotiationMethod": "WDXP.negotiate", + "cancellationMethod": "WDXP.cancel", + "reference": "See envelope.md for the normative framing spec." + }, + "errorCodes": [ + { "code": -32700, "name": "ParseError", "category": "jsonrpc", "recoverable": false, "summary": "Invalid JSON was received." }, + { "code": -32600, "name": "InvalidRequest", "category": "jsonrpc", "recoverable": false, "summary": "The JSON sent is not a valid Request object." }, + { "code": -32601, "name": "MethodNotFound", "category": "jsonrpc", "recoverable": false, "summary": "The method does not exist / is not available." }, + { "code": -32602, "name": "InvalidParams", "category": "jsonrpc", "recoverable": false, "summary": "Invalid method parameters." }, + { "code": -32603, "name": "InternalError", "category": "jsonrpc", "recoverable": false, "summary": "Internal JSON-RPC error." }, + { "code": -32000, "name": "TargetLost", "category": "target", "recoverable": false, "summary": "The attached app exited or its dispatcher is gone. Re-attach." }, + { "code": -32001, "name": "StaleHandle", "category": "visualtree", "recoverable": true, "summary": "Node handle generation mismatch. Re-enumerate to refresh handles." }, + { "code": -32002, "name": "NotOnDispatcher", "category": "engine", "recoverable": true, "summary": "A mutation was routed off the UI DispatcherQueue (RPC_E_WRONG_THREAD analog). The engine must marshal; clients should never see this." }, + { "code": -32003, "name": "CapabilityUnsupported", "category": "core", "recoverable": true, "summary": "The requested capability/version was not negotiated. Negotiation returns this cleanly; never crash." }, + { "code": -32004, "name": "Unauthorized", "category": "security", "recoverable": true, "summary": "The session lacks a grant for this command family or the token expired." }, + { "code": -32005, "name": "RefusedUnsafe", "category": "hotreload", "recoverable": true, "summary": "The engine classified the edit as unsafe to apply in place and refused rather than risk corruption." }, + { "code": -32006, "name": "UnreachableGate", "category": "hotreload", "recoverable": true, "summary": "A required apply gate (e.g. render verification) could not be reached." }, + { "code": -32007, "name": "SourceUnavailable", "category": "source", "recoverable": true, "summary": "No source provenance for this element (runtime-only, or spans stripped). Not an error condition to fear \u2014 use the anchor." }, + { "code": -32008, "name": "Cancelled", "category": "core", "recoverable": true, "summary": "The request was cancelled via WDXP.cancel." } + ], + "types": [ + { "id": "Confidence", "kind": "enum", "values": ["exact", "high", "low", "none"], "summary": "Grades any best-effort resolution (source spans, identity, diagnostics). Ordered strongest\u2192weakest." }, + { "id": "Outcome", "kind": "enum", "values": ["applied", "applied-inert", "reloaded", "needs-restart"], "summary": "The four-outcome classifier every mutating result reports. Honesty invariant: never report a stronger outcome than actually achieved." }, + { "id": "RiskTier", "kind": "enum", "values": ["read", "mutate-ephemeral", "structural", "persist", "privileged"], "summary": "Command risk tier. Drives consent, audit, and grant policy (Security domain). Every command declares one." } + ], + "riskTiers": [ + { "tier": "read", "level": 0, "consent": "session-grant", "summary": "No app-visible mutation: enumerations, reads, subscriptions, resolution." }, + { "tier": "mutate-ephemeral", "level": 1, "consent": "session-grant", "summary": "Live runtime override that is NOT persisted (a set-property preview)." }, + { "tier": "structural", "level": 2, "consent": "session-grant", "summary": "Adds/removes/moves live elements or commits a runtime transaction." }, + { "tier": "persist", "level": 3, "consent": "explicit", "summary": "Writes back to source on disk." }, + { "tier": "privileged", "level": 4, "consent": "explicit", "summary": "Auth, grants, or target-allowlist changes." } + ], + "domains": [ + { + "domain": "WDXP", + "capability": "core", + "stability": "stable", + "summary": "Protocol built-ins present on every session before any capability is negotiated.", + "types": [ + { "id": "Capability", "kind": "object", "summary": "A negotiable capability and the version the peer offers.", "properties": [ + { "name": "name", "type": "string", "summary": "Capability id, e.g. 'visualtree'." }, + { "name": "version", "type": "string", "summary": "SemVer of the capability contract." }, + { "name": "stability", "type": "string", "summary": "stable | experimental." } + ]} + ], + "commands": [ + { "name": "negotiate", "risk": "read", "stability": "stable", "summary": "Exchange supported capabilities/versions. Session = connection; call this first. Unknown capabilities return CapabilityUnsupported, never a crash.", + "parameters": [ + { "name": "client", "type": "string", "summary": "Client identity string (host id), e.g. 'winapp-vsc/1.2.0'." }, + { "name": "capabilities", "$ref": "Capability", "array": true, "summary": "Capabilities the client wants, with the max version it understands." } + ], + "returns": [ + { "name": "agreed", "$ref": "Capability", "array": true, "summary": "The intersection the engine will honor for this session." }, + { "name": "protocolVersion", "type": "string", "summary": "Engine protocol version (this file's version)." } + ] + }, + { "name": "cancel", "risk": "read", "stability": "stable", "summary": "Request cancellation of an in-flight request by its JSON-RPC id. Best-effort; long reads and applies honor it at safe points.", + "parameters": [ { "name": "id", "type": "string", "summary": "The JSON-RPC id of the request to cancel." } ], + "returns": [ { "name": "cancelled", "type": "boolean", "summary": "True if a matching in-flight request was found and signaled." } ] + } + ], + "events": [ + { "name": "sessionClosing", "summary": "The engine is tearing the session down (target lost or shutdown). Clients should stop issuing commands.", + "parameters": [ { "name": "reason", "type": "string", "summary": "target-lost | shutdown | unauthorized." } ] } + ] + }, + { + "domain": "Target", + "capability": "target", + "stability": "stable", + "summary": "Discover, attach to, and track the lifecycle of a running WinUI app (target). Session = connection; attach binds this connection to one target.", + "types": [ + { "id": "TargetId", "kind": "primitive", "type": "string", "summary": "Opaque, stable-for-process target id." }, + { "id": "PackageIdentity", "kind": "object", "summary": "Packaged vs unpackaged identity of a target.", "properties": [ + { "name": "packaged", "type": "boolean", "summary": "True if MSIX-packaged." }, + { "name": "aumid", "type": "string", "optional": true, "summary": "Application User Model ID (packaged only)." }, + { "name": "familyName", "type": "string", "optional": true, "summary": "Package family name (packaged only)." }, + { "name": "path", "type": "string", "optional": true, "summary": "Executable path (unpackaged) or install path." } + ]}, + { "id": "DispatcherAffinity", "kind": "object", "summary": "The UI thread / DispatcherQueue all mutations MUST marshal to (threading contract). Reads may run off-thread; mutations may not.", "properties": [ + { "name": "threadId", "type": "integer", "summary": "OS thread id of the UI thread." }, + { "name": "queueId", "type": "string", "optional": true, "summary": "Opaque DispatcherQueue id when multiple roots exist." } + ]}, + { "id": "Target", "kind": "object", "summary": "A discovered or attached target.", "properties": [ + { "name": "targetId", "$ref": "TargetId" }, + { "name": "pid", "type": "integer", "summary": "OS process id." }, + { "name": "title", "type": "string", "summary": "Best-effort window/app title." }, + { "name": "package", "$ref": "PackageIdentity" }, + { "name": "rootCount", "type": "integer", "summary": "Number of XamlRoots/windows." }, + { "name": "protocolVersion", "type": "string", "summary": "wdxp version the in-app engine speaks." } + ]}, + { "id": "LostReason", "kind": "enum", "values": ["closed", "crashed", "detached", "timeout"], "summary": "Why a target went away." } + ], + "commands": [ + { "name": "list", "risk": "read", "stability": "stable", "summary": "Enumerate attachable WinUI targets on this machine for the current user.", + "parameters": [], + "returns": [ { "name": "targets", "$ref": "Target", "array": true } ] + }, + { "name": "attach", "risk": "read", "stability": "stable", "summary": "Bind this connection to a target and negotiate capabilities against it. Loud-fail before an unsafe attach: version mismatch or a missing required runtime component returns an error, never a partial attach.", + "parameters": [ + { "name": "targetId", "$ref": "TargetId" }, + { "name": "requiredCapabilities", "type": "string", "array": true, "optional": true, "summary": "Capabilities the client cannot proceed without; attach fails loudly if unavailable." } + ], + "returns": [ + { "name": "sessionId", "type": "string", "summary": "Session handle (also = this connection)." }, + { "name": "granted", "type": "string", "array": true, "summary": "Capability families granted for this session." }, + { "name": "affinity", "$ref": "DispatcherAffinity" } + ] + }, + { "name": "detach", "risk": "read", "stability": "stable", "summary": "Release the target without closing the app.", "parameters": [], "returns": [] }, + { "name": "reconnect", "risk": "read", "stability": "stable", "summary": "Re-bind after a transport blip; preserves session identity if the target still lives.", + "parameters": [ { "name": "sessionId", "type": "string" } ], + "returns": [ { "name": "target", "$ref": "Target" } ] + } + ], + "events": [ + { "name": "attached", "summary": "Emitted when an attach succeeds.", "parameters": [ { "name": "target", "$ref": "Target" } ] }, + { "name": "targetLost", "summary": "The attached target went away; the session is dead.", "parameters": [ + { "name": "targetId", "$ref": "TargetId" }, + { "name": "reason", "$ref": "LostReason" } + ]} + ] + }, + { + "domain": "VisualTree", + "capability": "visualtree", + "stability": "stable", + "summary": "The guaranteed read floor: enumerate/search/subscribe the live XAML visual tree. Config-independent and proven (maps to TAP findname/children/getchild). This is 'XAML sight day one.'", + "types": [ + { "id": "NodeHandle", "kind": "object", "summary": "A live element reference. The generation guards against stale handles across tree mutations.", "properties": [ + { "name": "handle", "type": "integer", "summary": "Opaque element handle (uint64 rendered as JSON number/string per envelope)." }, + { "name": "generation", "type": "integer", "summary": "Increments when the handle is invalidated; a mismatch yields StaleHandle." } + ]}, + { "id": "IncompletenessReason", "kind": "enum", "values": ["popup-detached", "flyout-detached", "virtualized-unrealized", "template-subtree", "collection-handle-quirk"], "summary": "Honest incompleteness (REQ-A1): why an enumeration cannot fully see a subtree. Reported, never silently dropped." }, + { "id": "Node", "kind": "object", "summary": "One node in the visual tree.", "properties": [ + { "name": "node", "$ref": "NodeHandle" }, + { "name": "type", "type": "string", "summary": "The XAML runtime type name." }, + { "name": "name", "type": "string", "optional": true, "summary": "x:Name, when present." }, + { "name": "childCount", "type": "integer" }, + { "name": "incompleteness", "$ref": "IncompletenessReason", "array": true, "optional": true, "summary": "Present when this node's children are known to be partial." } + ]} + ], + "commands": [ + { "name": "enumerate", "risk": "read", "stability": "stable", "summary": "Enumerate the tree from a node (or the roots) to a depth. Honest incompleteness is reported per node.", + "parameters": [ + { "name": "node", "$ref": "NodeHandle", "optional": true, "summary": "Start node; omit for all roots." }, + { "name": "depth", "type": "integer", "optional": true, "summary": "Max depth; omit for full subtree." } + ], + "returns": [ + { "name": "root", "$ref": "Node", "array": true, "summary": "The requested start node(s)." }, + { "name": "nodes", "$ref": "Node", "array": true, "summary": "Flattened descendants in document order." } + ] + }, + { "name": "search", "risk": "read", "stability": "stable", "summary": "Find live elements by x:Name across all roots (maps to TAP findname/findalltype).", + "parameters": [ { "name": "name", "type": "string", "summary": "The x:Name to match." } ], + "returns": [ { "name": "matches", "$ref": "NodeHandle", "array": true } ] + }, + { "name": "describe", "risk": "read", "stability": "stable", "summary": "Describe a single node.", + "parameters": [ { "name": "node", "$ref": "NodeHandle" } ], + "returns": [ { "name": "node", "$ref": "Node" } ] + }, + { "name": "subscribe", "risk": "read", "stability": "stable", "summary": "Subscribe to structural changes under a node (or all roots).", + "parameters": [ { "name": "node", "$ref": "NodeHandle", "optional": true } ], + "returns": [ { "name": "subscriptionId", "type": "string" } ] + }, + { "name": "unsubscribe", "risk": "read", "stability": "stable", "summary": "Cancel a subscription.", + "parameters": [ { "name": "subscriptionId", "type": "string" } ], "returns": [] } + ], + "events": [ + { "name": "childrenChanged", "summary": "Children of a subscribed node were added/removed. Handles carry the new generation.", "parameters": [ + { "name": "node", "$ref": "NodeHandle" }, + { "name": "added", "$ref": "NodeHandle", "array": true }, + { "name": "removed", "$ref": "NodeHandle", "array": true } + ]} + ] + }, + { + "domain": "Property", + "capability": "property", + "stability": "stable", + "summary": "Read and (ephemerally) override dependency properties. value-source precedence is first-class: a set without knowing the current source is an agent foot-gun.", + "types": [ + { "id": "ValueSource", "kind": "enum", "values": ["local", "animation", "template", "style", "resource", "inherited", "default"], "summary": "DP value-source precedence, highest\u2192lowest. Reports where the current value actually comes from." }, + { "id": "DpDescriptor", "kind": "object", "summary": "Static shape of a dependency property.", "properties": [ + { "name": "name", "type": "string" }, + { "name": "ownerType", "type": "string" }, + { "name": "valueType", "type": "string" }, + { "name": "writeable", "type": "boolean" }, + { "name": "attached", "type": "boolean", "summary": "True for attached DPs (e.g. Grid.Row)." } + ]}, + { "id": "PropertyValue", "kind": "object", "summary": "A property's live value plus provenance.", "properties": [ + { "name": "name", "type": "string" }, + { "name": "value", "type": "string", "summary": "Serialized value." }, + { "name": "valueType", "type": "string" }, + { "name": "source", "$ref": "ValueSource" }, + { "name": "isDefault", "type": "boolean" } + ]} + ], + "commands": [ + { "name": "get", "risk": "read", "stability": "stable", "summary": "Get properties of a node, each tagged with its value-source (maps to TAP getprop/inspect).", + "parameters": [ + { "name": "node", "$ref": "VisualTree.NodeHandle" }, + { "name": "names", "type": "string", "array": true, "optional": true, "summary": "Specific properties; omit for the common set." } + ], + "returns": [ { "name": "properties", "$ref": "PropertyValue", "array": true } ] + }, + { "name": "describe", "risk": "read", "stability": "stable", "summary": "Describe a property (owner/type/writeability) before setting it.", + "parameters": [ + { "name": "node", "$ref": "VisualTree.NodeHandle" }, + { "name": "name", "type": "string" } + ], + "returns": [ { "name": "descriptor", "$ref": "DpDescriptor" } ] + }, + { "name": "set", "risk": "mutate-ephemeral", "stability": "stable", "summary": "Set a property live as an ephemeral runtime override (maps to TAP setprop/seth). Runs on the UI dispatcher.", + "parameters": [ + { "name": "node", "$ref": "VisualTree.NodeHandle" }, + { "name": "name", "type": "string" }, + { "name": "valueType", "type": "string" }, + { "name": "value", "type": "string" } + ], + "returns": [ { "name": "applied", "$ref": "PropertyValue" } ] + }, + { "name": "setPreview", "risk": "mutate-ephemeral", "stability": "experimental", "summary": "Set a transient preview value that auto-reverts (for scrubbing). Never persisted.", + "parameters": [ + { "name": "node", "$ref": "VisualTree.NodeHandle" }, + { "name": "name", "type": "string" }, + { "name": "valueType", "type": "string" }, + { "name": "value", "type": "string" } + ], + "returns": [ { "name": "applied", "$ref": "PropertyValue" } ] + } + ], + "events": [] + }, + { + "domain": "Resource", + "capability": "resource", + "stability": "stable", + "summary": "Resolve and read XAML resources with provenance (StaticResource vs ThemeResource vs system).", + "types": [ + { "id": "ResourceKind", "kind": "enum", "values": ["static", "theme", "system"], "summary": "StaticResource (snapshot) vs ThemeResource (theme-tracking) vs system-provided." }, + { "id": "ResourceValue", "kind": "object", "summary": "A resolved resource.", "properties": [ + { "name": "key", "type": "string" }, + { "name": "value", "type": "string" }, + { "name": "valueType", "type": "string" }, + { "name": "kind", "$ref": "ResourceKind" }, + { "name": "origin", "type": "string", "optional": true, "summary": "Dictionary/origin provenance, when known." } + ]} + ], + "commands": [ + { "name": "resolve", "risk": "read", "stability": "stable", "summary": "Resolve a resource key in the context of a node (respects merged dictionaries and theme).", + "parameters": [ + { "name": "node", "$ref": "VisualTree.NodeHandle" }, + { "name": "key", "type": "string" } + ], + "returns": [ { "name": "resource", "$ref": "ResourceValue" } ] + }, + { "name": "read", "risk": "read", "stability": "stable", "summary": "Read a resource from the app/system scope without a node context.", + "parameters": [ + { "name": "key", "type": "string" }, + { "name": "kind", "$ref": "ResourceKind", "optional": true } + ], + "returns": [ { "name": "resource", "$ref": "ResourceValue" } ] + } + ], + "events": [] + }, + { + "domain": "Source", + "capability": "source", + "stability": "stable", + "summary": "Confidence-graded provenance over the read floor (RT1). NEVER the load-bearing wedge: a guaranteed tree+name+file floor with a never-wrong best-effort line layer. The census proved file-level provenance is durable across Debug/Release/DisableXbfLineInfo; line precision is gated on that one switch.", + "types": [ + { "id": "SourceKind", "kind": "enum", "values": ["source-backed", "template-generated", "style-generated", "binding-generated", "runtime-only", "resource-origin", "ambiguous", "unreachable"], "summary": "Provenance class. source-backed REQUIRES confidence exact|high with a span (false-confident prohibition)." }, + { "id": "SourceSpan", "kind": "object", "summary": "A location in authored markup. uri is an ms-appx:/// or ms-resource:/// URI, not a filesystem path.", "properties": [ + { "name": "uri", "type": "string", "summary": "ms-appx:///Page.xaml etc. Durable across configs (census)." }, + { "name": "line", "type": "integer", "optional": true, "summary": "1-based line; absent/0 when line info is stripped." }, + { "name": "column", "type": "integer", "optional": true }, + { "name": "lineInfo", "type": "boolean", "summary": "False when DisableXbfLineInfo stripped spans; the uri still survives." } + ]}, + { "id": "Anchor", "kind": "object", "summary": "Stable non-source anchor used when spans are stripped or absent.", "properties": [ + { "name": "name", "type": "string", "optional": true, "summary": "x:Name." }, + { "name": "treePath", "type": "string", "summary": "Structural path from a root (e.g. type+child-index chain)." }, + { "name": "propertySignature", "type": "string", "optional": true, "summary": "Distinguishing property signature." } + ]}, + { "id": "SourceResolution", "kind": "object", "summary": "The confidence-graded answer for one element.", "properties": [ + { "name": "kind", "$ref": "SourceKind" }, + { "name": "confidence", "$ref": "Confidence" }, + { "name": "span", "$ref": "SourceSpan", "optional": true, "summary": "The declaration span when confidently known." }, + { "name": "candidateSpans", "$ref": "SourceSpan", "array": true, "optional": true, "summary": "Ambiguous alternatives (kind=ambiguous)." }, + { "name": "realizedIndex", "type": "integer", "optional": true, "summary": "Index of a realized virtualized item." }, + { "name": "itemKey", "type": "string", "optional": true, "summary": "Data item key for a realized template instance." }, + { "name": "anchor", "$ref": "Anchor", "summary": "Always present: the never-wrong fallback." } + ]} + ], + "commands": [ + { "name": "resolve", "risk": "read", "stability": "stable", "summary": "Resolve an element to source. Obeys the false-confident prohibition: it will down-grade kind/confidence rather than guess a span.", + "parameters": [ { "name": "node", "$ref": "VisualTree.NodeHandle" } ], + "returns": [ { "name": "resolution", "$ref": "SourceResolution" } ] + }, + { "name": "anchor", "risk": "read", "stability": "stable", "summary": "Return only the stable anchor (cheap; always available even with no source).", + "parameters": [ { "name": "node", "$ref": "VisualTree.NodeHandle" } ], + "returns": [ { "name": "anchor", "$ref": "Anchor" } ] + } + ], + "events": [] + }, + { + "domain": "Diagnostics", + "capability": "diagnostics", + "stability": "stable", + "summary": "Structured runtime diagnostics as API, not log text: parser errors, binding failures, apply results, and source-gaps as first-class diagnostics.", + "types": [ + { "id": "ReasonCode", "kind": "enum", "values": ["parse-error", "binding-failure", "apply-failed", "source-info-missing", "template-generated", "unreachable-popup", "release-no-line-info", "unsafe-refused"], "summary": "Machine reason codes. Diagnostics are branched on by agents, so they are typed, not strings." }, + { "id": "Diagnostic", "kind": "object", "summary": "One diagnostic event payload.", "properties": [ + { "name": "code", "$ref": "ReasonCode" }, + { "name": "message", "type": "string", "summary": "Human-readable detail (secondary to code)." }, + { "name": "node", "$ref": "VisualTree.NodeHandle", "optional": true }, + { "name": "span", "$ref": "Source.SourceSpan", "optional": true }, + { "name": "confidence", "$ref": "Confidence", "optional": true } + ]} + ], + "commands": [ + { "name": "subscribe", "risk": "read", "stability": "stable", "summary": "Subscribe to the diagnostic stream, optionally filtered by reason code.", + "parameters": [ { "name": "kinds", "$ref": "ReasonCode", "array": true, "optional": true } ], + "returns": [ { "name": "subscriptionId", "type": "string" } ] + }, + { "name": "unsubscribe", "risk": "read", "stability": "stable", "summary": "Cancel a diagnostics subscription.", + "parameters": [ { "name": "subscriptionId", "type": "string" } ], "returns": [] } + ], + "events": [ + { "name": "diagnostic", "summary": "A parser/binding/apply failure or a source-gap.", "parameters": [ { "name": "diagnostic", "$ref": "Diagnostic" } ] } + ] + }, + { + "domain": "HotReload", + "capability": "hotreload", + "stability": "experimental", + "summary": "Classified edit application with an explicit transaction taxonomy (impl is W5, demand+EDR-gated). plan is a pure read (classify without applying); commit/rollback/apply mutate. The honesty invariant is retained end-to-end.", + "types": [ + { "id": "EditKind", "kind": "enum", "values": ["xaml", "cs"], "summary": "Which pipeline an edit routes to (XAML in-place vs C# EnC)." }, + { "id": "TransactionState", "kind": "enum", "values": ["planned", "previewed", "committed-runtime", "rendered-verified", "source-persisted", "rolled-back", "stale-handle", "target-lost", "refused-unsafe", "unreachable-gate"], "summary": "The lifecycle states an agent branches on. Terminal-failure states (stale-handle/target-lost/refused-unsafe/unreachable-gate) are honest, not exceptions." }, + { "id": "Edit", "kind": "object", "summary": "A single classified edit.", "properties": [ + { "name": "kind", "$ref": "EditKind" }, + { "name": "uri", "type": "string", "summary": "Source uri the edit targets." }, + { "name": "detail", "type": "string", "summary": "Opaque edit payload (diff/span/value) \u2014 shape owned by W5." } + ]}, + { "id": "Plan", "kind": "object", "summary": "The result of classifying a set of edits without applying them.", "properties": [ + { "name": "transactionId", "type": "string" }, + { "name": "edits", "$ref": "Edit", "array": true }, + { "name": "classification", "$ref": "Outcome" }, + { "name": "predictedState", "$ref": "TransactionState" } + ]} + ], + "commands": [ + { "name": "plan", "risk": "read", "stability": "experimental", "summary": "Classify edits and return a plan + predicted outcome. Pure read; nothing is applied.", + "parameters": [ { "name": "edits", "$ref": "Edit", "array": true } ], + "returns": [ { "name": "plan", "$ref": "Plan" } ] + }, + { "name": "preview", "risk": "mutate-ephemeral", "stability": "experimental", "summary": "Apply a plan as a reversible runtime-only preview.", + "parameters": [ { "name": "transactionId", "type": "string" } ], + "returns": [ { "name": "state", "$ref": "TransactionState" } ] + }, + { "name": "commit", "risk": "structural", "stability": "experimental", "summary": "Commit a previewed transaction to the running app.", + "parameters": [ { "name": "transactionId", "type": "string" } ], + "returns": [ { "name": "state", "$ref": "TransactionState" }, { "name": "outcome", "$ref": "Outcome" } ] + }, + { "name": "rollback", "risk": "structural", "stability": "experimental", "summary": "Roll back a previewed or committed-runtime transaction.", + "parameters": [ { "name": "transactionId", "type": "string" } ], + "returns": [ { "name": "state", "$ref": "TransactionState" } ] + }, + { "name": "apply", "risk": "persist", "stability": "experimental", "summary": "One-shot plan+commit(+persist) of a single edit. Returns the honest terminal state and outcome.", + "parameters": [ { "name": "kind", "$ref": "EditKind" }, { "name": "edit", "$ref": "Edit" } ], + "returns": [ { "name": "state", "$ref": "TransactionState" }, { "name": "outcome", "$ref": "Outcome" } ] + } + ], + "events": [ + { "name": "transactionStateChanged", "summary": "A transaction advanced (or failed into) a state.", "parameters": [ + { "name": "transactionId", "type": "string" }, + { "name": "state", "$ref": "TransactionState" } + ]} + ] + }, + { + "domain": "Selection", + "capability": "selection", + "stability": "experimental", + "summary": "Element highlight and pick (impl is W6). ALWAYS an out-of-proc transparent click-through overlay window over UIA/visual bounds \u2014 NEVER an injected Adorner. Documented fallback to inert bounding-box screenshots when live tracking lags.", + "types": [ + { "id": "HighlightStyle", "kind": "enum", "values": ["outline", "fill", "margins"], "summary": "How the overlay renders the element bounds." } + ], + "commands": [ + { "name": "highlight", "risk": "read", "stability": "experimental", "summary": "Highlight an element via the out-of-proc overlay.", + "parameters": [ + { "name": "node", "$ref": "VisualTree.NodeHandle" }, + { "name": "style", "$ref": "HighlightStyle", "optional": true } + ], + "returns": [] + }, + { "name": "pick", "risk": "read", "stability": "experimental", "summary": "Let the user pick an element on screen; returns the chosen node (reverse of highlight).", + "parameters": [], + "returns": [ { "name": "node", "$ref": "VisualTree.NodeHandle" } ] + }, + { "name": "clear", "risk": "read", "stability": "experimental", "summary": "Clear all overlays.", "parameters": [], "returns": [] } + ], + "events": [ + { "name": "picked", "summary": "The user completed a pick.", "parameters": [ { "name": "node", "$ref": "VisualTree.NodeHandle" } ] } + ] + }, + { + "domain": "Security", + "capability": "security", + "stability": "experimental", + "summary": "CORE, not periphery (RT5). Pipe ACL + same-user process auth + per-session nonce/token + replay prevention + capability grants by command family + explicit consent + tamper-resistant local audit + target allowlist + token expiry + command risk tiers. Impl is W8; the contract is normative now.", + "types": [ + { "id": "Grant", "kind": "object", "summary": "A capability-family grant for the session.", "properties": [ + { "name": "capability", "type": "string" }, + { "name": "riskTier", "$ref": "RiskTier" }, + { "name": "granted", "type": "boolean" }, + { "name": "expiresAt", "type": "string", "optional": true, "summary": "ISO-8601 expiry; absent = session lifetime." } + ]}, + { "id": "AuditEntry", "kind": "object", "summary": "One tamper-resistant local audit record.", "properties": [ + { "name": "timestamp", "type": "string" }, + { "name": "method", "type": "string" }, + { "name": "risk", "$ref": "RiskTier" }, + { "name": "host", "type": "string", "summary": "Client identity." }, + { "name": "result", "type": "string", "summary": "ok | unauthorized | refused." } + ]} + ], + "commands": [ + { "name": "authenticate", "risk": "privileged", "stability": "experimental", "summary": "Establish session auth over the same-user pipe: per-session nonce/token, host identity, replay prevention.", + "parameters": [ { "name": "token", "type": "string", "optional": true, "summary": "Prior session token for reconnect; omit for a fresh handshake." } ], + "returns": [ { "name": "sessionId", "type": "string" }, { "name": "host", "type": "string" } ] + }, + { "name": "grant", "risk": "privileged", "stability": "experimental", "summary": "Grant capability families to the session with explicit consent; higher risk tiers require explicit (not session-default) consent.", + "parameters": [ { "name": "capabilities", "type": "string", "array": true } ], + "returns": [ { "name": "grants", "$ref": "Grant", "array": true } ] + }, + { "name": "audit", "risk": "read", "stability": "experimental", "summary": "Read the local audit log (tamper-evident).", + "parameters": [ { "name": "since", "type": "string", "optional": true, "summary": "ISO-8601 lower bound." } ], + "returns": [ { "name": "entries", "$ref": "AuditEntry", "array": true } ] + } + ], + "events": [ + { "name": "grantChanged", "summary": "A grant was added, revoked, or expired.", "parameters": [ { "name": "grant", "$ref": "Grant" } ] } + ] + } + ] +} diff --git a/specs/winapp-devtools-overview.md b/specs/winapp-devtools-overview.md index fec59609..3aa36f40 100644 --- a/specs/winapp-devtools-overview.md +++ b/specs/winapp-devtools-overview.md @@ -213,7 +213,7 @@ pre-build reality probe. | WS | Spec file | Scope (one line) | Surfaces as | Gate | |----|-----------|------------------|-------------|------| | **W1** | `winapp-run-inspect.md` | Attach + resident daemon / session broker. | `run --inspect` / `--watch` host | latency + identity persistence | -| **W2** | `winapp-devtools-protocol.md` | Protocol schema + normative contracts (**proto built**). | the contract | additive: change ≤1 surface | +| **W2** | `winapp-devtools-protocol.md` | Protocol schema + normative contracts (**ported to `protocol/`; conformance gate green**). | the contract | additive: change ≤1 surface | | **W3** | `winapp-devtools-read.md` | Visual-tree + property + resource **read floor**. | inspect | config-independent (Debug=Release) | | **W4** | `winapp-devtools-provenance.md` | Confidence-graded source/identity + **census**. | select-to-source | resolution rate; false-confident → KILL | | **W5** | `winapp-devtools-hot-reload.md` | Mutation/apply + transaction taxonomy. | **`run --watch`** + client edits | differentiated packaged-reload slice | diff --git a/specs/winapp-devtools-protocol.md b/specs/winapp-devtools-protocol.md index 7732bacf..16968909 100644 --- a/specs/winapp-devtools-protocol.md +++ b/specs/winapp-devtools-protocol.md @@ -260,16 +260,23 @@ Current: **PASS (5/5).** Any new family **must** add a golden trace and keep the ## 11. Rough implementation phases (port + harden) -1. **Port.** Bring the proof-of-concept protocol assets (the canonical schema, its JSON-Schema guard, - the envelope spec, the generator, the golden traces, and the conformance suite) into the winapp repo - under a dedicated schema folder. Keep the generator + conformance projects pure `net10.0` (no - `-windows`) so the conformance gate runs on hosted CI. -2. **Wire the fast gate.** Add the conformance suite to CI as a required check, alongside the - schema-validation and license-header checks. -3. **Bind W1.** Align `WDXP.negotiate`/`cancel` + `Target.*` with the daemon's session/attach model as - W1 lands; keep the proof-of-concept transport green as the oracle. -4. **Grow by family.** As W3/W5/W6/W4 implement, tighten each family's types + add golden traces; - every field-add stays single-surface (Gate 3) and every family keeps its proof-of-concept smoke +**Status:** Phases 1–2 have **landed** under [`protocol/`](../protocol/) (base branch `winui-devex`). The +generator + conformance projects are pure `net10.0` and the fast gate runs on hosted Linux CI via +[`.github/workflows/protocol-conformance.yml`](../.github/workflows/protocol-conformance.yml) +(conformance **PASS 5/5** + license-header + public-appropriateness checks). Phases 3–4 are follow-ups. + +1. **Port. ✅ Landed.** The proof-of-concept protocol assets (the canonical schema `protocol/wdxp.v0.json`, + its JSON-Schema guard, the envelope spec, the generator, the golden traces, and the conformance suite) + are in the winapp repo under `protocol/`. The generator + conformance projects are pure `net10.0` (no + `-windows`) so the conformance gate runs on hosted CI. (The internal agent-tool-manifest facade was + dropped on port — only the CLI-JSON + docs facades ship.) +2. **Wire the fast gate. ✅ Landed.** The conformance suite runs in CI as a check (intended to be marked a + required status check in branch protection), alongside the license-header and public-appropriateness + checks. +3. **Bind W1.** *(Follow-up — waits on `run --inspect`.)* Align `WDXP.negotiate`/`cancel` + `Target.*` with + the daemon's session/attach model as W1 lands; keep the proof-of-concept transport green as the oracle. +4. **Grow by family.** *(Follow-up.)* As W3/W5/W6/W4 implement, tighten each family's types + add golden + traces; every field-add stays single-surface (Gate 3) and every family keeps its proof-of-concept smoke check green. --- From cc727d80ec23b1b6f7bf40c349ad0596e31000ac Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:49:19 -0700 Subject: [PATCH 2/3] protocol: address reviewer round-1 findings (schema/doc drift + gate3 teeth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes for the round-1 review pass on the WDXP protocol port. All hard gates were already green; these close minor schema/doc drift and give Gate 3 real field-level teeth. Minor (schema/doc drift, verified against wdxp.v0.json source of truth): - README family map: sessionEnded -> sessionClosing; treeChanged -> childrenChanged. - README Property.ValueSource: templateBinding -> template, builtInStyle -> resource (now exactly the schema enum: local, animation, template, style, resource, inherited, default). - README Outcome: normalized the whole PascalCase list to the schema-exact values (applied, applied-inert, reloaded, needs-restart) — removes latent drift beyond the single ReloadRequired -> reloaded the reviewer flagged. - wdxp.schema.json $id: repointed off the personal fork URL to the microsoft/winappCli path. Nits: - conformance: golden traces with an empty/missing 'messages' array now FAIL ("nothing to conform") instead of passing vacuously. - Gate 3 now asserts field-level totality on the CLI facade (the structured contract clients bind to): every declared param/return field must be emitted for its command/event, and no undeclared field may be smuggled in. Success detail reports the field tally (89 fields). README and envelope wording tightened to state exactly what the gate asserts. Verified locally: Release build 0 warnings; conformance PASS (5/5); negative test (simulated field drop) makes Gate 3 FAIL with precise per-field diagnostics; public-appropriateness scan 0 hits; license-header check green. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d376fce1-8858-45ae-a496-245835e5879f --- protocol/README.md | 23 +++++++++-------- protocol/conformance/Program.cs | 45 ++++++++++++++++++++++++++++++++- protocol/envelope.md | 6 +++-- protocol/wdxp.schema.json | 2 +- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/protocol/README.md b/protocol/README.md index a03917c2..dcba9e35 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -33,10 +33,13 @@ language-neutral JSON contract that every implementation reads, with a small gen 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. +**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. --- @@ -79,9 +82,9 @@ Grouped by the debate's guaranteed **floor** vs. the mutation/best-effort surfac | Domain | Capability | Cmds/Evts | Stability | What it does | |---|---|---|---|---| -| `WDXP` | `core` | 2 / 1 | stable | `negotiate` (capability handshake), `cancel`; `sessionEnded`. | +| `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; `treeChanged`. | +| `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). | @@ -104,14 +107,14 @@ info is unavailable) and `Diagnostics.ReasonCode`. 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. +- **`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` / `AppliedInert` / - `ReloadRequired` / `NeedsRestart`) — the **honesty invariant**: the engine never claims success it +- **Protocol-level `Outcome`** — the four-outcome classifier (`applied` / `applied-inert` / + `reloaded` / `needs-restart`) — 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. diff --git a/protocol/conformance/Program.cs b/protocol/conformance/Program.cs index 9f7a3b25..c3a7d796 100644 --- a/protocol/conformance/Program.cs +++ b/protocol/conformance/Program.cs @@ -83,8 +83,49 @@ 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 present in every facade" : string.Join("; ", errs)); + 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, @@ -154,6 +195,8 @@ 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)); } diff --git a/protocol/envelope.md b/protocol/envelope.md index c8f664c8..7fdd6130 100644 --- a/protocol/envelope.md +++ b/protocol/envelope.md @@ -141,8 +141,10 @@ Full policy is the Security domain (W8); the envelope pins the parts that live a - 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. + 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. --- diff --git a/protocol/wdxp.schema.json b/protocol/wdxp.schema.json index c21eb091..0e5a5435 100644 --- a/protocol/wdxp.schema.json +++ b/protocol/wdxp.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/nmetulev/winui-devex/main/protocol/wdxp.schema.json", + "$id": "https://raw.githubusercontent.com/microsoft/winappCli/main/protocol/wdxp.schema.json", "title": "WDXP protocol definition guard", "description": "Validates the hand-authored wdxp.v0.json (DAP-style single source of truth). Also serves as the IDE authoring aid via the $schema reference in the protocol file.", "type": "object", From 75420eba16d43c209d5088cd0819a5622650b1f3 Mon Sep 17 00:00:00 2001 From: Nikola Metulev <711864+nmetulev@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:35:16 -0700 Subject: [PATCH 3/3] fix(protocol): harden W2 conformance gate + contract consistency (reviewer round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the 12 remaining reviewer round-2 findings on top of cc727d8. All are inside W2 scope (schema, conformance gate, docs, golden traces, scan/CI). No runtime work built; deferred-runtime areas (W1 transport / W8 security) are softened in docs with explicit "deferred" notes rather than implemented. Tooling / gate depth: - #2 Deepen golden validation: ConformTrace now requires jsonrpc "2.0" on every message, rejects result+error together, correlates error ids (not just results) to a prior request, and validates payloads RECURSIVELY against the contract via ValidateObject/ValidateValue/ValidateScalar — nested $ref objects, enum value sets, and array element types, not just top-level names. - #4 Assert golden trace count > 0 (an empty golden dir no longer passes vacuously). - #5 gen Validator.CheckType now runs CheckField over each object type's properties, so a dangling $ref inside an object type is flagged (was silent). - #8 New golden traces 04-07 (HotReload lifecycle, Resource+Diagnostics, Selection +Security, describe/set/cancel lifecycle). New golden-coverage check requires every command+event to be exercised by at least one trace (all 40 covered). - #10 wdxp.schema.json root closed (additionalProperties:false) + $schema property; new protocol/scripts/validate-schema.py (draft 2020-12 guard) wired into CI. - #11 check-public-appropriateness.ps1 broadened to also scan specs/winapp-devtools-*.md and AGENTS.md (excludes the csproj agent's file by design). - #15 Extract shared Wdxp.Gen.SchemaPaths.FindUp; gen + conformance both call it. Schema / contract consistency: - #7 Outcome invariant narrowed to transactional apply/commit; ephemeral Property.set/setPreview return the resulting PropertyValue (schema + envelope §6 + README). - #12 Security.authenticate now returns token (+optional expiresAt) so the reconnect token param has a source; envelope §7 softened to "host-defined, finalized in W8". - #13 envelope §3/§7 document the pre-auth trust boundary (CurrentUserOnly pipe ACL) and what target binding grants before authenticate. - #14 ids pinned to string protocol-wide (envelope §2, enforced in ConformTrace); NodeHandle.handle integer -> string (decimal-encoded uint64, no >2^53 precision loss); goldens 02/03 handles updated to strings. - #6 envelope §1/§3 clarify per-target pipe vs pre-bind discovery; cross-target discovery marked W1/phase-3 deferred. - README: de-hardcode the check count, Applied->applied casing, schema-guard note. Verified: Release build 0 warnings; conformance PASS (10/10) with recursive validation + full coverage; negative tests prove teeth (missing jsonrpc, numeric id, result+error, unknown/enum field, dangling $ref, closed-root schema guard); python schema guard PASS (+rejects stray key); license headers + broadened scrub scan green. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d376fce1-8858-45ae-a496-245835e5879f --- .github/workflows/protocol-conformance.yml | 13 +- protocol/README.md | 19 +- protocol/conformance/Program.cs | 238 +++++++++++++----- protocol/envelope.md | 48 ++-- protocol/gen/Program.cs | 24 +- protocol/gen/SchemaPaths.cs | 23 ++ protocol/golden/02-read-floor.json | 12 +- protocol/golden/03-subscribe-and-errors.json | 8 +- protocol/golden/04-hotreload-lifecycle.json | 39 +++ .../golden/05-resource-and-diagnostics.json | 31 +++ .../golden/06-selection-and-security.json | 41 +++ .../07-describe-set-cancel-lifecycle.json | 48 ++++ .../scripts/check-public-appropriateness.ps1 | 33 ++- protocol/scripts/validate-schema.py | 64 +++++ protocol/wdxp.schema.json | 3 +- protocol/wdxp.v0.json | 12 +- 16 files changed, 533 insertions(+), 123 deletions(-) create mode 100644 protocol/gen/SchemaPaths.cs create mode 100644 protocol/golden/04-hotreload-lifecycle.json create mode 100644 protocol/golden/05-resource-and-diagnostics.json create mode 100644 protocol/golden/06-selection-and-security.json create mode 100644 protocol/golden/07-describe-set-cancel-lifecycle.json create mode 100644 protocol/scripts/validate-schema.py diff --git a/.github/workflows/protocol-conformance.yml b/.github/workflows/protocol-conformance.yml index 0b4d3fa9..0fda5244 100644 --- a/.github/workflows/protocol-conformance.yml +++ b/.github/workflows/protocol-conformance.yml @@ -25,10 +25,21 @@ jobs: with: dotnet-version: 10.0.x - # Gate 3 (facade totality) + schema-valid + 3 golden traces. Exit 0 == PASS (5/5). + # 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 diff --git a/protocol/README.md b/protocol/README.md index dcba9e35..c3b2a116 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -48,12 +48,13 @@ model generically. | 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. | +| `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. | -| `conformance/` | The **W2 gate**. `dotnet run` → validates the schema, proves Gate-3 facade totality, and checks the golden traces. Exit 0 = green. | +| `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`. | --- @@ -66,9 +67,9 @@ model generically. 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): +# 3. Prove it (schema valid + Gate-3 totality + golden traces conform + coverage): cd protocol/conformance ; dotnet run -c Debug -# -> RESULT: PASS (5/5 checks) [exit 0] +# -> 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 @@ -114,8 +115,10 @@ These carry the hard-won policy. Do not weaken them without re-opening the debat 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`) — the **honesty invariant**: the engine never claims success it - can't guarantee. + `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. @@ -154,7 +157,7 @@ it. A **session is a connection**; capabilities are negotiated per-connection vi - **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. + 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. diff --git a/protocol/conformance/Program.cs b/protocol/conformance/Program.cs index c3a7d796..807f3702 100644 --- a/protocol/conformance/Program.cs +++ b/protocol/conformance/Program.cs @@ -1,18 +1,20 @@ // 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). +// 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 = FindUp("wdxp.v0.json"); +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"); @@ -29,16 +31,29 @@ // ---- 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); +// ---- 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 - foreach (var file in Directory.EnumerateFiles(goldenDir, "*.json").OrderBy(f => f)) - checks.Add(ConformTrace(file, commands, events, errorCodes)); +{ + 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"); @@ -128,8 +143,10 @@ static int CheckCliFields(JsonElement node, string prop, IReadOnlyList de return declared.Count; } -static (string, bool, string) ConformTrace(string file, IReadOnlyDictionary commands, - IReadOnlyDictionary events, IReadOnlyDictionary errorCodes) +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)); @@ -144,52 +161,67 @@ static int CheckCliFields(JsonElement node, string prop, IReadOnlyList de 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'"); + // 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); - string? id = hasId ? (idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : idEl.ToString()) : null; - if (hasError) + // 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) { - 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}"); + errs.Add($"{where}: a response carries BOTH 'result' and 'error' (exactly one is allowed)"); } - else if (hasResult) + 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 cmd)) - ValidateFields(result, cmd.Returns, $"{where} result[{method}]", errs); + 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()!; - id ??= ""; - idToMethod[id] = method; - if (!commands.TryGetValue(method, out var cmd)) + 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.ValueKind == JsonValueKind.Object ? pe : default; - ValidateFields(pars, cmd.Parameters, $"{where} params[{method}]", errs); + 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 evt)) + if (!events.TryGetValue(method, out var e)) 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); + 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"); @@ -200,41 +232,131 @@ static int CheckCliFields(JsonElement node, string prop, IReadOnlyList de 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) +// 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); - 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 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}'"); + { + 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); + } } -static Dictionary LoadErrorCodes(string schemaPath) +// 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) { - 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; + 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); } -static string? FindUp(string fileName) +// 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) { - foreach (var start in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory }) + if (f.Ref is not null) { - var dir = new DirectoryInfo(start); - while (dir 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) { - 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; + 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; } - return null; + 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/envelope.md b/protocol/envelope.md index 7fdd6130..e19731c5 100644 --- a/protocol/envelope.md +++ b/protocol/envelope.md @@ -23,6 +23,10 @@ request/response pair **and** a server-initiated notification over a `CurrentUse 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) @@ -32,18 +36,22 @@ Three message kinds, all with `"jsonrpc": "2.0"`: ```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`. +- `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 } ] } } +{ "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 @@ -53,7 +61,7 @@ Three message kinds, all with `"jsonrpc": "2.0"`: **Notification** (engine → client, no `id`, no response): ```json { "jsonrpc": "2.0", "method": "VisualTree.childrenChanged", - "params": { "node": { "handle": 3, "generation": 2 }, "added": [], "removed": [] } } + "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. @@ -65,7 +73,14 @@ connection to exactly one target, so "which app" is the connection, never a per- 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. + `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. @@ -118,20 +133,25 @@ WDXP application errors and how a client should react: | -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. +**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). -- `Security.authenticate` establishes a **per-session nonce/token** with **replay prevention**; every - command carries the session's authorization implicitly (it is the connection). +- `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`). diff --git a/protocol/gen/Program.cs b/protocol/gen/Program.cs index e88ae0a4..d5e1bf1e 100644 --- a/protocol/gen/Program.cs +++ b/protocol/gen/Program.cs @@ -8,7 +8,7 @@ using System.Text.Json; using Wdxp.Gen; -var schemaPath = ArgValue(args, "--schema") ?? FindSchema(); +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")); @@ -45,21 +45,6 @@ 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; @@ -77,7 +62,7 @@ public static List Validate(Protocol p, RefResolver r) { 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 t in d.Types) CheckType(t, d, errors, r); foreach (var c in d.Commands) { @@ -102,7 +87,7 @@ public static List Validate(Protocol p, RefResolver r) return errors; } - private static void CheckType(TypeDef t, Domain d, List errors) + private static void CheckType(TypeDef t, Domain d, List errors, RefResolver r) { switch (t.Kind) { @@ -110,6 +95,9 @@ private static void CheckType(TypeDef t, Domain d, List errors) 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) diff --git a/protocol/gen/SchemaPaths.cs b/protocol/gen/SchemaPaths.cs new file mode 100644 index 00000000..4808e7b8 --- /dev/null +++ b/protocol/gen/SchemaPaths.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +namespace Wdxp.Gen; + +/// Locates the protocol source of truth by walking up from the current directory and the app +/// base directory, checking both <dir>/protocol/<file> and <dir>/<file>. Shared by +/// the generator and the conformance suite so the lookup logic can never drift between them. +public static class SchemaPaths +{ + public 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 candidate in new[] { Path.Combine(dir.FullName, "protocol", fileName), Path.Combine(dir.FullName, fileName) }) + if (File.Exists(candidate)) return candidate; + dir = dir.Parent; + } + } + return null; + } +} diff --git a/protocol/golden/02-read-floor.json b/protocol/golden/02-read-floor.json index 1806e761..d2e94e6c 100644 --- a/protocol/golden/02-read-floor.json +++ b/protocol/golden/02-read-floor.json @@ -4,17 +4,17 @@ "messages": [ { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "10", "method": "VisualTree.search", "params": { "name": "Title" } } }, { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "10", - "result": { "matches": [ { "handle": 42, "generation": 1 } ] } } }, + "result": { "matches": [ { "handle": "42", "generation": 1 } ] } } }, { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "11", "method": "VisualTree.enumerate", - "params": { "node": { "handle": 42, "generation": 1 }, "depth": 1 } } }, + "params": { "node": { "handle": "42", "generation": 1 }, "depth": 1 } } }, { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "11", "result": { - "root": [ { "node": { "handle": 42, "generation": 1 }, "type": "TextBlock", "name": "Title", "childCount": 0 } ], + "root": [ { "node": { "handle": "42", "generation": 1 }, "type": "TextBlock", "name": "Title", "childCount": 0 } ], "nodes": [] } } }, { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "12", "method": "Property.get", - "params": { "node": { "handle": 42, "generation": 1 }, "names": ["Opacity", "Text"] } } }, + "params": { "node": { "handle": "42", "generation": 1 }, "names": ["Opacity", "Text"] } } }, { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "12", "result": { "properties": [ { "name": "Opacity", "value": "1", "valueType": "Double", "source": "default", "isDefault": true }, @@ -22,7 +22,7 @@ ] } } }, { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "13", "method": "Source.resolve", - "params": { "node": { "handle": 42, "generation": 1 } } } }, + "params": { "node": { "handle": "42", "generation": 1 } } } }, { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "13", "result": { "resolution": { "kind": "source-backed", "confidence": "exact", @@ -31,7 +31,7 @@ } } } }, { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "14", "method": "Source.resolve", - "params": { "node": { "handle": 77, "generation": 1 } } } }, + "params": { "node": { "handle": "77", "generation": 1 } } } }, { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "14", "result": { "resolution": { "kind": "runtime-only", "confidence": "none", diff --git a/protocol/golden/03-subscribe-and-errors.json b/protocol/golden/03-subscribe-and-errors.json index e4dce779..ff045ee8 100644 --- a/protocol/golden/03-subscribe-and-errors.json +++ b/protocol/golden/03-subscribe-and-errors.json @@ -3,15 +3,15 @@ "summary": "Structural subscription with a server notification, plus the two most common recoverable errors: a stale handle after the tree mutated (re-enumerate), and a capability that was never negotiated (clean typed refusal, never a crash). Exercises the notification model and the error taxonomy.", "messages": [ { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "20", "method": "VisualTree.subscribe", - "params": { "node": { "handle": 5, "generation": 1 } } } }, + "params": { "node": { "handle": "5", "generation": 1 } } } }, { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "20", "result": { "subscriptionId": "sub-1" } } }, { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "VisualTree.childrenChanged", - "params": { "node": { "handle": 5, "generation": 2 }, - "added": [ { "handle": 91, "generation": 1 } ], "removed": [] } } }, + "params": { "node": { "handle": "5", "generation": 2 }, + "added": [ { "handle": "91", "generation": 1 } ], "removed": [] } } }, { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "21", "method": "Property.get", - "params": { "node": { "handle": 5, "generation": 1 } } } }, + "params": { "node": { "handle": "5", "generation": 1 } } } }, { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "21", "error": { "code": -32001, "name": "StaleHandle", "message": "Handle 5 generation 1 is stale (current 2). Re-enumerate to refresh handles." } } }, diff --git a/protocol/golden/04-hotreload-lifecycle.json b/protocol/golden/04-hotreload-lifecycle.json new file mode 100644 index 00000000..130fb1b1 --- /dev/null +++ b/protocol/golden/04-hotreload-lifecycle.json @@ -0,0 +1,39 @@ +{ + "scenario": "04-hotreload-lifecycle", + "summary": "The HotReload transaction taxonomy end-to-end on the attached session: classify (plan, a pure read), preview the runtime-only change, watch it advance via transactionStateChanged, commit it, then roll back; finally a one-shot apply that persists to source. Every mutating result reports an honest TransactionState + Outcome — never a stronger outcome than achieved.", + "messages": [ + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "40", "method": "HotReload.plan", + "params": { "edits": [ { "kind": "xaml", "uri": "ms-appx:///SmokePage.xaml", "detail": "set TextBlock[Title].Text = \"Reloaded\"" } ] } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "40", + "result": { "plan": { + "transactionId": "tx-1", + "edits": [ { "kind": "xaml", "uri": "ms-appx:///SmokePage.xaml", "detail": "set TextBlock[Title].Text = \"Reloaded\"" } ], + "classification": "applied", + "predictedState": "planned" + } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "41", "method": "HotReload.preview", + "params": { "transactionId": "tx-1" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "41", "result": { "state": "previewed" } } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "HotReload.transactionStateChanged", + "params": { "transactionId": "tx-1", "state": "previewed" } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "42", "method": "HotReload.commit", + "params": { "transactionId": "tx-1" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "42", + "result": { "state": "committed-runtime", "outcome": "applied" } } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "HotReload.transactionStateChanged", + "params": { "transactionId": "tx-1", "state": "committed-runtime" } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "43", "method": "HotReload.rollback", + "params": { "transactionId": "tx-1" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "43", "result": { "state": "rolled-back" } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "44", "method": "HotReload.apply", + "params": { "kind": "xaml", "edit": { "kind": "xaml", "uri": "ms-appx:///SmokePage.xaml", "detail": "set Grid.Background = \"#FF202020\"" } } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "44", + "result": { "state": "source-persisted", "outcome": "reloaded" } } } + ] +} diff --git a/protocol/golden/05-resource-and-diagnostics.json b/protocol/golden/05-resource-and-diagnostics.json new file mode 100644 index 00000000..e20e5230 --- /dev/null +++ b/protocol/golden/05-resource-and-diagnostics.json @@ -0,0 +1,31 @@ +{ + "scenario": "05-resource-and-diagnostics", + "summary": "Two provenance-carrying reads plus the structured diagnostic stream on the attached session: resolve a resource key in a node's context (theme-tracking), read an app/system resource without a node, then subscribe to typed diagnostics and receive a binding-failure as a first-class Diagnostic (machine reason code + confidence), not log text.", + "messages": [ + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "50", "method": "Resource.resolve", + "params": { "node": { "handle": "42", "generation": 1 }, "key": "SystemAccentColor" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "50", + "result": { "resource": { "key": "SystemAccentColor", "value": "#FF0078D4", "valueType": "Color", "kind": "theme" } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "51", "method": "Resource.read", + "params": { "key": "TextFillColorPrimaryBrush", "kind": "theme" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "51", + "result": { "resource": { "key": "TextFillColorPrimaryBrush", "value": "#E4000000", "valueType": "SolidColorBrush", "kind": "theme", "origin": "Fluent/Themes/Dark" } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "52", "method": "Diagnostics.subscribe", + "params": { "kinds": ["binding-failure", "parse-error"] } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "52", "result": { "subscriptionId": "diag-1" } } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "Diagnostics.diagnostic", + "params": { "diagnostic": { + "code": "binding-failure", + "message": "BindingExpression path error: 'Subtitle' property not found on 'ViewModel'.", + "node": { "handle": "42", "generation": 1 }, + "confidence": "high" + } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "53", "method": "Diagnostics.unsubscribe", + "params": { "subscriptionId": "diag-1" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "53", "result": {} } } + ] +} diff --git a/protocol/golden/06-selection-and-security.json b/protocol/golden/06-selection-and-security.json new file mode 100644 index 00000000..8374ee07 --- /dev/null +++ b/protocol/golden/06-selection-and-security.json @@ -0,0 +1,41 @@ +{ + "scenario": "06-selection-and-security", + "summary": "The privileged Security floor and the out-of-proc Selection overlay: authenticate (issuing a per-session token bound to host identity), grant capability families by risk tier, receive a grantChanged notification, then highlight/pick/clear via the transparent overlay window (never an injected adorner) with a picked notification, and finally read the tamper-evident local audit log.", + "messages": [ + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "60", "method": "Security.authenticate", "params": {} } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "60", + "result": { "sessionId": "s-1", "host": "winapp-vsc/1.2.0", "token": "tok-9f3a2b", "expiresAt": "2026-07-12T10:00:00Z" } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "61", "method": "Security.grant", + "params": { "capabilities": ["hotreload", "selection"] } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "61", + "result": { "grants": [ + { "capability": "hotreload", "riskTier": "structural", "granted": true, "expiresAt": "2026-07-12T11:00:00Z" }, + { "capability": "selection", "riskTier": "read", "granted": true } + ] } } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "Security.grantChanged", + "params": { "grant": { "capability": "hotreload", "riskTier": "structural", "granted": true, "expiresAt": "2026-07-12T11:00:00Z" } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "62", "method": "Selection.highlight", + "params": { "node": { "handle": "42", "generation": 1 }, "style": "outline" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "62", "result": {} } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "63", "method": "Selection.pick", "params": {} } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "63", + "result": { "node": { "handle": "77", "generation": 1 } } } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "Selection.picked", + "params": { "node": { "handle": "77", "generation": 1 } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "64", "method": "Selection.clear", "params": {} } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "64", "result": {} } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "65", "method": "Security.audit", + "params": { "since": "2026-07-12T09:00:00Z" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "65", + "result": { "entries": [ + { "timestamp": "2026-07-12T09:30:00Z", "method": "HotReload.commit", "risk": "structural", "host": "winapp-vsc/1.2.0", "result": "ok" } + ] } } } + ] +} diff --git a/protocol/golden/07-describe-set-cancel-lifecycle.json b/protocol/golden/07-describe-set-cancel-lifecycle.json new file mode 100644 index 00000000..32537f94 --- /dev/null +++ b/protocol/golden/07-describe-set-cancel-lifecycle.json @@ -0,0 +1,48 @@ +{ + "scenario": "07-describe-set-cancel-lifecycle", + "summary": "Node/property describe before mutate, the two ephemeral Property mutations (set + auto-reverting setPreview, each returning the resulting PropertyValue rather than an Outcome), the cheap always-available Source anchor, a best-effort WDXP.cancel, a transport reconnect, then teardown: detach followed by the honest targetLost + sessionClosing notifications.", + "messages": [ + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "70", "method": "VisualTree.describe", + "params": { "node": { "handle": "42", "generation": 1 } } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "70", + "result": { "node": { "node": { "handle": "42", "generation": 1 }, "type": "TextBlock", "name": "Title", "childCount": 0 } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "71", "method": "Property.describe", + "params": { "node": { "handle": "42", "generation": 1 }, "name": "Opacity" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "71", + "result": { "descriptor": { "name": "Opacity", "ownerType": "UIElement", "valueType": "Double", "writeable": true, "attached": false } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "72", "method": "Property.set", + "params": { "node": { "handle": "42", "generation": 1 }, "name": "Opacity", "valueType": "Double", "value": "0.5" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "72", + "result": { "applied": { "name": "Opacity", "value": "0.5", "valueType": "Double", "source": "local", "isDefault": false } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "73", "method": "Property.setPreview", + "params": { "node": { "handle": "42", "generation": 1 }, "name": "Opacity", "valueType": "Double", "value": "0.8" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "73", + "result": { "applied": { "name": "Opacity", "value": "0.8", "valueType": "Double", "source": "local", "isDefault": false } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "74", "method": "Source.anchor", + "params": { "node": { "handle": "42", "generation": 1 } } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "74", + "result": { "anchor": { "name": "Title", "treePath": "Page/Grid/StackPanel[0]/TextBlock[0]", "propertySignature": "Text" } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "75", "method": "WDXP.cancel", + "params": { "id": "31" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "75", "result": { "cancelled": true } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "76", "method": "Target.reconnect", + "params": { "sessionId": "s-1" } } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "76", + "result": { "target": { "targetId": "t-8123", "pid": 8123, "title": "SmokeFixture", "package": { "packaged": false, "path": "C:\\fixtures\\SmokeFixture.exe" }, "rootCount": 1, "protocolVersion": "0.1.0" } } } }, + + { "dir": "c2s", "msg": { "jsonrpc": "2.0", "id": "77", "method": "Target.detach", "params": {} } }, + { "dir": "s2c", "msg": { "jsonrpc": "2.0", "id": "77", "result": {} } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "Target.targetLost", + "params": { "targetId": "t-8123", "reason": "detached" } } }, + + { "dir": "s2c-event", "msg": { "jsonrpc": "2.0", "method": "WDXP.sessionClosing", + "params": { "reason": "target-lost" } } } + ] +} diff --git a/protocol/scripts/check-public-appropriateness.ps1 b/protocol/scripts/check-public-appropriateness.ps1 index e7d297d2..c10ef455 100644 --- a/protocol/scripts/check-public-appropriateness.ps1 +++ b/protocol/scripts/check-public-appropriateness.ps1 @@ -10,10 +10,14 @@ # * the injection/platform runtime component name, # * internal transport source-path references. # The search terms are assembled from fragments so this scanner never matches itself. +# +# Scope: all of protocol/ (minus build/generated output) PLUS the cross-cutting docs this port also +# touches — the devtools specs (specs/winapp-devtools-*.md) and the repo AGENTS.md. The devtools glob +# deliberately excludes specs/winapp-run-csproj.md (a different workstream owns it). [CmdletBinding()] param( - # Defaults to the protocol/ folder (the parent of this scripts/ directory). - [string]$Root = (Split-Path -Parent $PSScriptRoot) + # Repo root; defaults to two levels up from this scripts/ directory (protocol/scripts -> protocol -> repo root). + [string]$RepoRoot = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) ) $ErrorActionPreference = 'Stop' @@ -30,21 +34,36 @@ $terms = @( ) $rx = ($terms -join '|') -$hits = Get-ChildItem -Path $Root -Recurse -File | +$targets = [System.Collections.Generic.List[System.IO.FileInfo]]::new() + +# 1. The whole protocol/ folder, excluding build output and generated facades. +$protocolDir = Join-Path $RepoRoot 'protocol' +Get-ChildItem -Path $protocolDir -Recurse -File | Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' -and $_.FullName -notmatch '[\\/]gen[\\/]out[\\/]' - } | - Select-String -Pattern $rx + } | ForEach-Object { $targets.Add($_) } + +# 2. The devtools specs this port edits (overview + protocol), and their siblings for good measure. +$specsDir = Join-Path $RepoRoot 'specs' +if (Test-Path $specsDir) { + Get-ChildItem -Path $specsDir -Filter 'winapp-devtools-*.md' -File | ForEach-Object { $targets.Add($_) } +} + +# 3. The repo AGENTS.md (this port adds a protocol/ pointer to it). +$agents = Join-Path $RepoRoot 'AGENTS.md' +if (Test-Path $agents) { $targets.Add((Get-Item $agents)) } + +$hits = $targets | Select-String -Pattern $rx if ($hits) { Write-Host "Public-appropriateness scan FAILED - $($hits.Count) forbidden reference(s):" foreach ($h in $hits) { - $rel = $h.Path.Substring($Root.Length).TrimStart('\', '/') + $rel = $h.Path.Substring($RepoRoot.Length).TrimStart('\', '/') Write-Host (" {0}:{1}: {2}" -f $rel, $h.LineNumber, $h.Line.Trim()) } exit 1 } -Write-Host "Public-appropriateness scan passed (zero forbidden references under protocol/)." +Write-Host "Public-appropriateness scan passed (zero forbidden references under protocol/, specs/winapp-devtools-*.md, AGENTS.md)." exit 0 diff --git a/protocol/scripts/validate-schema.py b/protocol/scripts/validate-schema.py new file mode 100644 index 00000000..0ae3698a --- /dev/null +++ b/protocol/scripts/validate-schema.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. Licensed under the MIT License. +# +# validate-schema.py — the JSON-Schema (draft 2020-12) guard for the WDXP canonical file. +# +# This is the *second* validator (the C# conformance suite is the first): it checks that +# wdxp.v0.json still satisfies wdxp.schema.json structurally. Keeping both green closes the +# "two validators drift apart" gap. Pure-stdlib except for `jsonschema`; no network access — +# every $ref in the schema is internal (#/$defs/...), and the instance's own "$schema": +# "./wdxp.schema.json" is treated as plain data, never fetched. +# +# Usage: python3 protocol/scripts/validate-schema.py +# Exit 0 = valid; 2 = invalid / error. + +import json +import sys +from pathlib import Path + +try: + from jsonschema import Draft202012Validator +except ImportError: + print("error: the 'jsonschema' package is required (pip install jsonschema)", file=sys.stderr) + sys.exit(2) + +PROTOCOL_DIR = Path(__file__).resolve().parent.parent +SCHEMA_PATH = PROTOCOL_DIR / "wdxp.schema.json" +INSTANCE_PATH = PROTOCOL_DIR / "wdxp.v0.json" + + +def load(path: Path): + try: + with path.open(encoding="utf-8") as fh: + return json.load(fh) + except FileNotFoundError: + print(f"error: file not found: {path}", file=sys.stderr) + sys.exit(2) + except json.JSONDecodeError as exc: + print(f"error: {path.name} is not valid JSON: {exc}", file=sys.stderr) + sys.exit(2) + + +def main() -> int: + schema = load(SCHEMA_PATH) + instance = load(INSTANCE_PATH) + + # Validate the schema document itself, then the canonical instance against it. + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.path)) + + if errors: + print(f"FAIL: {INSTANCE_PATH.name} does not satisfy {SCHEMA_PATH.name} " + f"({len(errors)} error(s)):", file=sys.stderr) + for err in errors: + location = "/".join(str(p) for p in err.path) or "" + print(f" - at {location}: {err.message}", file=sys.stderr) + return 2 + + print(f"PASS: {INSTANCE_PATH.name} satisfies {SCHEMA_PATH.name} " + f"(draft 2020-12; root closed, internal $refs only).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/protocol/wdxp.schema.json b/protocol/wdxp.schema.json index 0e5a5435..6f6625da 100644 --- a/protocol/wdxp.schema.json +++ b/protocol/wdxp.schema.json @@ -5,8 +5,9 @@ "description": "Validates the hand-authored wdxp.v0.json (DAP-style single source of truth). Also serves as the IDE authoring aid via the $schema reference in the protocol file.", "type": "object", "required": ["protocol", "version", "stability", "framing", "errorCodes", "riskTiers", "types", "domains"], - "additionalProperties": true, + "additionalProperties": false, "properties": { + "$schema": { "type": "string" }, "protocol": { "const": "wdxp" }, "title": { "type": "string" }, "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, diff --git a/protocol/wdxp.v0.json b/protocol/wdxp.v0.json index 8bda32ef..ea0aa976 100644 --- a/protocol/wdxp.v0.json +++ b/protocol/wdxp.v0.json @@ -34,7 +34,7 @@ ], "types": [ { "id": "Confidence", "kind": "enum", "values": ["exact", "high", "low", "none"], "summary": "Grades any best-effort resolution (source spans, identity, diagnostics). Ordered strongest\u2192weakest." }, - { "id": "Outcome", "kind": "enum", "values": ["applied", "applied-inert", "reloaded", "needs-restart"], "summary": "The four-outcome classifier every mutating result reports. Honesty invariant: never report a stronger outcome than actually achieved." }, + { "id": "Outcome", "kind": "enum", "values": ["applied", "applied-inert", "reloaded", "needs-restart"], "summary": "The four-outcome classifier a transactional apply/commit reports (HotReload.commit/apply; predicted in a plan's classification). Honesty invariant: never report a stronger outcome than actually achieved. An ephemeral Property.set returns the resulting PropertyValue, not an Outcome." }, { "id": "RiskTier", "kind": "enum", "values": ["read", "mutate-ephemeral", "structural", "persist", "privileged"], "summary": "Command risk tier. Drives consent, audit, and grant policy (Security domain). Every command declares one." } ], "riskTiers": [ @@ -69,7 +69,7 @@ ] }, { "name": "cancel", "risk": "read", "stability": "stable", "summary": "Request cancellation of an in-flight request by its JSON-RPC id. Best-effort; long reads and applies honor it at safe points.", - "parameters": [ { "name": "id", "type": "string", "summary": "The JSON-RPC id of the request to cancel." } ], + "parameters": [ { "name": "id", "type": "string", "summary": "The JSON-RPC id (a string — WDXP pins string ids protocol-wide, envelope §2) of the request to cancel." } ], "returns": [ { "name": "cancelled", "type": "boolean", "summary": "True if a matching in-flight request was found and signaled." } ] } ], @@ -142,7 +142,7 @@ "summary": "The guaranteed read floor: enumerate/search/subscribe the live XAML visual tree. Config-independent and proven (maps to TAP findname/children/getchild). This is 'XAML sight day one.'", "types": [ { "id": "NodeHandle", "kind": "object", "summary": "A live element reference. The generation guards against stale handles across tree mutations.", "properties": [ - { "name": "handle", "type": "integer", "summary": "Opaque element handle (uint64 rendered as JSON number/string per envelope)." }, + { "name": "handle", "type": "string", "summary": "Opaque element handle: a uint64 encoded as a decimal string so precision is never lost as a JSON number (envelope §2). Treat as opaque — never do arithmetic on it." }, { "name": "generation", "type": "integer", "summary": "Increments when the handle is invalidated; a mismatch yields StaleHandle." } ]}, { "id": "IncompletenessReason", "kind": "enum", "values": ["popup-detached", "flyout-detached", "virtualized-unrealized", "template-subtree", "collection-handle-quirk"], "summary": "Honest incompleteness (REQ-A1): why an enumeration cannot fully see a subtree. Reported, never silently dropped." }, @@ -442,9 +442,9 @@ ]} ], "commands": [ - { "name": "authenticate", "risk": "privileged", "stability": "experimental", "summary": "Establish session auth over the same-user pipe: per-session nonce/token, host identity, replay prevention.", - "parameters": [ { "name": "token", "type": "string", "optional": true, "summary": "Prior session token for reconnect; omit for a fresh handshake." } ], - "returns": [ { "name": "sessionId", "type": "string" }, { "name": "host", "type": "string" } ] + { "name": "authenticate", "risk": "privileged", "stability": "experimental", "summary": "Establish session auth over the same-user pipe and issue a per-session token bound to the resolved host identity. Nonce issuance and replay-prevention policy are host-defined and finalized in W8 (Security).", + "parameters": [ { "name": "token", "type": "string", "optional": true, "summary": "Prior session token (from a previous authenticate) for reconnect; omit for a fresh handshake." } ], + "returns": [ { "name": "sessionId", "type": "string" }, { "name": "host", "type": "string", "summary": "Resolved client identity." }, { "name": "token", "type": "string", "summary": "Opaque per-session token to present on a later reconnect. Issuance/nonce/replay policy is host-defined, finalized in W8." }, { "name": "expiresAt", "type": "string", "optional": true, "summary": "ISO-8601 token expiry; absent = session lifetime." } ] }, { "name": "grant", "risk": "privileged", "stability": "experimental", "summary": "Grant capability families to the session with explicit consent; higher risk tiers require explicit (not session-default) consent.", "parameters": [ { "name": "capabilities", "type": "string", "array": true } ],