@ggui-ai/protocol-conformance 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +54 -16
  2. package/dist/fixtures/consume-buffer/index.d.ts.map +1 -1
  3. package/dist/fixtures/consume-buffer/index.js +4 -1
  4. package/dist/fixtures/index.d.ts +42 -4
  5. package/dist/fixtures/index.d.ts.map +1 -1
  6. package/dist/fixtures/index.js +42 -4
  7. package/dist/fixtures/reserved-channel-authority/index.d.ts.map +1 -1
  8. package/dist/fixtures/reserved-channel-authority/index.js +4 -2
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +7 -0
  12. package/dist/match-behavior.d.ts.map +1 -1
  13. package/dist/match-behavior.js +4 -2
  14. package/dist/resource-read-conformance/cases/all-ephemeral-substrate-fuses-like-none.json +29 -0
  15. package/dist/resource-read-conformance/cases/evicted-row-remints-from-the-durable-record.json +26 -0
  16. package/dist/resource-read-conformance/cases/half-wired-blueprints-only-substrate-fuses-like-none.json +29 -0
  17. package/dist/resource-read-conformance/cases/half-wired-identity-only-substrate-fuses-like-none.json +29 -0
  18. package/dist/resource-read-conformance/cases/live-row-read-returns-mount-material.json +18 -0
  19. package/dist/resource-read-conformance/cases/malformed-locator-stays-outside-the-typed-set.json +19 -0
  20. package/dist/resource-read-conformance/cases/read-miss-answers-not-found.json +23 -0
  21. package/dist/resource-read-conformance/cases/read-on-a-substrate-less-server-answers-not-supported.json +22 -0
  22. package/dist/resource-read-conformance/cases/read-record-with-purged-blueprint-answers-blueprint-unresolvable.json +29 -0
  23. package/dist/resource-read-conformance/cases/read-refusal-is-indistinguishable-from-a-miss.json +47 -0
  24. package/dist/resource-read-conformance/cases/read-without-a-delivery-channel-answers-not-mountable.json +22 -0
  25. package/dist/resource-read-conformance/cases/registry-match-fuses-on-not-mountable.json +40 -0
  26. package/dist/resource-read-conformance/cases/substrate-less-fusion-of-refusal-and-miss.json +29 -0
  27. package/dist/resource-read-conformance/index.d.ts +371 -0
  28. package/dist/resource-read-conformance/index.d.ts.map +1 -0
  29. package/dist/resource-read-conformance/index.js +782 -0
  30. package/package.json +8 -3
