endpoint-libs 2.1.2

WebSocket RPC server and endpoint schema model, with MCP tool exposure and JSON Schema / OpenAPI / AsyncAPI generation. Shared runtime for services generated by endpoint-gen.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
# endpoint-libs 2.1 / endpointgen 2.1 — OpenAPI + AsyncAPI emission (brief for Claude Code)

Repos: `~/code/endpoint-libs` (2.0.1) and `~/code/endpointgen` (1.10.1 — *not* in
lockstep; see the version-skew note in §1). Goal of 2.1: teach the RON pipeline to emit **OpenAPI 3.1** and
**AsyncAPI 3.0** documents as *additional artifacts* alongside the Rust/docs/MCP output
it already produces, so the project collects the standard-format dividends (third-party
client SDKs, hosted docs, spec-driven fuzzing, OpenAPI→MCP bridging) without giving up
the RON source of truth, the role/error model, or the deployed WS protocol.

**Read first:** `~/code/iaai-27/rpc-crate-survey.md` §A — the research this release
comes from, including why the alternative (adopt an OpenAPI-first framework) was
rejected and why the codegen arrow has to keep pointing RON → artifacts.

Ground rules:

- **This is a MINOR release. Nothing here may break a 2.0 consumer.** If you find
  yourself needing a breaking change to `Type`, `Field`, or `EndpointSchema`, stop and
  re-read §1 — the 2.0 Phase 2b groundwork exists precisely so you don't have to.
- The RON stays the single source of truth. These emitters are **outputs**. Never add
  an OpenAPI/AsyncAPI *input* path (that is the "adopt OpenAPI" plan that was
  rejected).
- No new required dependencies in endpoint-libs default features. The emitters live in
  endpointgen, which may take `serde_yaml` (or emit JSON only — see §4.4).
- Every phase lands green: `cargo all-features clippy --all-targets -- -D warnings` and
  `cargo all-features test` (the CI matrix — see §9), and the generated documents
  validate against a real spec validator (§7).

---

## 1. What 2.0 already gave you (do not redo this work)

These were the 2.0 groundwork this release depends on. All five were checked against the
tree on 2026-07-26 — the status column is fact, not aspiration, so don't re-verify the
✅ rows. One did not land:

| Invariant | Where | Status |
|---|---|---|
| Generated schemas are JSON-deserialized at runtime, not struct literals | `endpointgen/src/rust.rs:525` emits `serde_json::from_str(schema)` | ✅ (line 525, not 513) |
| New model fields carry `#[serde(default)]` | `model/endpoint.rs:26,30,34,41,109,111` ||
| `Type`, `Field`, `EnumVariant`, `EndpointSchema` are `#[non_exhaustive]` | `model/types.rs:62,115,154`, `model/endpoint.rs:11,105` ||
| `Field.meta` and `EndpointSchema.meta` round-trip unknown keys | `model/types.rs:76`, `model/endpoint.rs:47` (`MetaMap`) ||
| endpointgen has `--check` (regenerate → diff → non-zero on drift) | 2.0 lockstep item 3 |**Does not exist.** No `--check` in `endpointgen/src/main.rs`. Phase 4 must build it. |

**Version skew to fix before §9's consumer-corpus step.** The two repos are not in lockstep
and the six backends are pinned to an older generator than the one in the tree:

- `endpoint-libs` is 2.0.1, `endpointgen` is 1.10.1 (not 2.0.x as this plan assumed).
- The installed `endpoint-gen` binary is **1.9.0**, and all six consumers' `config/version.toml`
  declare `[binary] 1.9.0` / `[libs] 1.9.1` — self-consistent with that binary, so nothing is
  broken today.
- But their `Cargo.toml`/`Cargo.lock` build against **endpoint-libs 2.0.0**. So the declared
  `[libs]` version is already a lie, and the moment anyone installs endpointgen 1.10.1,
  `check_compatibility` compares its `^2.0` requirement against the declared `1.9.1` and
  **refuses to run in all six repos**.

Bumping `[binary]`/`[libs]` and regenerating six production backends is a coordinated
migration with real diffs in deployed services — it is not a prerequisite for writing the
emitters. Do Phases 1–3 against a fixture RON, and treat the consumer-corpus check as its
own scheduled piece of work.

**The single most important existing asset:** `Type::to_json_schema`
(`endpoint-libs/src/model/json_schema.rs:161`) already emits **JSON Schema 2020-12** —
`$defs`, `$ref`, `format: uuid`, `contentEncoding: base64`, `pattern` for blockchain
addresses/hashes, `minimum`/`maximum` for sized ints, `anyOf: [T, null]` for
`Optional`. **OpenAPI 3.1 is a superset of JSON Schema 2020-12**, so these schema
objects drop into an OpenAPI document essentially verbatim. AsyncAPI 3.0 also uses
JSON Schema for payloads. You are not writing a type-to-schema converter — you are
writing two document *envelopes* around an existing one.

The only structural mismatch is location: `to_json_schema` puts shared definitions in
`#/$defs/X`, while OpenAPI wants `#/components/schemas/X` and AsyncAPI wants
`#/components/schemas/X` too. That is a mechanical `$ref` rewrite (§2.2).

---

## 2. Phase 1 — shared document plumbing (endpoint-libs, additive)

New module `src/model/api_document.rs`, exported from `model`. This lives in
endpoint-libs (not endpointgen) because the MCP server already needs the same
registry-walking logic at startup, and both emitters plus any future OpenRPC emitter
should share one implementation.

### 2.1 Collect every shared definition once

```rust
/// Walks all endpoints in a service, emitting each referenced Struct/Enum exactly
/// once into a component map. Mirrors what `to_mcp_input_schema` does per-endpoint,
/// but hoisted to document scope so `$ref`s are shared across operations.
pub struct SchemaComponents {
    pub schemas: BTreeMap<String, serde_json::Value>,
}

impl SchemaComponents {
    pub fn collect(
        endpoints: &[EndpointSchema],
        registry: &TypeRegistry,
    ) -> Result<Self>;
}
```

Implementation note: reuse `Type::to_json_schema` with a shared `defs` map across all
endpoints instead of a fresh `BTreeMap` per endpoint (which is what
`to_mcp_input_schema`/`to_mcp_output_schema` do today). Do **not** change those two
methods — MCP tool schemas are self-contained by design and consumers depend on that.

### 2.2 `$ref` relocation

```rust
/// Rewrites `#/$defs/X` → `#/components/schemas/X` throughout a schema value.
/// Recursive over objects and arrays; only touches string values under a `$ref` key.
pub fn relocate_refs(value: &mut serde_json::Value, prefix: &str);
```

Unit-test this against a deeply nested case (Vec<Optional<StructRef>> inside a Struct
field) and a recursive struct — `to_json_schema` reserves a slot to terminate
recursion (`json_schema.rs:211`), so the rewrite must not loop.

### 2.3 The `meta` passthrough

Any key in `Field.meta` / `EndpointSchema.meta` that starts with `x-` is copied
verbatim onto the corresponding schema/operation object (both OpenAPI and AsyncAPI
allow arbitrary `x-` extensions). Recognised non-`x-` keys are mapped explicitly:
`example`, `examples`, `deprecated`, `tags`, plus the JSON Schema constraint keywords
(`minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `enum`). Unrecognised
non-`x-` keys are a **hard error** with the endpoint and field name — silently dropping
metadata is how these documents rot.

**Acceptance:** unit tests only; no emitter yet. `cargo test -p endpoint-libs`.

---

## 3. Phase 2 — OpenAPI 3.1 emitter (endpointgen)

New file `endpointgen/src/openapi.rs`, modelled on
`endpointgen/src/docs.rs::gen_mcp_tools_json` (`docs.rs:275`) — same registry
construction, same `docs/` output directory. It differs in one way: `gen_mcp_tools_json`
writes one file per service, while this walks every service to *accumulate* a single
merged document.