@@ -0,0 +1,782 @@
1
+ /**
2
+ * Resource-read conformance catalog — the kit's first MCP-binding
3
+ * driver.
4
+ *
5
+ * ## The obligation this catalog grades
6
+ *
7
+ * A read of a render locator (`ui://ggui/render/{sessionId}` or
8
+ * `ui://ggui/render/{sessionId}/{blueprintKey}`) has exactly TWO exits:
9
+ * a result whose contents declare a delivery channel — a live mount —
10
+ * or one typed JSON-RPC error. There is no third outcome. In
11
+ * particular there is no successful result carrying a shell that can
12
+ * never paint anything, which is what a loading placeholder returned on
13
+ * a failure branch amounts to.
14
+ *
15
+ * That invariant is what makes the read host-checkable: a host that
16
+ * gets `contents` back can mount them without inspecting anything, and
17
+ * a host that gets an error can route on `error.data.code` — "come back
18
+ * never" versus "come back with a fresh render" — without parsing prose.
19
+ *
20
+ * ## Why this is not a fixture in `../fixtures`
21
+ *
22
+ * `../fixtures` is the Path-A catalog: WebSocket-observable behaviors,
23
+ * driven by `runConformance()` over a live channel with a runner-owned
24
+ * subscribe frame and an observation window. A `resources/read` has no
25
+ * WebSocket envelope, no subscribe and no frames to observe — it is a
26
+ * request/response on the MCP binding. Registering these cases in the
27
+ * Path-A catalog would make every WebSocket run carry a permanent skip
28
+ * that could never become a pass on that transport, which is the false
29
+ * gate the kit's skip-set pinning exists to prevent.
30
+ *
31
+ * So this catalog gets the treatment the gadget obligations already
32
+ * get (`../schema-conformance`, `../registration-conformance`,
33
+ * `../resolution-conformance`): its own case shape, its own runner, and
34
+ * its cases shipped as raw JSON under `./cases/` so a non-TypeScript
35
+ * implementer can grade their own server against the same catalog. The
36
+ * difference is that those three grade a pure function while this one
37
+ * drives a live server — hence the scenario driver below, which is the
38
+ * seam an adopter implements.
39
+ *
40
+ * ## What an adopter implements
41
+ *
42
+ * {@link runResourceReadConformance} takes a
43
+ * {@link ResourceReadScenarioDriver}: given a declared server shape,
44
+ * caller identity and seed set, bring a server up that way and return
45
+ * something that can read a URI. The kit never imports a concrete
46
+ * server — adopters drive their OWN. The kit owns the locator grammar,
47
+ * the wire numbers, and every assertion; the driver owns nothing but
48
+ * "make my server look like this".
49
+ *
50
+ * Two rules make the grade honest:
51
+ *
52
+ * - A driver that CANNOT express a scenario throws from the driver
53
+ * function. The runner records a SKIP with that reason — never a
54
+ * pass. A server that cannot be brought up half-wired cannot be
55
+ * graded on what a half-wired server answers.
56
+ * - A driver's {@link PreparedResourceReadScenario.read} MUST NOT
57
+ * throw for a protocol-level failure — it resolves with
58
+ * `{kind: 'error'}` carrying the RAW JSON-RPC error frame the
59
+ * server put on the wire. A throw from `read` is a FAIL, because
60
+ * the read is the thing under test. The raw frame matters: an MCP
61
+ * client's reconstructed error object rewrites `message`, and the
62
+ * indistinguishability obligation below is graded on bytes.
63
+ *
64
+ * ## What this catalog deliberately does NOT grade
65
+ *
66
+ * - **The order in which a substrate-less server answers.**
67
+ * `NOT_SUPPORTED` describes the deployment, not the locator, so a
68
+ * server that keeps no durable record MAY answer it immediately —
69
+ * before any per-locator work, including the access check. Grading
70
+ * "the access check comes first" against such a server would fail a
71
+ * correct implementation. What IS graded is that its answer does
72
+ * not vary with the row (see `indistinguishable`), which is the
73
+ * property the ordering rule exists to produce.
74
+ * - **`detail` wording, on any code.** `detail` is optional operator
75
+ * context; two conformant servers word it differently. What is
76
+ * graded is its ABSENCE on `NOT_FOUND`, which is normative.
77
+ * - **The `NOT_FOUND` message literal.** What is normative is that
78
+ * the message is CONSTANT — identical for a refused read and a
79
+ * read of a locator that never existed. A server whose constant
80
+ * message reads differently is conformant; one whose message varies
81
+ * with the locator is not, and the fusion cases catch exactly that.
82
+ * - **Internal-error message text.** A `-32603` carries whatever the
83
+ * operator's own store or runtime produced. No case asserts its
84
+ * content, and none should — it is not protocol surface.
85
+ * - **The number a URI naming no locator receives — including
86
+ * negatively.** MCP assigns the resource-missing number to a
87
+ * `resources/read` of a URI the server does not serve, so a
88
+ * framework that maps every unrecognized URI onto it is behaving
89
+ * correctly; a catalog that banned that number would fail it
90
+ * forever. What IS graded is the classification: such a URI MUST
91
+ * NOT carry one of the four resource-read codes on
92
+ * `error.data.code`, because the four classify reads that name a
93
+ * locator and this names none. The two stay distinguishable on
94
+ * that alone.
95
+ * - **The shell's markup.** Success is graded on the projected render
96
+ * meta — the delivery channel it declares — never on DOM or HTML
97
+ * shape, which is a server's own presentation concern.
98
+ */
99
+ import { isRecord } from '@ggui-ai/protocol';
100
+ import notFoundOnAMiss from './cases/read-miss-answers-not-found.json' with { type: 'json' };
101
+ import refusalIsAMiss from './cases/read-refusal-is-indistinguishable-from-a-miss.json' with { type: 'json' };
102
+ import purgedBlueprint from './cases/read-record-with-purged-blueprint-answers-blueprint-unresolvable.json' with { type: 'json' };
103
+ import substrateLess from './cases/read-on-a-substrate-less-server-answers-not-supported.json' with { type: 'json' };
104
+ import noDeliveryChannel from './cases/read-without-a-delivery-channel-answers-not-mountable.json' with { type: 'json' };
105
+ import substrateLessFusion from './cases/substrate-less-fusion-of-refusal-and-miss.json' with { type: 'json' };
106
+ import halfWiredIdentityOnly from './cases/half-wired-identity-only-substrate-fuses-like-none.json' with { type: 'json' };
107
+ import halfWiredBlueprintsOnly from './cases/half-wired-blueprints-only-substrate-fuses-like-none.json' with { type: 'json' };
108
+ import allEphemeral from './cases/all-ephemeral-substrate-fuses-like-none.json' with { type: 'json' };
109
+ import registryFusion from './cases/registry-match-fuses-on-not-mountable.json' with { type: 'json' };
110
+ import malformedLocator from './cases/malformed-locator-stays-outside-the-typed-set.json' with { type: 'json' };
111
+ import liveRowMount from './cases/live-row-read-returns-mount-material.json' with { type: 'json' };
112
+ import remintMount from './cases/evicted-row-remints-from-the-durable-record.json' with { type: 'json' };
113
+ // =============================================================================
114
+ // Catalog
115
+ // =============================================================================
116
+ /**
117
+ * Every resource-read case the kit ships, in deterministic order: the
118
+ * four typed classes, then the disclosure fusions, then the boundary of
119
+ * the classification, then the two mount cases.
120
+ */
121
+ export const resourceReadCases = [
122
+ notFoundOnAMiss,
123
+ purgedBlueprint,
124
+ substrateLess,
125
+ noDeliveryChannel,
126
+ refusalIsAMiss,
127
+ substrateLessFusion,
128
+ halfWiredIdentityOnly,
129
+ halfWiredBlueprintsOnly,
130
+ allEphemeral,
131
+ registryFusion,
132
+ malformedLocator,
133
+ liveRowMount,
134
+ remintMount,
135
+ ];
136
+ // =============================================================================
137
+ // Locator grammar + the mount predicate
138
+ // =============================================================================
139
+ /** The resource URI every render locator hangs off. */
140
+ export const GGUI_RENDER_RESOURCE_URI = 'ui://ggui/render';
141
+ /**
142
+ * Build the URI a probe reads. The kit owns the grammar so no case
143
+ * spells a well-formed locator out by hand; a `raw` locator passes
144
+ * through verbatim, which is the point of that arm.
145
+ */
146
+ export function renderLocatorUri(locator, registeredKeys = {}) {
147
+ if (locator.kind === 'raw')
148
+ return locator.uri;
149
+ const base = `${GGUI_RENDER_RESOURCE_URI}/${locator.session}`;
150
+ const keyRef = locator.key;
151
+ if (keyRef === undefined)
152
+ return base;
153
+ if (keyRef.kind === 'literal')
154
+ return `${base}/${keyRef.value}`;
155
+ const key = registeredKeys[keyRef.seed];
156
+ if (key === undefined) {
157
+ throw new Error(`no key was reported for registry seed '${keyRef.seed}'`);
158
+ }
159
+ return `${base}/${key}`;
160
+ }
161
+ /**
162
+ * Does this render meta carry enough to paint something? One of the
163
+ * three delivery channels a runtime knows: a static component URL, a
164
+ * live channel, or a server-emitted system card.
165
+ *
166
+ * The live channel needs BOTH halves — an endpoint with no token cannot
167
+ * be opened, and a token with no endpoint has nowhere to go — so a
168
+ * half-declared one is not a channel.
169
+ */
170
+ export function declaresDeliveryChannel(meta) {
171
+ if (typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0)
172
+ return true;
173
+ if (typeof meta.kind === 'string' && meta.kind.length > 0)
174
+ return true;
175
+ return (typeof meta.wsUrl === 'string' &&
176
+ meta.wsUrl.length > 0 &&
177
+ typeof meta.wsToken === 'string' &&
178
+ meta.wsToken.length > 0);
179
+ }
180
+ // =============================================================================
181
+ // Runner
182
+ // =============================================================================
183
+ /**
184
+ * Grade a server against the catalog.
185
+ *
186
+ * One scenario is prepared per case, every probe is read against it in
187
+ * order, then the cross-probe obligations are checked. A case
188
+ * contributes at most one failure — the first obligation it broke —
189
+ * so the result reads as one verdict per case.
190
+ *
191
+ * A conformant server produces an empty `failed` array. An empty
192
+ * `passed` array is NOT success: a run where every case skipped proves
193
+ * nothing, and callers should treat it the way the WebSocket runner's
194
+ * consumers already do.
195
+ */
196
+ export async function runResourceReadConformance(drive, options = {}) {
197
+ const passed = [];
198
+ const failed = [];
199
+ const skipped = [];
200
+ for (const raw of resourceReadCases) {
201
+ const testCase = parseCase(raw);
202
+ if (options.only !== undefined && !options.only.includes(testCase.name))
203
+ continue;
204
+ let prepared;
205
+ try {
206
+ prepared = await drive({
207
+ caseName: testCase.name,
208
+ server: testCase.server,
209
+ caller: testCase.caller,
210
+ seeds: testCase.seeds,
211
+ });
212
+ }
213
+ catch (err) {
214
+ skipped.push({
215
+ name: testCase.name,
216
+ reason: `driver refused to prepare the scenario: ${errorText(err)}`,
217
+ });
218
+ continue;
219
+ }
220
+ let verdict;
221
+ try {
222
+ verdict = await gradeCase(testCase, prepared);
223
+ }
224
+ finally {
225
+ // A teardown that throws costs THIS case its cleanup and nothing
226
+ // else. Unguarded, one flaky dispose propagates out of the loop
227
+ // and takes the whole scorecard with it — every case after it
228
+ // goes ungraded, and the run reports nothing rather than the
229
+ // verdicts it had already earned. Cleanup is not a verdict, so it
230
+ // cannot produce one either: the throw is dropped here, not
231
+ // recorded as a failure of the server.
232
+ if (prepared.dispose !== undefined) {
233
+ await prepared.dispose().catch(() => undefined);
234
+ }
235
+ }
236
+ if (verdict.kind === 'pass')
237
+ passed.push(testCase.name);
238
+ else if (verdict.kind === 'fail')
239
+ failed.push(verdict.failure);
240
+ else
241
+ skipped.push({ name: testCase.name, reason: verdict.reason });
242
+ }
243
+ return { passed, failed, skipped };
244
+ }
245
+ async function gradeCase(testCase, prepared) {
246
+ const registeredKeys = prepared.registeredKeys ?? {};
247
+ const outcomes = new Map();
248
+ for (const probe of testCase.reads) {
249
+ let uri;
250
+ try {
251
+ uri = renderLocatorUri(probe.locator, registeredKeys);
252
+ }
253
+ catch (err) {
254
+ return {
255
+ kind: 'skip',
256
+ reason: `probe '${probe.as}' reads a locator the driver did not resolve — ${errorText(err)}`,
257
+ };
258
+ }
259
+ let outcome;
260
+ try {
261
+ outcome = await prepared.read(uri);
262
+ }
263
+ catch (err) {
264
+ return {
265
+ kind: 'fail',
266
+ failure: {
267
+ name: testCase.name,
268
+ probe: probe.as,
269
+ obligation: 'a read resolves with an outcome; a protocol failure is a typed error frame, not a throw',
270
+ expected: probe.expect,
271
+ actual: `read('${uri}') threw: ${errorText(err)}`,
272
+ message: `reading '${uri}' threw instead of resolving. A read that cannot succeed still answers — with one typed JSON-RPC error.`,
273
+ },
274
+ };
275
+ }
276
+ outcomes.set(probe.as, outcome);
277
+ const probeFailure = gradeProbe(testCase, probe, uri, outcome);
278
+ if (probeFailure !== null)
279
+ return { kind: 'fail', failure: probeFailure };
280
+ }
281
+ const disclosure = gradeDisclosure(testCase, outcomes);
282
+ if (disclosure !== null)
283
+ return { kind: 'fail', failure: disclosure };
284
+ const fusion = gradeIndistinguishable(testCase, outcomes);
285
+ if (fusion !== null)
286
+ return { kind: 'fail', failure: fusion };
287
+ return { kind: 'pass' };
288
+ }
289
+ function gradeProbe(testCase, probe, uri, outcome) {
290
+ const fail = (obligation, expected, message) => ({
291
+ name: testCase.name,
292
+ probe: probe.as,
293
+ obligation,
294
+ expected,
295
+ actual: outcome,
296
+ message,
297
+ });
298
+ if (probe.expect.kind === 'live-mount') {
299
+ if (outcome.kind !== 'mount') {
300
+ return fail('a resolvable locator returns a result, not an error', 'a result whose contents declare a delivery channel', `reading '${uri}' failed, but this locator resolves on this server — the read had mount material to hand back.`);
301
+ }
302
+ if (!declaresDeliveryChannel(outcome.renderMeta)) {
303
+ return fail('any successful result IS a live mount', 'render meta declaring a component URL, a live channel (endpoint AND token), or a system card', `reading '${uri}' succeeded with contents that declare no delivery channel — a shell that can never paint anything. A read with nothing to mount answers with a typed error instead.`);
304
+ }
305
+ return null;
306
+ }
307
+ if (outcome.kind !== 'error') {
308
+ return fail('a read that cannot be mounted answers with a typed error, never a success-shaped result', probe.expect, `reading '${uri}' returned a result, but this locator cannot be mounted on this server.`);
309
+ }
310
+ const dataCode = readDataCode(outcome.error.data);
311
+ if (probe.expect.kind === 'outside-typed-set') {
312
+ // The NUMBER is deliberately not graded here, and an earlier draft
313
+ // of this arm got it wrong by banning the two canonical ones. MCP
314
+ // itself assigns the resource-missing number to a `resources/read`
315
+ // of a URI the server does not serve, so a framework that maps every
316
+ // unrecognized URI onto it is behaving correctly and would fail such
317
+ // a check forever. What survives is the classification: the four
318
+ // codes classify reads that NAME a locator, and this URI names none.
319
+ //
320
+ // That still leaves the two distinguishable, which is the point —
321
+ // a genuine miss carries the resource-read classification and the
322
+ // constant message; a malformed URI carries no classification at
323
+ // all, whatever number it rides on.
324
+ if (dataCode !== undefined) {
325
+ return fail('the four resource-read codes classify reads of a locator; a URI that names none is not one', 'an error carrying no resource-read classification on error.data.code', `reading '${uri}' — which names no render locator — was classified as '${dataCode}'. That classification is for reads of a locator; this is a malformed request, and giving it a locator verdict tells a host to stop retrying a request it should fix instead.`);
326
+ }
327
+ return null;
328
+ }
329
+ if (outcome.error.code !== probe.expect.jsonRpcCode) {
330
+ return fail('each classification rides on its canonical JSON-RPC number', probe.expect.jsonRpcCode, `reading '${uri}' answered on ${outcome.error.code}; this class rides on ${probe.expect.jsonRpcCode}. A host routes on the number before it reads anything else — a deterministic outcome reported as a malfunction invites a retry that cannot succeed.`);
331
+ }
332
+ if (dataCode !== probe.expect.dataCode) {
333
+ return fail('the fine-grained class rides on error.data.code', probe.expect.dataCode, `reading '${uri}' carried data.code ${dataCode === undefined ? '(absent)' : `'${dataCode}'`}; the catalog expects '${probe.expect.dataCode}'.`);
334
+ }
335
+ if (typeof outcome.error.message !== 'string') {
336
+ // Presence-and-type only, and NOT a ggui obligation: JSON-RPC 2.0
337
+ // requires an error object to carry a string `message`. An earlier
338
+ // draft required it to be non-empty, which is a rule no ruling
339
+ // establishes — a server whose constant message is the empty string
340
+ // is conformant, however unhelpful. Kept because the fusion
341
+ // comparison serializes this field, so a frame missing it entirely
342
+ // is malformed at the transport level before this catalog has an
343
+ // opinion.
344
+ return fail("JSON-RPC requires an error object to carry a string 'message'", 'a string message field', `reading '${uri}' answered with an error carrying no string message.`);
345
+ }
346
+ if (probe.expect.detailAbsent === true && dataHasDetail(outcome.error.data)) {
347
+ return fail('a NOT_FOUND body is constant — it carries no detail', 'error.data with no `detail` key', `reading '${uri}' attached a 'detail' to a NOT_FOUND. Whatever varies between a refused read and a read of a locator that never existed is an existence oracle for other callers' renders, and a diagnostic is exactly what varies.`);
348
+ }
349
+ return null;
350
+ }
351
+ function gradeDisclosure(testCase, outcomes) {
352
+ const secrets = testCase.disclosesNothing ?? [];
353
+ if (secrets.length === 0)
354
+ return null;
355
+ for (const [probeName, outcome] of outcomes) {
356
+ if (outcome.kind !== 'error')
357
+ continue;
358
+ const serialized = JSON.stringify(outcome.error);
359
+ for (const secret of secrets) {
360
+ if (serialized.includes(secret)) {
361
+ return {
362
+ name: testCase.name,
363
+ probe: probeName,
364
+ obligation: 'an error frame names nothing about the render it refused',
365
+ expected: `no occurrence of ${JSON.stringify(secret)}`,
366
+ actual: serialized,
367
+ message: `the error frame for probe '${probeName}' contains ${JSON.stringify(secret)}. A caller with no claim to this render learned it exists.`,
368
+ };
369
+ }
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+ function gradeIndistinguishable(testCase, outcomes) {
375
+ const group = testCase.indistinguishable ?? [];
376
+ if (group.length < 2)
377
+ return null;
378
+ // Session segments are caller-supplied and differ BY CONSTRUCTION
379
+ // between the probes being compared, so they are normalized out
380
+ // before the byte comparison. A server that echoes one back is not
381
+ // excused by this — `disclosesNothing` catches that, and it runs
382
+ // first.
383
+ const sessions = testCase.reads
384
+ .flatMap((probe) => (probe.locator.kind === 'render' ? [probe.locator.session] : []))
385
+ .sort((a, b) => b.length - a.length);
386
+ let reference = null;
387
+ for (const probeName of group) {
388
+ const outcome = outcomes.get(probeName);
389
+ if (outcome === undefined || outcome.kind !== 'error') {
390
+ return {
391
+ name: testCase.name,
392
+ probe: probeName,
393
+ obligation: 'a refused read and a read of a locator that never existed are the same bytes',
394
+ expected: 'an error frame to compare',
395
+ actual: outcome ?? '(no outcome recorded)',
396
+ message: `probe '${probeName}' is named in this case's indistinguishability group but did not produce an error frame, so the two reads are already distinguishable — one failed and one did not.`,
397
+ };
398
+ }
399
+ const normalized = normalizeSessions(JSON.stringify(outcome.error), sessions);
400
+ if (reference === null) {
401
+ reference = { probe: probeName, frame: normalized };
402
+ continue;
403
+ }
404
+ if (normalized !== reference.frame) {
405
+ return {
406
+ name: testCase.name,
407
+ probe: probeName,
408
+ obligation: 'a refused read and a read of a locator that never existed are the same bytes',
409
+ expected: reference.frame,
410
+ actual: normalized,
411
+ message: `probes '${reference.probe}' and '${probeName}' produced different error frames. One read a locator that resolves for somebody else and one read a locator that never existed; any difference between them answers "does this render exist?" for a caller with no claim to it.`,
412
+ };
413
+ }
414
+ }
415
+ return null;
416
+ }
417
+ function normalizeSessions(frame, sessions) {
418
+ let out = frame;
419
+ for (const session of sessions) {
420
+ out = out.split(session).join('<session>');
421
+ }
422
+ return out;
423
+ }
424
+ /** The two numbers the resource-read classification rides on. */
425
+ const CANONICAL_NUMBERS = {
426
+ NOT_FOUND: -32002,
427
+ MOUNT_UNAVAILABLE: -32006,
428
+ };
429
+ function readDataCode(data) {
430
+ if (!isRecord(data))
431
+ return undefined;
432
+ const code = data['code'];
433
+ return typeof code === 'string' ? code : undefined;
434
+ }
435
+ function dataHasDetail(data) {
436
+ return isRecord(data) && 'detail' in data;
437
+ }
438
+ function errorText(err) {
439
+ return err instanceof Error ? err.message : String(err);
440
+ }
441
+ // =============================================================================
442
+ // Case-authoring trust boundary
443
+ // =============================================================================
444
+ const SEED_KINDS = [
445
+ 'identity-record',
446
+ 'durable-blueprint',
447
+ 'committed-render',
448
+ 'uncommitted-render',
449
+ 'registered-blueprint',
450
+ ];
451
+ const DATA_CODES = [
452
+ 'NOT_FOUND',
453
+ 'BLUEPRINT_UNRESOLVABLE',
454
+ 'NOT_SUPPORTED',
455
+ 'NOT_MOUNTABLE',
456
+ ];
457
+ const SUBSTRATE_WIRINGS = [
458
+ 'all',
459
+ 'none',
460
+ 'identity-only',
461
+ 'blueprints-only',
462
+ 'all-ephemeral',
463
+ ];
464
+ const EXPECTATION_KINDS = [
465
+ 'typed-error',
466
+ 'outside-typed-set',
467
+ 'live-mount',
468
+ ];
469
+ /**
470
+ * Validating parse of one authored case.
471
+ *
472
+ * The input is `unknown` BY DESIGN — this function IS the trust
473
+ * boundary, so it never leans on the static union it produces evidence
474
+ * for. The catalog's JSON enters the type system through a compile-time
475
+ * cast (tsc widens JSON-module string literals), which means the static
476
+ * type is the claim being checked here, not support for it. Same
477
+ * posture as the WebSocket runner's `parseSetupStep`.
478
+ *
479
+ * An unknown seed kind, locator kind, expectation kind or
480
+ * classification is a CASE-AUTHORING error: the runner throws, aborting
481
+ * the run loudly. It is never a skip and never a fail — those verdicts
482
+ * describe the server under test, not the catalog.
483
+ *
484
+ * Exported for the kit's own meta-tests and reachable from the
485
+ * `./resource-read-conformance` subpath, but NOT re-exported from the
486
+ * package root — the runner calls it for you, and a consumer needing it
487
+ * directly is authoring their own catalog rather than grading against
488
+ * this one.
489
+ */
490
+ export function parseCase(input) {
491
+ if (!isRecord(input) || typeof input['name'] !== 'string' || input['name'].length === 0) {
492
+ throw new Error(`protocol-conformance: a resource-read case is malformed — expected an object with a non-empty string 'name'. Received: ${JSON.stringify(input)}`);
493
+ }
494
+ const name = input['name'];
495
+ const bad = (problem) => new Error(`protocol-conformance: resource-read case '${name}' is malformed — ${problem}.`);
496
+ rejectUnknownKeys(bad, input, CASE_KEYS, 'the case');
497
+ if (typeof input['description'] !== 'string' || input['description'].length === 0) {
498
+ throw bad("'description' must be a non-empty string — it is what a failure report names");
499
+ }
500
+ const server = input['server'];
501
+ if (!isRecord(server))
502
+ throw bad("'server' must be an object declaring the deployment shape");
503
+ rejectUnknownKeys(bad, server, SERVER_KEYS, "'server'");
504
+ for (const flag of ['blueprintRegistry', 'staticDelivery', 'liveChannel']) {
505
+ if (server[flag] !== undefined && typeof server[flag] !== 'boolean') {
506
+ throw bad(`'server.${flag}' must be a boolean when present`);
507
+ }
508
+ }
509
+ const caller = input['caller'];
510
+ if (caller !== 'owner' && caller !== 'other') {
511
+ throw bad(`'caller' must be 'owner' or 'other'. Received: ${JSON.stringify(caller)}`);
512
+ }
513
+ const rawSeeds = input['seeds'];
514
+ if (!Array.isArray(rawSeeds))
515
+ throw bad("'seeds' must be an array (possibly empty)");
516
+ const seeds = rawSeeds.map((seed) => parseSeed(bad, seed));
517
+ const rawReads = input['reads'];
518
+ if (!Array.isArray(rawReads) || rawReads.length === 0) {
519
+ throw bad("'reads' must be a non-empty array — a case that reads nothing grades nothing");
520
+ }
521
+ const probeNames = new Set();
522
+ const reads = rawReads.map((probe) => {
523
+ const parsed = parseProbe(bad, probe);
524
+ if (probeNames.has(parsed.as))
525
+ throw bad(`two probes named '${parsed.as}'`);
526
+ probeNames.add(parsed.as);
527
+ return parsed;
528
+ });
529
+ const group = input['indistinguishable'];
530
+ if (group !== undefined) {
531
+ if (!Array.isArray(group))
532
+ throw bad("'indistinguishable' must be an array of probe names");
533
+ if (group.length === 1) {
534
+ throw bad("'indistinguishable' names a single probe — a group of one asserts nothing, which reads as an obligation being graded when it is not");
535
+ }
536
+ for (const ref of group) {
537
+ if (typeof ref !== 'string' || !probeNames.has(ref)) {
538
+ throw bad(`the indistinguishability group names unknown probe '${String(ref)}'`);
539
+ }
540
+ }
541
+ }
542
+ const secrets = input['disclosesNothing'];
543
+ if (secrets !== undefined &&
544
+ (!Array.isArray(secrets) || secrets.some((s) => typeof s !== 'string' || s.length === 0))) {
545
+ throw bad("'disclosesNothing' must be an array of non-empty strings");
546
+ }
547
+ // Constructed rather than asserted: every field below came through a
548
+ // check above, so the returned value carries evidence for its type
549
+ // instead of a claim about it.
550
+ return {
551
+ name,
552
+ description: input['description'],
553
+ server: {
554
+ durableSubstrate: parseSubstrate(bad, server['durableSubstrate']),
555
+ ...(server['blueprintRegistry'] === true ? { blueprintRegistry: true } : {}),
556
+ ...(server['staticDelivery'] === true ? { staticDelivery: true } : {}),
557
+ ...(server['liveChannel'] === true ? { liveChannel: true } : {}),
558
+ },
559
+ caller,
560
+ seeds,
561
+ reads,
562
+ ...(group !== undefined ? { indistinguishable: group.map(String) } : {}),
563
+ ...(secrets !== undefined ? { disclosesNothing: secrets.map(String) } : {}),
564
+ };
565
+ }
566
+ /**
567
+ * Reject any key outside the authored vocabulary.
568
+ *
569
+ * A typo is otherwise SILENT and expensive: `"liveChanel": true` leaves
570
+ * the flag undefined, so the case brings up a server with no live
571
+ * channel and grades that instead — passing, while asserting something
572
+ * other than what it reads as asserting. Every closed shape in this
573
+ * module is checked, because each one is a scenario the catalog claims
574
+ * to have graded.
575
+ */
576
+ function rejectUnknownKeys(bad, value, known, what) {
577
+ for (const key of Object.keys(value)) {
578
+ if (!known.includes(key)) {
579
+ throw bad(`${what} carries unknown key '${key}' — the vocabulary is closed: ${known.join(', ')}`);
580
+ }
581
+ }
582
+ }
583
+ const CASE_KEYS = [
584
+ 'name',
585
+ 'description',
586
+ 'server',
587
+ 'caller',
588
+ 'seeds',
589
+ 'reads',
590
+ 'indistinguishable',
591
+ 'disclosesNothing',
592
+ ];
593
+ const SERVER_KEYS = [
594
+ 'durableSubstrate',
595
+ 'blueprintRegistry',
596
+ 'staticDelivery',
597
+ 'liveChannel',
598
+ ];
599
+ const PROBE_KEYS = ['as', 'locator', 'expect'];
600
+ const SEED_KEYS = {
601
+ 'identity-record': ['kind', 'session', 'key', 'blueprint'],
602
+ 'durable-blueprint': ['kind', 'componentRef', 'body'],
603
+ 'committed-render': ['kind', 'session'],
604
+ 'uncommitted-render': ['kind', 'session'],
605
+ 'registered-blueprint': ['kind', 'as'],
606
+ };
607
+ const LOCATOR_KEYS = {
608
+ render: ['kind', 'session', 'key'],
609
+ raw: ['kind', 'uri'],
610
+ };
611
+ const KEY_KEYS = {
612
+ literal: ['kind', 'value'],
613
+ registered: ['kind', 'seed'],
614
+ };
615
+ const EXPECTATION_KEYS = {
616
+ 'typed-error': ['kind', 'jsonRpcCode', 'dataCode', 'detailAbsent'],
617
+ 'outside-typed-set': ['kind'],
618
+ 'live-mount': ['kind'],
619
+ };
620
+ function parseSubstrate(bad, value) {
621
+ switch (value) {
622
+ case 'all':
623
+ case 'none':
624
+ case 'identity-only':
625
+ case 'blueprints-only':
626
+ case 'all-ephemeral':
627
+ return value;
628
+ default:
629
+ throw bad(`'server.durableSubstrate' must be one of: ${SUBSTRATE_WIRINGS.join(', ')}. Received: ${JSON.stringify(value)}`);
630
+ }
631
+ }
632
+ function requireText(bad, value, what) {
633
+ if (typeof value !== 'string' || value.length === 0) {
634
+ throw bad(`${what} must be a non-empty string`);
635
+ }
636
+ return value;
637
+ }
638
+ function parseSeed(bad, seed) {
639
+ if (!isRecord(seed))
640
+ throw bad(`a seed must be an object. Received: ${JSON.stringify(seed)}`);
641
+ const seedKeys = SEED_KEYS[String(seed['kind'])];
642
+ if (seedKeys !== undefined)
643
+ rejectUnknownKeys(bad, seed, seedKeys, `a '${String(seed['kind'])}' seed`);
644
+ switch (seed['kind']) {
645
+ case 'identity-record': {
646
+ const blueprint = seed['blueprint'];
647
+ if (blueprint !== 'named' && blueprint !== 'unnamed') {
648
+ throw bad("an identity-record seed's 'blueprint' must be 'named' or 'unnamed'");
649
+ }
650
+ return {
651
+ kind: 'identity-record',
652
+ session: requireText(bad, seed['session'], "an identity-record seed's 'session'"),
653
+ key: requireText(bad, seed['key'], "an identity-record seed's 'key'"),
654
+ blueprint,
655
+ };
656
+ }
657
+ case 'durable-blueprint': {
658
+ const componentRef = seed['componentRef'];
659
+ const body = seed['body'];
660
+ if (componentRef !== 'present' && componentRef !== 'absent') {
661
+ throw bad("a durable-blueprint seed's 'componentRef' must be 'present' or 'absent'");
662
+ }
663
+ if (body !== 'stored' && body !== 'purged') {
664
+ throw bad("a durable-blueprint seed's 'body' must be 'stored' or 'purged'");
665
+ }
666
+ return { kind: 'durable-blueprint', componentRef, body };
667
+ }
668
+ case 'committed-render':
669
+ return {
670
+ kind: 'committed-render',
671
+ session: requireText(bad, seed['session'], "a committed-render seed's 'session'"),
672
+ };
673
+ case 'uncommitted-render':
674
+ return {
675
+ kind: 'uncommitted-render',
676
+ session: requireText(bad, seed['session'], "an uncommitted-render seed's 'session'"),
677
+ };
678
+ case 'registered-blueprint':
679
+ return {
680
+ kind: 'registered-blueprint',
681
+ as: requireText(bad, seed['as'], "a registered-blueprint seed's 'as'"),
682
+ };
683
+ default:
684
+ throw bad(`unknown seed kind '${String(seed['kind'])}'. The seed vocabulary is closed: ${SEED_KINDS.join(', ')}`);
685
+ }
686
+ }
687
+ function parseProbe(bad, probe) {
688
+ if (!isRecord(probe))
689
+ throw bad(`a probe must be an object. Received: ${JSON.stringify(probe)}`);
690
+ const as = requireText(bad, probe['as'], "a probe's 'as' name");
691
+ rejectUnknownKeys(bad, probe, PROBE_KEYS, `probe '${as}'`);
692
+ return {
693
+ as,
694
+ locator: parseLocator(bad, as, probe['locator']),
695
+ expect: parseExpectation(bad, as, probe['expect']),
696
+ };
697
+ }
698
+ function parseLocator(bad, as, locator) {
699
+ if (!isRecord(locator))
700
+ throw bad(`probe '${as}' authors no locator`);
701
+ const locatorKeys = LOCATOR_KEYS[String(locator['kind'])];
702
+ if (locatorKeys !== undefined) {
703
+ rejectUnknownKeys(bad, locator, locatorKeys, `probe '${as}' locator`);
704
+ }
705
+ switch (locator['kind']) {
706
+ case 'render': {
707
+ const session = requireText(bad, locator['session'], `probe '${as}' locator 'session'`);
708
+ const key = locator['key'];
709
+ if (key === undefined)
710
+ return { kind: 'render', session };
711
+ return { kind: 'render', session, key: parseKey(bad, as, key) };
712
+ }
713
+ case 'raw':
714
+ return { kind: 'raw', uri: requireText(bad, locator['uri'], `probe '${as}' locator 'uri'`) };
715
+ default:
716
+ throw bad(`probe '${as}' authors unknown locator kind '${String(locator['kind'])}'. The vocabulary is closed: render, raw`);
717
+ }
718
+ }
719
+ function parseKey(bad, as, key) {
720
+ if (!isRecord(key))
721
+ throw bad(`probe '${as}' authors a malformed blueprint key`);
722
+ const keyKeys = KEY_KEYS[String(key['kind'])];
723
+ if (keyKeys !== undefined)
724
+ rejectUnknownKeys(bad, key, keyKeys, `probe '${as}' blueprint key`);
725
+ switch (key['kind']) {
726
+ case 'literal':
727
+ return { kind: 'literal', value: requireText(bad, key['value'], `probe '${as}' key value`) };
728
+ case 'registered':
729
+ return {
730
+ kind: 'registered',
731
+ seed: requireText(bad, key['seed'], `probe '${as}' registered-key seed name`),
732
+ };
733
+ default:
734
+ throw bad(`probe '${as}' authors unknown key kind '${String(key['kind'])}'. The vocabulary is closed: literal, registered`);
735
+ }
736
+ }
737
+ function parseExpectation(bad, as, expect) {
738
+ if (!isRecord(expect))
739
+ throw bad(`probe '${as}' authors no expectation`);
740
+ const expectKeys = EXPECTATION_KEYS[String(expect['kind'])];
741
+ if (expectKeys !== undefined) {
742
+ rejectUnknownKeys(bad, expect, expectKeys, `probe '${as}' expectation`);
743
+ }
744
+ switch (expect['kind']) {
745
+ case 'outside-typed-set':
746
+ return { kind: 'outside-typed-set' };
747
+ case 'live-mount':
748
+ return { kind: 'live-mount' };
749
+ case 'typed-error': {
750
+ const dataCode = parseDataCode(bad, as, expect['dataCode']);
751
+ const canonical = dataCode === 'NOT_FOUND'
752
+ ? CANONICAL_NUMBERS.NOT_FOUND
753
+ : CANONICAL_NUMBERS.MOUNT_UNAVAILABLE;
754
+ if (expect['jsonRpcCode'] !== canonical) {
755
+ throw bad(`probe '${as}' pins ${String(expect['jsonRpcCode'])} for '${dataCode}', which rides on ${canonical}`);
756
+ }
757
+ const detailAbsent = expect['detailAbsent'];
758
+ if (detailAbsent !== undefined && typeof detailAbsent !== 'boolean') {
759
+ throw bad(`probe '${as}' authors a non-boolean 'detailAbsent'`);
760
+ }
761
+ return {
762
+ kind: 'typed-error',
763
+ jsonRpcCode: canonical,
764
+ dataCode,
765
+ ...(detailAbsent === true ? { detailAbsent: true } : {}),
766
+ };
767
+ }
768
+ default:
769
+ throw bad(`probe '${as}' authors unknown expectation kind '${String(expect['kind'])}'. The vocabulary is closed: ${EXPECTATION_KINDS.join(', ')}`);
770
+ }
771
+ }
772
+ function parseDataCode(bad, as, value) {
773
+ switch (value) {
774
+ case 'NOT_FOUND':
775
+ case 'BLUEPRINT_UNRESOLVABLE':
776
+ case 'NOT_SUPPORTED':
777
+ case 'NOT_MOUNTABLE':
778
+ return value;
779
+ default:
780
+ throw bad(`probe '${as}' authors unknown classification '${String(value)}'. The vocabulary is closed: ${DATA_CODES.join(', ')}`);
781
+ }
782
+ }