```rust
pub fn gen_openapi(data: &Data) -> eyre::Result<()>;
// writes ONE merged docs/openapi.json (and .yaml if the yaml feature is on)
// covering all services, grouped by per-service tags — matching the committed
// api.support.cafe docs/openapi.yaml. (Per-service splitting, if ever needed,
// is a --flag later; the merged document is the canonical artifact.)
```

Call it from `main.rs` next to the existing emitters (`main.rs:83-89`).

### 3.1 The modelling decision you must make first

A WS RPC method has no URL. OpenAPI needs paths. **Synthesize them per-service** — this
matches the golden fixture (§3.5), which is where these conventions come from:

```
POST /{serviceName}/{endpoint_snake_name}    e.g. POST /adminApi/delete_app
  operationId: {serviceName}_{endpoint_snake_name}   e.g. adminApi_delete_app
  tags: [{serviceName}]
  requestBody:  application/json  → object schema over `parameters`
  responses:
    200:     application/json → object schema over `returns`
    default: $ref to the shared error envelope → the endpoint's error catalog (§3.3)
```

Rationale: endpoint names are only conventionally unique across services — the RON
namespace is `(service_name, service_id)`, and the path convention must mirror it. A
flat `/rpc/{Name}` scheme collides the moment documents are merged or two services
reuse a name, and the `/rpc/` segment carries no information.

Document prominently in the generated file's `info.description` **and** in
`docs/openapi-README.md` that this is a *projection for tooling purposes*: the real
transport is a persistent WebSocket carrying `{method, seq, params}` frames, and the
authoritative description of that is the AsyncAPI document (§4). Generating an HTTP
client from this and pointing it at the server will not work. This warning is not
optional — an undocumented synthetic path map is worse than no document, because it
looks usable.

Fields that carry over directly:

| RON / `EndpointSchema` | OpenAPI |
|---|---|
| `name` | path segment (snake_case) + `operationId` (`{service}_{snake_name}`) |
| `code` | `x-endpoint-code` extension |
| `description` | `summary` (first line) + `description` (full) |
| `parameters` | `requestBody` object schema, non-`Optional``required` |
| `returns` | `200` response schema |
| `stream_response` | `x-stream-response` extension + a note in `description` |
| `roles` | `security` + `x-roles` (§3.2) |
| `errors` | error responses (§3.3) |
| `frontend_facing` (on the element, not the schema) | `x-frontend-facing`; also drives `--public-only` filtering |

### 3.2 Roles → security

Emit one `securitySchemes` entry describing the WS subprotocol auth token:

```json
"securitySchemes": {
  "sessionToken": { "type": "apiKey", "in": "header", "name": "Sec-WebSocket-Protocol",
                    "description": "Auth token passed as WS subprotocol; see AuthController." }
}
```

Each operation gets `"security": [{"sessionToken": []}]` plus
`"x-roles": ["Admin", "User"]` from `schema.roles` — `x-roles`, not `x-required-roles`,
matching the vendor-extension names in the golden fixture (§3.5). OpenAPI has no native
role concept — do not attempt to encode roles as scopes, it misleads generators into
emitting OAuth2 flows that do not exist.

### 3.3 Error catalog → responses

`EndpointSchema.errors` (`Vec<EndpointErrorSchema>`) plus the global error-code catalog
(`endpointgen/src/error_codes.rs`) become response entries. One response object per
distinct HTTP-ish class is enough — the wire protocol has no status codes, so:

- `default` response → the standard error envelope schema (code, message, params),
  with `x-error-codes` listing the codes this endpoint may return, each with its
  description from the catalog.

Do not invent per-code HTTP statuses. The envelope is the contract.

### 3.4 Filtering

`--public-only` (or a config key) emits only `frontend_facing` endpoints, for the
document you would hand to a third party. Default emits everything.

**Acceptance:** the emitted document validates (§7); an endpoint with a recursive
struct, an enum ref, an optional vec, and two error codes round-trips into readable
schemas; `--public-only` drops exactly the non-frontend-facing operations.

### 3.5 The golden fixture

`endpointgen/tests/fixtures/api_support_cafe_openapi.golden.yaml` — 1335 lines, 26
operations across 7 services. This is the hand-written projection that established the
§3.1 conventions, moved out of `api.support.cafe/docs/openapi.yaml` (commit `3b50c57`)
and into this repo, because a checked-in spec living next to generator output in a
service repo is a competing source of truth. See the README beside it for full
provenance.

Two things to know before you diff against it:

- **It is a shape reference, not a byte-exact expectation.** It was written by hand from
  an older RON state, so operation-for-operation equality is not the goal. Assert the
  path scheme, `operationId` scheme, tagging, and vendor extensions; do not assert
  whole-document equality until the emitter has been trusted once and the fixture
  regenerated from it.
- **Vendor-extension names: fixture and plan now agree.** §3.1/§3.2 were revised
  (2026-07-25 review) to adopt the fixture's conventions — per-service paths
  (`/{serviceName}/{endpoint_snake_name}`), `operationId` = `{service}_{snake_name}`,
  and `x-roles` (not `x-required-roles`). Rationale in §3.1: the RON namespace is
  `(service_name, service_id)`, and a flat `/rpc/{Name}` scheme collides across
  services while the `/rpc/` segment carries no information.
- **It is not an oracle, and nothing is deployed from it.** An earlier revision gave
  the fixture the tiebreak "it is the deployed artifact" — that was written while the
  file still lived in `api.support.cafe/docs/openapi.yaml`. It doesn't any more, and it
  never described a running surface anyway: the server speaks WebSocket, and these paths
  are synthetic (§3.1). It is a hand-written file that predates the emitter, and no test
  consumes it yet.

  So the tiebreak is the ground rule, not the file: **the RON is the source of truth.**
  Where fixture and emitter disagree, work out which is right from the RON. The fixture's
  standing is that its *conventions* were reviewed and adopted into §3.1/§3.2 — those are
  now the plan's, and the plan is what an implementer follows. Its *contents* have no
  authority; it was generated from an older RON state by hand.

  Phase 2 should end with this file regenerated from the emitter and a test asserting the
  §3.1 conventions against it. Only then is it a golden in the real sense.

---

## 4. Phase 3 — AsyncAPI 3.0 emitter (endpointgen)

New file `endpointgen/src/asyncapi.rs`. **This is the document that actually describes
your protocol** — the OpenAPI one is a tooling projection, this one is the truth.

```rust
pub fn gen_asyncapi(data: &Data) -> eyre::Result<()>;
// writes ONE merged docs/asyncapi.json covering all services, one channel each
```

**One document, matching §3's merged OpenAPI.** This was per-service (`docs/{service}_asyncapi.json`)
in the original draft, written before §3 moved to a single merged file. Leaving it
per-service would make §4.2's acceptance test impossible to satisfy: a merged OpenAPI
document's `components.schemas` holds every service's types, while a per-service AsyncAPI
document holds one service's, so `assert_eq!` between them could never pass. Two documents
also describe the protocol more honestly as one document, since the services share a
transport and are distinguished by the `method` code inside the envelope, not by endpoint.

### 4.1 Channel and operation model

AsyncAPI 3.0 separates channels (where messages flow), operations (send/receive), and
messages (payload shapes). Map as:

- **One channel per service**, each with `address: "/"` and a `ws` binding recording
  the subprotocol used for auth. Name them after the service so the channel set mirrors
  the OpenAPI tag set.
- **Two operations**: `sendRequest` (client → server, `action: send`) and
  `receiveResponse` (server → client, `action: receive`).
- **Messages**: `Request`, `Response`, `Error`, and — because they share the socket —
  `McpJsonRpc`. The `Request` payload is the envelope:

```json
{ "type": "object",
  "properties": {
    "method": { "type": "integer", "description": "endpoint code" },
    "seq":    { "type": "integer" },
    "params": { "oneOf": [ /* $ref per endpoint parameter schema */ ] } },
  "required": ["method", "seq", "params"] }
```

Use `oneOf` over the per-endpoint parameter schemas with a `discriminator` on `method`
if the generator you test with supports it; otherwise emit the `oneOf` plus an
`x-method-map` extension mapping code → schema name. Note which you did in the file
header.

### 4.2 Per-endpoint detail

Each endpoint contributes a `components.messages.{Name}Request` /
`{Name}Response` pair with the same descriptions, roles extensions, and error lists as
the OpenAPI operations. Reuse `SchemaComponents` from §2.1 — both documents must
reference **identical** schema objects, and a test should assert that
(`assert_eq!(openapi.components.schemas, asyncapi.components.schemas)`).

### 4.3 The framed_json binding

2.0 defines a length-delimited frame format for non-WS transports
(`u32 BE length | u8 kind | payload`, `PLAN-2.0.md` §4). Record it in the AsyncAPI
document as a second channel entry with a custom `x-framing` extension describing the
byte layout, so a non-Rust peer implementing the local transport has one authoritative
reference. This is the only place that format is machine-readable.

### 4.4 YAML

JSON is mandatory; YAML is nice-to-have for humans. If you add it, put `serde_yaml`
behind an endpointgen feature — do not make it a default dependency for a cosmetic
output.

**Acceptance:** validates against an AsyncAPI 3.0 validator (§7); the shared-components
equality test passes; a hand-written peer can reconstruct the frame layout from
`x-framing` alone.

---

## 5. Phase 4 — wire it into the build and CI

- `main.rs`: call `openapi::gen_openapi` and `asyncapi::gen_asyncapi` after
  `docs::gen_mcp_tools_json` (`main.rs:87`).
- Both documents are **committed artifacts**, like the existing generated Rust/docs.
- **Build `--check` from scratch** — regenerate → diff → non-zero exit — and cover the
  new documents with it. §1 lists it as a 2.0 deliverable that was never implemented, so
  this is net-new work, not an extension. This is the dropshot `dropshot-api-manager`
  discipline; the whole value of a committed spec is that CI proves it matches the RON.
  Note that none of the six consumer repos' CI regenerates and diffs today, so `--check`
  only pays off once it is wired into *their* workflows, not just endpointgen's.
- `docs/openapi-README.md`: what each document is, the synthetic-path warning (§3.1),
  and the three consumption recipes in §6.

---

## 6. What this unlocks (validate at least the first one)

1. **OpenAPI → MCP bridging**: point `rmcp-openapi`
   (`gitlab.com/lx-industries/rmcp-openapi`) at the emitted document and confirm the
   tool list matches endpoint-libs' own `tools/list` output. Since §3 emits one merged
   document, compare against the union of every service's `tools/list`, or filter the
   document by tag to check a service at a time.
   **This is the highest-value check in the release** — a mismatch means the hand-rolled
   MCP metadata and the emitted spec disagree, and one of them is lying to an agent.

   Useful sanity anchor: for api.support.cafe those tool lists total **26** operations
   across 7 services, which is exactly the operation count in the §3.5 fixture.
2. **Third-party client SDKs**: `openapi-generator` (any of 50+ languages) against the
   `--public-only` document. Expect the synthetic paths to be wrong for real use —
   that is exactly why §3.1's warning exists; validate that it *generates*, not that
   it *connects*.
3. **Spec-driven fuzzing**: Schemathesis against the document is the interesting one
   long-term, but it needs an HTTP surface the server does not have. Note it as future
   work behind a REST adapter; do not build the adapter here.

---

## 7. Validation tooling

- OpenAPI 3.1: `redocly lint` or the `oas3` Rust crate for a parse check in a test.
  Prefer a real linter in CI over a parse check.
- AsyncAPI 3.0: the official `@asyncapi/cli validate`.
- Both: a test that every `$ref` in the document resolves against
  `components.schemas` (catches the §2.2 rewrite regressing) and that no `$defs` key
  survives anywhere.

---

## 8. Explicit non-goals for 2.1

- **No OpenAPI/AsyncAPI as input.** No spec → Rust codegen, ever, in this direction.
  The RON is the source of truth.
- **No runtime behaviour change.** endpoint-libs serves the same frames; the emitters are
  build-time artifacts, and the only addition is `src/model/api_document.rs`.

  The one `src/libs/` change in 2.1 is a *deletion*: `src/libs/ws/wtx/` (see §8's
  breaking-change carve-out). It changes no runtime behaviour either, because that code
  could not be compiled into a build in the first place.
- **No REST/HTTP adapter.** The synthetic paths are for tooling, not for serving. If a
  real REST surface is ever wanted, that is its own release with its own plan.
- **No OpenRPC emitter yet.** It is arguably the best-fitting standard (MCP is
  JSON-RPC, and so is half your protocol) but Rust/ecosystem tooling for it is thin —
  see `rpc-crate-survey.md` §A.5. Revisit if OpenRPC tooling matures; the
  `SchemaComponents` plumbing in §2 is deliberately emitter-agnostic so adding it later
  is one more file.
- **No breaking changes to anything a consumer can compile against.** If one seems
  necessary, it belongs in 3.0 and needs its own plan — not a quiet bump here.

  One carve-out, already taken: 2.1 removes the `ws-wtx` / `ws-wtx-http2` features and
  the wtx backend. That is marked breaking and is not one, in the sense the rule cares
  about — enabling `ws-wtx` on 2.0.x was a hard compile error (verified:
  `cargo check --no-default-features --features ws-wtx` fails), so no working consumer
  existed to break. See the CHANGELOG. The rule protects consumers who can build; it is
  not a reason to carry code that never built. Anything that *does* compile for a
  consumer today still falls under the rule.

## 9. Order of work & verification loop

1. Phase 1 (`api_document.rs` + `relocate_refs` + meta passthrough) → unit tests.
2. Phase 2 (OpenAPI emitter) → validator + recursive/enum/optional round-trip test.
3. Phase 3 (AsyncAPI emitter) → validator + shared-components equality test.
4. Phase 4 (build wiring + `--check` extension + docs).
5. Consumption check §6.1 (`rmcp-openapi` tool-list parity) — treat a mismatch as a
   release blocker, not a curiosity.

After each phase, in both repos:

```bash
cargo all-features clippy --all-targets -- -D warnings
cargo all-features test
cargo fmt --all -- --check
```

`cargo all-features` (the `cargo-all-features` subcommand, installed in CI) walks the
feature matrix honouring the `denylist` in `[package.metadata.cargo-all-features]`. It is
what `.github/workflows/rust.yml:73` runs, so it is the invocation that decides whether CI
is green — prefer it.

**Plain `cargo clippy --all-features` also works again as of this release.** It used to be
permanently red: enabling every feature pulled in the deprecated `ws-wtx` / `ws-wtx-http2`
backends, which were mutually exclusive with `ws` and fired `compile_error!`s by design,
*plus* genuine rot (`src/libs/ws/wtx/upgrader.rs` still implemented the pre-2.0
`WsUpgrader::upgrade` instead of `upgrade_stream`, giving `E0046`/`E0407`). 2.1 removes
that backend outright — see the CHANGELOG — so `--all-features` now compiles clean with
no errors and no warnings. Both invocations are valid; they simply cover different things
(`--all-features` is one union build, `all-features` is the combinatorial matrix).

Also: `endpointgen --check` does not exist yet (§1 lists it as a 2.0 deliverable; it was not
built). Phase 4 must implement it, not extend it. Until it exists, verify emitters against a
fixture RON rather than a consumer repo — see the version-skew note in §1.