@openwop/openwop-conformance 2.42.6 → 2.42.8

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 (129) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +3 -3
  3. package/coverage.md +1 -1
  4. package/dist/lib/soft-skip.js +13 -0
  5. package/dist/spec-artifacts.lock.json +2 -2
  6. package/fixtures/conformance-envelope-retry-attempted.json +2 -1
  7. package/package.json +2 -2
  8. package/requirements.json +581 -175
  9. package/scenario-majors.json +22 -3
  10. package/schemas/CORPUS-STAMP.json +70 -67
  11. package/src/lib/audit-checkpoint.ts +72 -0
  12. package/src/lib/ext-claims.ts +96 -0
  13. package/src/lib/mock-ai-budgets.ts +40 -0
  14. package/src/lib/soft-skip.ts +14 -0
  15. package/src/scenarios/a2a-peer-authority.test.ts +6 -13
  16. package/src/scenarios/a2a-task-roundtrip.test.ts +6 -34
  17. package/src/scenarios/a2a-version-negotiation.test.ts +6 -12
  18. package/src/scenarios/a2ui-surface-delta-transport.test.ts +23 -12
  19. package/src/scenarios/agent-channel-dispatch.test.ts +13 -4
  20. package/src/scenarios/agent-live-invocation-bracket.test.ts +1 -1
  21. package/src/scenarios/agent-manifest-runtime.test.ts +3 -0
  22. package/src/scenarios/aiEnvelope.capBreached.test.ts +2 -2
  23. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +2 -2
  24. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +2 -2
  25. package/src/scenarios/aiEnvelope.universalKinds.test.ts +2 -2
  26. package/src/scenarios/aiproviders-selfhosted-honesty.test.ts +2 -2
  27. package/src/scenarios/append-ordering.test.ts +26 -21
  28. package/src/scenarios/artifact-auth.test.ts +3 -1
  29. package/src/scenarios/artifact-type-registration-source.test.ts +2 -2
  30. package/src/scenarios/audit-checkpoint-signature.test.ts +63 -0
  31. package/src/scenarios/audit-checkpoint-vectors.test.ts +93 -0
  32. package/src/scenarios/audit-log-integrity.test.ts +7 -16
  33. package/src/scenarios/auth-mtls.test.ts +7 -6
  34. package/src/scenarios/auth-subject-link-key-class.test.ts +3 -3
  35. package/src/scenarios/auth-subject-link.test.ts +19 -18
  36. package/src/scenarios/blob-presign-expiry.test.ts +2 -2
  37. package/src/scenarios/byok-roundtrip.test.ts +4 -1
  38. package/src/scenarios/compensation-behavior.test.ts +10 -5
  39. package/src/scenarios/conformance-advertised-seam-required.test.ts +6 -0
  40. package/src/scenarios/connection-pack-apihosts.test.ts +2 -2
  41. package/src/scenarios/connection-provider-resolution.test.ts +2 -2
  42. package/src/scenarios/context-budget-transcript-bound.test.ts +7 -3
  43. package/src/scenarios/context-summarization-replay.test.ts +2 -0
  44. package/src/scenarios/cost-attribution.test.ts +3 -1
  45. package/src/scenarios/debugBundle.test.ts +12 -4
  46. package/src/scenarios/discovery.test.ts +6 -2
  47. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +2 -2
  48. package/src/scenarios/dispatch-input-mapping.test.ts +2 -2
  49. package/src/scenarios/dispatch-output-mapping.test.ts +15 -2
  50. package/src/scenarios/distillation-token-budget.test.ts +2 -2
  51. package/src/scenarios/durability-poison-exhaustion.test.ts +2 -2
  52. package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +51 -15
  53. package/src/scenarios/envelope-nl-to-format-engaged.test.ts +2 -2
  54. package/src/scenarios/envelope-recovery-applied.test.ts +2 -2
  55. package/src/scenarios/envelope-refusal-shape.test.ts +2 -2
  56. package/src/scenarios/envelope-retry-attempted.test.ts +2 -2
  57. package/src/scenarios/envelope-retry-exhausted.test.ts +2 -2
  58. package/src/scenarios/envelope-tier-one-subset-static.test.ts +1 -1
  59. package/src/scenarios/envelope-truncation-cap-exhaustion.test.ts +2 -2
  60. package/src/scenarios/eventOrdering.test.ts +17 -10
  61. package/src/scenarios/form-content-instantiation.test.ts +2 -2
  62. package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +2 -2
  63. package/src/scenarios/highConcurrency.test.ts +9 -9
  64. package/src/scenarios/idempotency-concurrent-claim.test.ts +5 -5
  65. package/src/scenarios/idempotency-key-determinism.test.ts +8 -8
  66. package/src/scenarios/idempotency.test.ts +6 -6
  67. package/src/scenarios/idempotencyRetry.test.ts +16 -11
  68. package/src/scenarios/interrupt-token-matrix.test.ts +24 -11
  69. package/src/scenarios/mcp-cache-tenant-scope.test.ts +3 -3
  70. package/src/scenarios/mcp-current-auth-boundary.test.ts +9 -6
  71. package/src/scenarios/mcp-extension-opacity.test.ts +6 -5
  72. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +9 -8
  73. package/src/scenarios/mcp-version-negotiation.test.ts +5 -10
  74. package/src/scenarios/media-url-inline-cap.test.ts +3 -2
  75. package/src/scenarios/memory-compaction-provenance-tag.test.ts +2 -0
  76. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +2 -2
  77. package/src/scenarios/memory-consolidation-idempotent.test.ts +2 -2
  78. package/src/scenarios/memory-injection-budget.test.ts +2 -2
  79. package/src/scenarios/multi-party-conversation-behavioral.test.ts +2 -2
  80. package/src/scenarios/multi-region-idempotency-behavior.test.ts +9 -9
  81. package/src/scenarios/multi-region-idempotency.test.ts +2 -2
  82. package/src/scenarios/pack-registry-isolation.test.ts +13 -12
  83. package/src/scenarios/pack-registry.test.ts +3 -2
  84. package/src/scenarios/pause-resume.test.ts +30 -11
  85. package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +43 -34
  86. package/src/scenarios/provider-usage.test.ts +2 -2
  87. package/src/scenarios/redaction.test.ts +7 -5
  88. package/src/scenarios/redactionAdversarial.test.ts +5 -4
  89. package/src/scenarios/registry-public.test.ts +5 -1
  90. package/src/scenarios/replay-divergence-at-refusal.test.ts +10 -7
  91. package/src/scenarios/replay-fanout-suppression.test.ts +7 -4
  92. package/src/scenarios/replay-fork-arbitrary.test.ts +2 -2
  93. package/src/scenarios/replay-side-effect-suppression.test.ts +2 -2
  94. package/src/scenarios/replayDeterminism.test.ts +9 -4
  95. package/src/scenarios/runner-ledger.test.ts +4 -0
  96. package/src/scenarios/runtime-capabilities.test.ts +2 -0
  97. package/src/scenarios/speech-synthesis-roundtrip.test.ts +1 -1
  98. package/src/scenarios/streamReconnect.test.ts +2 -2
  99. package/src/scenarios/subrun-approval-gate.test.ts +2 -2
  100. package/src/scenarios/subworkflow-input-mapping.test.ts +2 -2
  101. package/src/scenarios/tool-catalog-projection.test.ts +10 -7
  102. package/src/scenarios/tool-session-lifecycle.test.ts +1 -1
  103. package/src/scenarios/trigger-ingestion.test.ts +2 -2
  104. package/src/scenarios/v2-a2ui-v09-surface.test.ts +15 -0
  105. package/src/scenarios/v2-client-version-header.test.ts +124 -0
  106. package/src/scenarios/v2-effect-identity-business-key.test.ts +12 -0
  107. package/src/scenarios/v2-ext-family-claims.test.ts +62 -0
  108. package/src/scenarios/v2-ext-rest-transport.test.ts +151 -0
  109. package/src/scenarios/v2-id-grammar.test.ts +17 -5
  110. package/src/scenarios/v2-idempotency-in-flight.test.ts +3 -0
  111. package/src/scenarios/v2-legacy-profiles-absent.test.ts +4 -6
  112. package/src/scenarios/v2-mcp-tasks.test.ts +1 -1
  113. package/src/scenarios/v2-min-client-version.test.ts +20 -14
  114. package/src/scenarios/v2-preferred-version-default.test.ts +19 -1
  115. package/src/scenarios/v2-refresh-sla.test.ts +6 -5
  116. package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +3 -3
  117. package/src/scenarios/v2-sse-last-event-id.test.ts +2 -0
  118. package/src/scenarios/v2-version-header-honored.test.ts +3 -0
  119. package/src/scenarios/v2-webhook-durable-delivery.test.ts +2 -0
  120. package/src/scenarios/v2-webhook-generated-secret.test.ts +76 -0
  121. package/src/scenarios/v2-webhook-secret-rotation.test.ts +8 -1
  122. package/src/scenarios/verifier-gating.test.ts +2 -2
  123. package/src/scenarios/version-fold.test.ts +2 -2
  124. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +7 -6
  125. package/src/scenarios/webhook-signed-delivery.test.ts +2 -0
  126. package/src/scenarios/workflow-chain-host-expansion.test.ts +10 -1
  127. package/src/scenarios/workflow-variable-format.test.ts +2 -2
  128. package/src/scenarios/workload-identity-chain-bounds.test.ts +6 -3
  129. package/vectors/audit-checkpoint-v1.json +260 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * v2 — `OpenWOP-Client-Version` (suite 2.42.8; RFC 0219;
3
+ * `spec/v2/core/versioning.md` §1.5 "Client precedence and minClientVersion").
4
+ *
5
+ * A client announces the protocol version it implements in
6
+ * `OpenWOP-Client-Version: <major>.<minor>[.<patch>]`. A host that advertises
7
+ * `minClientVersion` MAY refuse a client below it with `426
8
+ * client_version_unsupported`, and MUST NOT refuse anything else on version
9
+ * grounds. Every leg probes `GET /.well-known/openwop` under
10
+ * `OpenWOP-Version: 2.0` (the discovery document is not exempt from refusal).
11
+ *
12
+ * floor-comparison (gated on minClientVersion) `<floor>`, `<floor>.0` and
13
+ * `<floor>.99` are served: equal on major.minor is not
14
+ * below, and the patch never decides.
15
+ * malformed-not-refused (gated on a host that refuses `0.0.1` with 426) the
16
+ * malformed values `not-a-version`, `1`, `01.0` and
17
+ * `1.0-rc.1` are treated as absent and served — never
18
+ * 426, never 400. A host that serves `0.0.1` does not
19
+ * exercise the refusal, so a served malformed value
20
+ * proves nothing there: `inapplicable`.
21
+ * absent-not-refused a header-less request is never 426 (every host); on a
22
+ * host that refuses `0.0.1` with 426, it is also served.
23
+ * no-floor-no-refusal (hosts advertising NO minClientVersion) `0.0.1` is
24
+ * served, not 426. None of today's certified hosts is in
25
+ * this state, so it records `inapplicable` on each.
26
+ *
27
+ * Unwitnessed and recorded in RFC 0219's falsifiability table: a host choosing
28
+ * a major or a representation from the header, and the header used as an
29
+ * authentication or authorization input.
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
34
+ import { v2Discovery } from '../lib/v2.js';
35
+ import { softSkip } from '../lib/soft-skip.js';
36
+ import { req } from '../lib/requirement-ids.js';
37
+
38
+ const DOC = 'spec/v2/core/versioning.md §1.5';
39
+ const PATH = '/.well-known/openwop';
40
+ const FLOOR = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
41
+ /** Below any floor a host can advertise except `0.0`, which nothing is below. */
42
+ const BELOW = '0.0.1';
43
+ const MALFORMED = ['not-a-version', '1', '01.0', '1.0-rc.1'] as const;
44
+
45
+ async function discovery(): Promise<Record<string, unknown> | null> {
46
+ try { return await v2Discovery(); } catch { return null; }
47
+ }
48
+ async function probe(clientVersion: string | null): Promise<OpenWOPResponse | null> {
49
+ const headers: Record<string, string> = { 'OpenWOP-Version': '2.0' };
50
+ if (clientVersion !== null) headers['OpenWOP-Client-Version'] = clientVersion;
51
+ try { return await driver.get(PATH, { authenticated: false, headers }); } catch { return null; }
52
+ }
53
+ const served = (r: OpenWOPResponse): boolean => r.status >= 200 && r.status < 300;
54
+
55
+ /** The advertised floor, or a skip reason. */
56
+ async function advertisedFloor(): Promise<{ floor: string } | { skip: [kind: 'blocked' | 'inapplicable', reason: string] }> {
57
+ const doc = await discovery();
58
+ if (!doc) return { skip: ['blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0'] };
59
+ const floor = doc['minClientVersion'];
60
+ if (floor === undefined) return { skip: ['inapplicable', 'the host does not advertise minClientVersion — the floor legs are gated on a host that sets it'] };
61
+ // The floor's own grammar is v2-min-client-version's row; a floor this leg cannot read compares nothing.
62
+ if (typeof floor !== 'string' || !FLOOR.test(floor)) return { skip: ['inapplicable', `minClientVersion ${JSON.stringify(floor)} is not <major>.<minor> — judged by v2-min-client-version, nothing to compare here`] };
63
+ if (floor === '0.0') return { skip: ['inapplicable', 'minClientVersion is 0.0 — no well-formed client version is below it, so no refusal can be provoked'] };
64
+ return { floor };
65
+ }
66
+
67
+ /** Gate for the legs that need a host which actually refuses a below-floor client. */
68
+ async function refusingHost(): Promise<{ floor: string } | { skip: [kind: 'blocked' | 'inapplicable', reason: string] }> {
69
+ const f = await advertisedFloor();
70
+ if ('skip' in f) return f;
71
+ const control = await probe(BELOW);
72
+ if (control === null) return { skip: ['blocked', `GET ${PATH} with OpenWOP-Client-Version: ${BELOW} unreachable (fetch failed)`] };
73
+ if (served(control)) return { skip: ['inapplicable', `the host advertises minClientVersion ${f.floor} but served a client announcing ${BELOW} (${control.status}) — refusal is a MAY; a served header-less or malformed request distinguishes nothing on a host that refuses nobody`] };
74
+ if (control.status !== 426) return { skip: ['blocked', `the control (${BELOW}, below minClientVersion ${f.floor}) answered ${control.status} — neither served nor 426; v2-min-client-version records that as a failure, and this leg has no refusal to compare against`] };
75
+ return f;
76
+ }
77
+
78
+ describe('v2 OpenWOP-Client-Version (RFC 0219 — versioning.md §1.5)', () => {
79
+ it('a client at the floor is not below it: equal on major.minor, the patch never decides', async () => {
80
+ const f = await advertisedFloor();
81
+ if ('skip' in f) return softSkip(...f.skip);
82
+ for (const v of [f.floor, `${f.floor}.0`, `${f.floor}.99`]) {
83
+ const res = await probe(v);
84
+ if (res === null) return softSkip('blocked', `GET ${PATH} with OpenWOP-Client-Version: ${v} unreachable (fetch failed)`);
85
+ expect(res.status, req('openwop.requirement.0219.floor-comparison', DOC, `a client announcing ${v} is not below minClientVersion ${f.floor} (major and minor compared as integers; the patch never decides) and MUST be served, not refused (got ${res.status})`)).not.toBe(426);
86
+ expect(served(res), req('openwop.requirement.0219.floor-comparison', DOC, `a well-formed OpenWOP-Client-Version at the floor MUST NOT make the request fail — ${v} got ${res.status}`)).toBe(true);
87
+ }
88
+ });
89
+
90
+ it('a malformed OpenWOP-Client-Version is treated as absent: never 426, never 400', async () => {
91
+ const f = await refusingHost();
92
+ if ('skip' in f) return softSkip(...f.skip);
93
+ for (const v of MALFORMED) {
94
+ const res = await probe(v);
95
+ if (res === null) return softSkip('blocked', `GET ${PATH} with OpenWOP-Client-Version: ${v} unreachable (fetch failed)`);
96
+ expect(res.status, req('openwop.requirement.0219.malformed-not-refused', DOC, `a value outside the grammar (${JSON.stringify(v)}) MUST be treated as absent, so it MUST NOT be refused 426 client_version_unsupported (floor ${f.floor})`)).not.toBe(426);
97
+ expect(res.status, req('openwop.requirement.0219.malformed-not-refused', DOC, `a value outside the grammar (${JSON.stringify(v)}) MUST NOT produce a 400`)).not.toBe(400);
98
+ expect(served(res), req('openwop.requirement.0219.malformed-not-refused', DOC, `a malformed value is treated as absent and a header-less request is served — ${JSON.stringify(v)} got ${res.status}`)).toBe(true);
99
+ }
100
+ });
101
+
102
+ it('a request without OpenWOP-Client-Version is served where a below-floor one is refused', async () => {
103
+ // A 426 to a header-less request is a violation on ANY host, floor or not — probe it before the gate,
104
+ // because a host that refuses it also refuses the header-less discovery read the gate depends on.
105
+ const res = await probe(null);
106
+ if (res === null) return softSkip('blocked', `GET ${PATH} without OpenWOP-Client-Version unreachable (fetch failed)`);
107
+ expect(res.status, req('openwop.requirement.0219.absent-not-refused', DOC, 'a host MUST NOT answer 426 client_version_unsupported to a request that carries no OpenWOP-Client-Version')).not.toBe(426);
108
+ const f = await refusingHost();
109
+ // partial-witness-ok: the header-less request was not refused 426 (asserted above, binding on every host);
110
+ // that it is SERVED distinguishes something only on a host that refuses a below-floor client.
111
+ if ('skip' in f) return softSkip(...f.skip);
112
+ expect(served(res), req('openwop.requirement.0219.absent-not-refused', DOC, `a host MUST NOT refuse a request because it omits OpenWOP-Client-Version (got ${res.status})`)).toBe(true);
113
+ });
114
+
115
+ it('a host that advertises no minClientVersion refuses no client on version grounds', async () => {
116
+ const doc = await discovery();
117
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
118
+ if (doc['minClientVersion'] !== undefined) return softSkip('inapplicable', `the host advertises minClientVersion ${JSON.stringify(doc['minClientVersion'])} — this leg is for a host with no floor`);
119
+ const res = await probe(BELOW);
120
+ if (res === null) return softSkip('blocked', `GET ${PATH} with OpenWOP-Client-Version: ${BELOW} unreachable (fetch failed)`);
121
+ expect(res.status, req('openwop.requirement.0219.no-floor-no-refusal', DOC, `a host that advertises no minClientVersion MUST NOT answer 426 client_version_unsupported (a client announcing ${BELOW})`)).not.toBe(426);
122
+ expect(served(res), req('openwop.requirement.0219.no-floor-no-refusal', DOC, `a well-formed OpenWOP-Client-Version MUST NOT make the request fail on a host with no floor (got ${res.status})`)).toBe(true);
123
+ });
124
+ });
@@ -29,6 +29,15 @@ import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
29
29
  import { startEffectReceiver } from '../lib/effect-receiver.js';
30
30
  import { req } from '../lib/requirement-ids.js';
31
31
 
32
+ /**
33
+ * The retry leg hands the host the address of the suite's OWN receiver
34
+ * (`lib/effect-receiver.ts`, since 2.42.4) and the host calls it — a connection
35
+ * the host originates back to the harness. `host-callback-declaration` requires
36
+ * that to be declared; 2.42.4 added the receiver and not the declaration, so
37
+ * the self-check failed on every host that runs the full catalog.
38
+ */
39
+ export const REQUIRES_HOST_CALLBACK = 'the host makes the retried outbound effect call to the suite-owned effect receiver (OPENWOP_WEBHOOK_RECEIVER_PORT)';
40
+
32
41
  const FIXTURE = 'conformance-noop';
33
42
  const KEYING = ['business-identity', 'activity-recipe'];
34
43
  const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
@@ -73,6 +82,9 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
73
82
  const body = res.json as { runId?: unknown; effects?: Array<{ effectId?: unknown; keying?: unknown; providerKey?: unknown }> } | null;
74
83
  const effects = body?.effects ?? [];
75
84
  if (res.status === 200 && check.ok && body?.runId === runId && effects.length === 0) {
85
+ // partial-witness-ok: no assertion has passed at runtime. The only earlier
86
+ // expect runs solely when the create is not 201, and then it throws, so this
87
+ // skip is a zero-assertion `inapplicable` (the gate reads source order).
76
88
  return softSkip('inapplicable', 'the noop fixture issued no external effect — the ledger read is well-formed but the per-row keying leg had no rows (an effect-issuing fixture would exercise it)');
77
89
  }
78
90
  expect(
@@ -0,0 +1,62 @@
1
+ /**
2
+ * v2-ext-family-claims — RFC 0220 §B, the `claims-check` witness for the 11
3
+ * discovery-only extension families (suite 2.42.7, target major 2).
4
+ *
5
+ * Each family is a reservation: v2 defines no portable operation for it, so the
6
+ * only thing a server-oriented suite can check is the claim itself. A host that
7
+ * advertises `extensions["<org>.<extensionName>"]` records `executed-pass` under
8
+ * `openwop.family.<key>` when the claim is well formed (lib/ext-claims.ts), and
9
+ * `executed-fail` when it is not. A host that does not advertise the family
10
+ * records `inapplicable`.
11
+ *
12
+ * Why not behaviorGate: an ext family is outside every profile
13
+ * (spec/v2/ext/README.md), so strict mode has nothing to demand here — a host
14
+ * that serves no `kanban` has made no promise about one. The gate is the claim.
15
+ *
16
+ * Every req() id is a literal so the requirement registry collects it. That a
17
+ * leg exists for every claims-check family in the declaration is a corpus fact,
18
+ * checked by the self-test in src/lib/ext-claims.test.ts, not by a host run.
19
+ *
20
+ * @see RFCS/0220-ext-families-graduate-on-evidence.md
21
+ * @see spec/v2/ext/README.md
22
+ * @see spec/v2/core/capabilities.md §3.2
23
+ */
24
+ import { describe, it, expect } from 'vitest';
25
+ import { v2Discovery } from '../lib/v2.js';
26
+ import { extDeclaration, classifyExtClaim } from '../lib/ext-claims.js';
27
+ import { softSkip } from '../lib/soft-skip.js';
28
+ import { req } from '../lib/requirement-ids.js';
29
+
30
+ const LEGS = ['brand', 'canvas', 'chat', 'coordination', 'dataIntegration', 'entities', 'kanban', 'knowledge', 'launchStudio', 'messaging', 'webResearch'] as const;
31
+
32
+ async function leg(key: (typeof LEGS)[number], cite: (why: string) => string): Promise<undefined> {
33
+ const decl = extDeclaration();
34
+ if (!decl) return softSkip('blocked', 'spec/v2/declaration.json is not resolvable in this layout — the extension name and the org registry come from it, and guessing either would make the suite the registry (conformance.md §Whose fact is the reason?)');
35
+ const family = decl.families.find((f) => f.key === key);
36
+ if (!family) return softSkip('blocked', `the declaration has no ext row for ${key} — this leg is stale against the corpus`);
37
+ const doc = await v2Discovery();
38
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
39
+ const claims = classifyExtClaim(doc, family, decl);
40
+ if (claims.length === 1 && claims[0].state === 'absent') return softSkip('inapplicable', `no extensions["<org>.${family.extensionName}"] record under a registered org — the host does not advertise ${key}`);
41
+ for (const c of claims) {
42
+ if (c.state === 'absent') continue;
43
+ expect(c.state, cite(c.state === 'malformed' ? `extensions["${c.key}"]: ${c.why}` : `extensions["${c.key}"] is a well-formed claim`)).toBe('well-formed');
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ const S = (key: string): string => `spec/v2/ext/${key}/README.md §Conformance`;
49
+
50
+ describe('RFC 0220 §B — an extension family is claimed in a well-formed extensions record (claims-check)', () => {
51
+ it('brand', () => leg('brand', (why) => req('openwop.family.brand', S('brand'), why)));
52
+ it('canvas', () => leg('canvas', (why) => req('openwop.family.canvas', S('canvas'), why)));
53
+ it('chat', () => leg('chat', (why) => req('openwop.family.chat', S('chat'), why)));
54
+ it('coordination', () => leg('coordination', (why) => req('openwop.family.coordination', S('coordination'), why)));
55
+ it('dataIntegration', () => leg('dataIntegration', (why) => req('openwop.family.dataIntegration', S('dataIntegration'), why)));
56
+ it('entities', () => leg('entities', (why) => req('openwop.family.entities', S('entities'), why)));
57
+ it('kanban', () => leg('kanban', (why) => req('openwop.family.kanban', S('kanban'), why)));
58
+ it('knowledge', () => leg('knowledge', (why) => req('openwop.family.knowledge', S('knowledge'), why)));
59
+ it('launchStudio', () => leg('launchStudio', (why) => req('openwop.family.launchStudio', S('launchStudio'), why)));
60
+ it('messaging', () => leg('messaging', (why) => req('openwop.family.messaging', S('messaging'), why)));
61
+ it('webResearch', () => leg('webResearch', (why) => req('openwop.family.webResearch', S('webResearch'), why)));
62
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * v2-ext-rest-transport — RFC 0220 §C, the behavioral witness for the
3
+ * `restTransport` extension family (suite 2.42.7, target major 2).
4
+ *
5
+ * Gate: the host advertises `extensions["<org>.rest-transport"]` under a
6
+ * registered org (spec/v2/ext/restTransport/README.md §The claim). Absent ⇒
7
+ * `inapplicable`. A malformed claim fails the claim leg; it does not skip.
8
+ *
9
+ * Legs, both recorded under `openwop.family.restTransport` so the family row is
10
+ * the least certifiable of them (requirement-ledger fold):
11
+ *
12
+ * 1. `conditionalRunGet: true` — the `conformance-approval` fixture parks at
13
+ * `waiting-approval`, a stable state. The 200 carries a strong ETag, a
14
+ * matching If-None-Match gets 304 with no body, and once the approval is
15
+ * resolved and the run completes the ETag differs — a 304 across that
16
+ * transition would be stale (runs.md §"Caching and encoding").
17
+ * 2. `contentEncodings` — for each advertised coding, the response carries
18
+ * that `Content-Encoding` and decodes to the identity body byte for byte.
19
+ *
20
+ * A leg whose facet the record does not set records `inapplicable` for itself
21
+ * only: a host that claims one facet has promised nothing about the other.
22
+ *
23
+ * Ported from the v1 `run-transport-economy` scenario, which reads the v1 root
24
+ * key; this one reads the v2 extensions record.
25
+ *
26
+ * @see RFCS/0220-ext-families-graduate-on-evidence.md
27
+ * @see spec/v2/ext/restTransport/README.md
28
+ * @see spec/v2/core/runs.md §"Caching and encoding"
29
+ */
30
+ import { describe, it, expect } from 'vitest';
31
+ import { gunzipSync, brotliDecompressSync } from 'node:zlib';
32
+ import * as zlib from 'node:zlib';
33
+ import { driver } from '../lib/driver.js';
34
+ import { loadEnv } from '../lib/env.js';
35
+ import { v2Discovery } from '../lib/v2.js';
36
+ import { extDeclaration, classifyExtClaim } from '../lib/ext-claims.js';
37
+ import { pollUntilStatus, pollUntilTerminal } from '../lib/polling.js';
38
+ import { softSkip } from '../lib/soft-skip.js';
39
+ import { req } from '../lib/requirement-ids.js';
40
+
41
+ type Json = Record<string, unknown>;
42
+ type Encoding = 'gzip' | 'br' | 'zstd';
43
+ const APPROVAL_FIXTURE = 'conformance-approval';
44
+ const APPROVAL_NODE_ID = 'gate';
45
+ const NOOP_FIXTURE = 'conformance-noop';
46
+ const SECTION = 'spec/v2/ext/restTransport/README.md §What the claim adds';
47
+ const R = (why: string): string => req('openwop.family.restTransport', SECTION, why);
48
+
49
+ type Claim = { ok: true; record: Json; key: string } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string };
50
+
51
+ /** The one well-formed rest-transport record, or why there is none. A malformed claim is asserted here, not skipped. */
52
+ async function claim(): Promise<Claim> {
53
+ const decl = extDeclaration();
54
+ if (!decl) return { ok: false, kind: 'blocked', reason: 'spec/v2/declaration.json is not resolvable in this layout — the extension name and the org registry come from it' };
55
+ const family = decl.families.find((f) => f.key === 'restTransport');
56
+ if (!family) return { ok: false, kind: 'blocked', reason: 'the declaration has no ext row for restTransport — this scenario is stale against the corpus' };
57
+ const doc = await v2Discovery();
58
+ if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
59
+ const claims = classifyExtClaim(doc, family, decl);
60
+ if (claims.length === 1 && claims[0].state === 'absent') return { ok: false, kind: 'inapplicable', reason: 'no extensions["<org>.rest-transport"] record under a registered org — the host does not advertise restTransport' };
61
+ for (const c of claims) {
62
+ if (c.state === 'malformed') expect(c.state, R(`extensions["${c.key}"]: ${c.why}`)).toBe('well-formed');
63
+ }
64
+ const first = claims.find((c) => c.state === 'well-formed');
65
+ if (!first || first.state !== 'well-formed') return { ok: false, kind: 'blocked', reason: 'no well-formed rest-transport claim' };
66
+ return { ok: true, record: ((doc['extensions'] as Json)[first.key] ?? {}) as Json, key: first.key };
67
+ }
68
+
69
+ function advertisedEncodings(record: Json): Encoding[] {
70
+ const raw = record['contentEncodings'];
71
+ return Array.isArray(raw) ? raw.filter((e): e is Encoding => e === 'gzip' || e === 'br' || e === 'zstd') : [];
72
+ }
73
+
74
+ describe('RFC 0220 §C — restTransport: conditional GET on the run snapshot', () => {
75
+ it('a strong ETag on the 200, 304 on a match while the run is parked, and a new ETag once it completes', async () => {
76
+ const c = await claim();
77
+ if (!c.ok) return softSkip(c.kind, c.reason);
78
+ if (c.record['conditionalRunGet'] !== true) return softSkip('inapplicable', `extensions["${c.key}"].conditionalRunGet is not true — the host claims no validator`);
79
+
80
+ const create = await driver.post('/runs', { workflowId: APPROVAL_FIXTURE });
81
+ // Any non-201 is the suite's precondition failing (fixture, credential), not the claim — the v2-a2ui gate's convention.
82
+ if (create.status !== 201) return softSkip('blocked', `POST /runs {workflowId: ${APPROVAL_FIXTURE}} answered ${create.status} — the run the leg needs could not be created`);
83
+ const runId = (create.json as { runId: string }).runId;
84
+ const path = `/runs/${encodeURIComponent(runId)}`;
85
+ await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
86
+
87
+ const parked = await driver.get(path);
88
+ expect(parked.status, R('the parked run reads 200')).toBe(200);
89
+ const etagParked = parked.headers.get('etag');
90
+ expect(etagParked, R('a host whose record sets conditionalRunGet: true MUST carry an ETag on every 200 of GET /runs/{runId}')).toBeTruthy();
91
+ expect(etagParked?.startsWith('W/'), R('the ETag MUST be strong')).toBe(false);
92
+
93
+ const revalidate = await driver.get(path, { headers: { 'If-None-Match': etagParked as string } });
94
+ expect(revalidate.status, R('a matching If-None-Match MUST receive 304 while the run has not advanced (runs.md §"Caching and encoding")')).toBe(304);
95
+ expect(revalidate.text, R('the 304 MUST carry no body')).toBe('');
96
+
97
+ const resolve = await driver.post(`${path}/interrupts/${encodeURIComponent(APPROVAL_NODE_ID)}`, { resumeValue: { action: 'accept' } });
98
+ expect(resolve.status, R('the approval resolves (control: the run must advance for the rotation check to mean anything)')).toBe(200);
99
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
100
+ expect(terminal.status, R('the run completes')).toBe('completed');
101
+
102
+ const done = await driver.get(path);
103
+ const etagDone = done.headers.get('etag');
104
+ expect(etagDone, R('the completed run still carries an ETag')).toBeTruthy();
105
+ expect(etagDone, R('the ETag MUST change once the run advances; the parked ETag would otherwise answer 304 for a changed run')).not.toBe(etagParked);
106
+ const stale = await driver.get(path, { headers: { 'If-None-Match': etagParked as string } });
107
+ expect(stale.status, R('the parked ETag MUST NOT match the completed run')).toBe(200);
108
+ });
109
+ });
110
+
111
+ describe('RFC 0220 §C — restTransport: each advertised coding round-trips', () => {
112
+ it('each coding in contentEncodings is produced when asked for alone and decodes to the identity body', async () => {
113
+ const c = await claim();
114
+ if (!c.ok) return softSkip(c.kind, c.reason);
115
+ const encodings = advertisedEncodings(c.record);
116
+ if (encodings.length === 0) return softSkip('inapplicable', `extensions["${c.key}"].contentEncodings lists no coding — the host claims none`);
117
+
118
+ const create = await driver.post('/runs', { workflowId: NOOP_FIXTURE });
119
+ // Any non-201 is the suite's precondition failing (fixture, credential), not the claim — the v2-a2ui gate's convention.
120
+ if (create.status !== 201) return softSkip('blocked', `POST /runs {workflowId: ${NOOP_FIXTURE}} answered ${create.status} — the run the leg needs could not be created`);
121
+ const runId = (create.json as { runId: string }).runId;
122
+ await pollUntilTerminal(runId, { timeoutMs: 10_000 });
123
+
124
+ // A raw fetch, not the driver: the driver decodes JSON, and the oracle here is bytes.
125
+ const env = loadEnv();
126
+ const url = `${env.baseUrl}/runs/${encodeURIComponent(runId)}`;
127
+ const base = { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'OpenWOP-Version': '2.0' };
128
+ const identity = await fetch(url, { headers: { ...base, 'Accept-Encoding': 'identity' } });
129
+ expect(identity.status, R('the identity read is 200')).toBe(200);
130
+ const identityBytes = Buffer.from(await identity.arrayBuffer());
131
+ expect(identityBytes.length, R('the identity body is non-empty')).toBeGreaterThan(0);
132
+
133
+ const zstdMaybe: { zstdDecompressSync?: (b: Buffer) => Buffer } = zlib;
134
+ for (const enc of encodings) {
135
+ const res = await fetch(url, { headers: { ...base, 'Accept-Encoding': enc } });
136
+ expect(res.status, R(`the ${enc} read is 200`)).toBe(200);
137
+ expect(res.headers.get('content-encoding'), R(`a coding listed in contentEncodings MUST be produced when it is the only one asked for (${enc})`)).toBe(enc);
138
+ expect((res.headers.get('vary') ?? '').toLowerCase(), R('a compressed response MUST carry Vary: Accept-Encoding (runs.md §"Caching and encoding")')).toContain('accept-encoding');
139
+ const bytes = Buffer.from(await res.arrayBuffer());
140
+ // Node's fetch decodes gzip and br in transit, so equal bytes already prove the round trip.
141
+ const decode = enc === 'gzip' ? gunzipSync : enc === 'br' ? brotliDecompressSync : zstdMaybe.zstdDecompressSync;
142
+ // No zstd decoder in this Node: the coding was produced (asserted above); the byte compare needs a decoder the suite lacks.
143
+ if (!bytes.equals(identityBytes) && !decode) continue;
144
+ let decoded = bytes;
145
+ if (!bytes.equals(identityBytes) && decode) {
146
+ try { decoded = Buffer.from(decode(bytes)); } catch { /* not compressed bytes either: a real mismatch, compared below */ }
147
+ }
148
+ expect(decoded.equals(identityBytes), R(`the ${enc}-decoded body MUST be byte-identical to the identity body`)).toBe(true);
149
+ }
150
+ });
151
+ });
@@ -28,9 +28,11 @@ import { readErrorCode } from '../lib/error-envelope.js';
28
28
  import { softSkip } from '../lib/soft-skip.js';
29
29
  import { req } from '../lib/requirement-ids.js';
30
30
  import { BOUND_ID as RUN_ID } from '../lib/bound-id.js';
31
+ import { scaledTimeoutMs } from '../lib/polling.js';
31
32
 
32
33
  const DOC = 'spec/v2/core/identity.md §5';
33
34
  const NOOP_WORKFLOW_ID = 'conformance-noop';
35
+ const EVENTS_DEADLINE_MS = 20_000;
34
36
  const FOREIGN_RUN_ID = 'openwop-conformance-foreign-tenant/foreignopaque0123456789abcdef';
35
37
 
36
38
  async function discovery(): Promise<Record<string, unknown> | null> {
@@ -63,17 +65,27 @@ describe('v2 id-grammar (RFC 0170 §D.1)', () => {
63
65
  it('every id on the run events matches its ids.schema.json kind', async () => {
64
66
  const c = await createRun();
65
67
  if ('reason' in c) return softSkip('blocked', c.reason);
66
- const res = await http(() => driver.get(`/runs/${encodeURIComponent(c.runId)}/events/poll?timeout=1`));
67
- if (res === null || res.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events/poll answered ${res?.status ?? 'no response'}`);
68
- const events = (res.json as { events?: unknown } | undefined)?.events;
69
- if (!Array.isArray(events) || events.length === 0) return softSkip('blocked', 'the poll returned no events for the run just created — nothing to check the eventId / runId / nodeId grammars against');
68
+ // Poll until the run has recorded an event. A single 1 s poll straight after
69
+ // the create raced the host's dispatch: a host that queues the run (Cloud
70
+ // Tasks on MyndHyve) can legitimately have no event yet, and `blocked` is
71
+ // bundle-fatal (2.42.8; MyndHyve cut 3 on 2.42.7). A host that records
72
+ // nothing for a noop run inside the scaled deadline is still `blocked`.
73
+ const deadline = Date.now() + scaledTimeoutMs(EVENTS_DEADLINE_MS);
74
+ let res: OpenWOPResponse | null = null;
75
+ let events: unknown;
76
+ do {
77
+ res = await http(() => driver.get(`/runs/${encodeURIComponent(c.runId)}/events/poll?timeout=5`));
78
+ if (res === null || res.status !== 200) return softSkip('blocked', `GET /runs/{runId}/events/poll answered ${res?.status ?? 'no response'}`);
79
+ events = (res.json as { events?: unknown } | undefined)?.events;
80
+ } while ((!Array.isArray(events) || events.length === 0) && Date.now() < deadline);
81
+ if (!Array.isArray(events) || events.length === 0) return softSkip('blocked', `the poll returned no events for the run just created within ${scaledTimeoutMs(EVENTS_DEADLINE_MS)} ms — nothing to check the eventId / runId / nodeId grammars against`);
70
82
  const validate = v2Validator('run-event');
71
83
  for (const ev of events) {
72
84
  const r = validate(ev);
73
85
  expect(r.ok, req('openwop.requirement.0170.id-grammar.events', DOC, `every RunEventDoc id (eventId, runId, nodeId, causationId) MUST match its kind — event ${String((ev as { eventId?: unknown }).eventId)} (${r.errors})`)).toBe(true);
74
86
  expect((ev as { runId?: unknown }).runId, req('openwop.requirement.0170.id-grammar.events', DOC, 'every event MUST carry the run\'s own runId')).toBe(c.runId);
75
87
  }
76
- });
88
+ }, scaledTimeoutMs(EVENTS_DEADLINE_MS) + 15_000);
77
89
 
78
90
  it('a run id whose tenant segment is not the caller\'s is refused', async () => {
79
91
  if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
@@ -147,6 +147,9 @@ describe('v2 idempotency-in-flight (idempotency.md Concurrency, RFC 0213 §B)',
147
147
  // (#1525's fix, kept verbatim; `r.refusals` is the split form's spelling of
148
148
  // its `refusals`, and it now lands on the LOSER id it is about rather than
149
149
  // on a row shared with the winner clause.)
150
+ // partial-witness-ok: the winner/replay clauses were asserted above; RFC 0213 §B lets every
151
+ // loser wait for a marked replay, so the 409 branch is an outcome this host did not take,
152
+ // not an unobserved obligation.
150
153
  if (r.refusals.length === 0) return softSkip('inapplicable', `no loser was refused in flight — all ${N} answers were successes (each loser a marked replay, which §B permits), so the 409 branch did not run on this host`);
151
154
  }, 60_000);
152
155
  // The deterministic §B witness (host-sample-test-seams.md §26): the seam only
@@ -41,11 +41,10 @@ describe('RFC 0175 §C.1 — legacy-profiles-absent (unaided)', () => {
41
41
  it('no -legacy id appears in a2a.profiles or mcp.profiles and each id matches its facet pattern', async () => {
42
42
  const doc = await discovery();
43
43
  if (!doc) return softSkip('blocked', 'discovery unreachable');
44
- let checked = 0;
45
- for (const family of ['a2a', 'mcp'] as const) {
46
- const profiles = profilesOf(doc, family);
47
- if (profiles === null) continue;
48
- checked++;
44
+ const families = (['a2a', 'mcp'] as const).filter((family) => profilesOf(doc, family) !== null);
45
+ if (families.length === 0) return softSkip('inapplicable', 'host advertises neither a2a nor mcp — no profiles[] to check for a -legacy id');
46
+ for (const family of families) {
47
+ const profiles = profilesOf(doc, family)!;
49
48
  for (const id of profiles) {
50
49
  expect(
51
50
  /-legacy$/.test(id),
@@ -64,7 +63,6 @@ describe('RFC 0175 §C.1 — legacy-profiles-absent (unaided)', () => {
64
63
  req('openwop.requirement.0175.legacy-profiles-absent', `facets/${family}.schema.json`, `the ${family} facet MUST validate against its facet schema (capabilities.schema.json ${family}): ${check.errors}`),
65
64
  ).toBe(true);
66
65
  }
67
- if (checked === 0) softSkip('inapplicable', 'host advertises neither a2a nor mcp — no profiles[] to check for a -legacy id');
68
66
  });
69
67
 
70
68
  it('no root profiles[] exists in the v2 representation', async () => {
@@ -309,7 +309,7 @@ describe('RFC 0198 — v2-mcp-tasks (MCP Tasks on the server mount; disconnect c
309
309
  if (f.result?.['resultType'] === 'task') terminal = await pollTask(m.url, String(f.result['taskId']), (s) => ['completed', 'failed', 'cancelled'].includes(s), 10_000);
310
310
  else {
311
311
  // unfailable-leg audit wave 2, 2026-09-27: this branch returned
312
- // softSkip('inapplicable') after the input_required asserts — a
312
+ // an inapplicable soft-skip after the input_required asserts — a
313
313
  // partial-witness PASS at both majors even when the synchronous answer
314
314
  // was NOT a tool-error outcome. The synchronous CallToolResult of a
315
315
  // failed run MUST still carry isError true; the task projection itself
@@ -9,16 +9,19 @@
9
9
  * any advertised floor) is sent; when the host refuses, the refusal MUST be the
10
10
  * registered code at its registered status in the closed envelope; a host that
11
11
  * exercises the MAY by serving the request records `inapplicable` with that
12
- * reason. `OpenWOP-Client-Version` is the `OpenWOP-<Name>` header this
13
- * scenario uses to announce the client; it is not yet declared in
14
- * `api/v2/openapi.yaml` / `headers.md`.
12
+ * reason. `OpenWOP-Client-Version` is declared in `api/v2/openapi.yaml` /
13
+ * `headers.md` and specified in versioning.md §1.5 (RFC 0219), so a non-`2xx`,
14
+ * non-`426` answer to a well-formed below-floor value is a refusal in an
15
+ * unregistered form: a failure, not `blocked` (RFC 0219, Unresolved question 4,
16
+ * decided 2026-09-27). The header's other legs (at-floor, absent, malformed, no
17
+ * floor) are `v2-client-version-header`.
15
18
  */
16
19
 
17
20
  import { describe, it, expect } from 'vitest';
18
21
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
19
22
  import { v2Discovery, v2Validator } from '../lib/v2.js';
20
23
  import { readErrorCode } from '../lib/error-envelope.js';
21
- import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
24
+ import { softSkip } from '../lib/soft-skip.js';
22
25
  import { req } from '../lib/requirement-ids.js';
23
26
 
24
27
  const DOC = 'spec/v2/core/versioning.md §1.5';
@@ -40,19 +43,22 @@ describe('v2 min-client-version (RFC 0172 §A.5 — gated on minClientVersion)',
40
43
  expect(typeof floor === 'string' && VERSION.test(floor), req('openwop.requirement.0172.min-client-version', DOC, 'minClientVersion MUST use the <major>.<minor> grammar (axis 15, as axis 1)')).toBe(true);
41
44
  const res = await http(() => driver.get('/.well-known/openwop', { authenticated: false, headers: { 'OpenWOP-Client-Version': '0.0.1' } }));
42
45
  if (res === null) return softSkip('blocked', 'GET /.well-known/openwop with OpenWOP-Client-Version unreachable (fetch failed)');
43
- // unfailable-leg audit wave 2, 2026-09-27: every non-426 answer — including
44
- // a 4xx/5xx that refused the client in an unregistered form — recorded
45
- // `inapplicable` AFTER the grammar assert, i.e. a partial-witness PASS of
46
- // the 426 refusal row. Only a SERVED request (2xx) is the MAY exercised; any
47
- // other non-426 answer is `blocked`. Not asserted as a failure:
48
- // `OpenWOP-Client-Version` is not yet declared in api/v2/openapi.yaml /
49
- // headers.md, so a non-2xx here is not provably a version refusal.
50
- if (res.status !== 426 && (res.status < 200 || res.status >= 300)) {
51
- return blockedDespiteAssertions(`the host advertises minClientVersion ${String(floor)} and answered ${res.status} to a client announcing 0.0.1 — neither served (the MAY) nor refused with the registered 426 client_version_unsupported; the refusal form was not observed`);
52
- }
46
+ // RFC 0219 Q4 (2026-09-27): the header is declared, so a host that answers a
47
+ // well-formed below-floor value with anything but 2xx (the MAY: served) or
48
+ // 426 client_version_unsupported refuses a declared header in an
49
+ // unregistered form. That is a failure. Before RFC 0219 it recorded `blocked`.
50
+ expect(res.status === 426 || (res.status >= 200 && res.status < 300), req('openwop.requirement.0172.min-client-version', DOC, `a client announcing 0.0.1 below minClientVersion ${String(floor)} MUST be served or refused with 426 client_version_unsupported — the host answered ${res.status}`)).toBe(true);
51
+ // partial-witness-ok: the minClientVersion grammar MUST was asserted above; refusing a
52
+ // below-floor client is a MAY and this host served it, so the 426 shape binds nothing.
53
53
  if (res.status !== 426) return softSkip('inapplicable', `the host advertises minClientVersion ${String(floor)} but served a client announcing 0.0.1 (${res.status}) — refusal is a MAY; nothing further is observable`);
54
54
  expect(readErrorCode(res.json), req('openwop.requirement.0172.min-client-version', DOC, 'a 426 refusal MUST carry client_version_unsupported')).toBe('client_version_unsupported');
55
55
  const r = v2Validator('error-envelope')(res.json);
56
56
  expect(r.ok, req('openwop.requirement.0172.min-client-version', 'spec/v2/core/errors.md §The envelope', `the refusal MUST be the closed error envelope (${r.errors})`)).toBe(true);
57
+ // RFC 0219 Q5: details.minClientVersion is OPTIONAL; when a host sends it, it names the floor
58
+ // (errors.json registers its grammar, which the envelope validator above already checked).
59
+ const details = (res.json as { details?: { minClientVersion?: unknown } } | null)?.details;
60
+ if (details && details.minClientVersion !== undefined) {
61
+ expect(details.minClientVersion, req('openwop.requirement.0172.min-client-version', DOC, `a refusal's details.minClientVersion MUST name the advertised floor ${String(floor)}`)).toBe(floor);
62
+ }
57
63
  });
58
64
  });
@@ -8,7 +8,8 @@
8
8
  * served `preferredVersion`'s major, so a header-less `GET /.well-known/openwop`
9
9
  * is the `preferredVersion` representation and its `OpenWOP-Version` response
10
10
  * header names that major. The header-less fetch bypasses the driver (which
11
- * stamps `OpenWOP-Version: 2.0` under target major 2).
11
+ * stamps `OpenWOP-Version: 2.0` under target major 2). Any OTHER unversioned path
12
+ * is the v2 surface (§1.2), so a header-less request there is served major 2.
12
13
  */
13
14
 
14
15
  import { describe, it, expect } from 'vitest';
@@ -67,6 +68,23 @@ describe('v2 preferred-version-default (RFC 0172 §A.1, §A.3)', () => {
67
68
  expect(Array.isArray(body?.['protocolVersions']) && typeof body?.['preferredVersion'] === 'string', req('openwop.requirement.0172.preferred-version-default.headerless', 'spec/v2/core/capabilities.md §1', 'the v1 representation MUST carry protocolVersions[] and preferredVersion additively so a single fetch names the other major')).toBe(true);
68
69
  }
69
70
  });
71
+ it('a header-less request on any other unversioned path is served major 2', async () => {
72
+ const doc = await discovery();
73
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
74
+ // versioning.md §1.2/§1.3: an unversioned operation path is the v2 surface, so the
75
+ // header-less default (preferredVersion) applies to /.well-known/openwop only. Any
76
+ // answer carries OpenWOP-Version (§1.4), so an unknown run id is enough to read it.
77
+ const path = `/runs/openwop-conformance~2F${'h'.repeat(22)}`;
78
+ let version: string | null = null;
79
+ try {
80
+ const res = await fetch(`${loadEnv().baseUrl}${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${loadEnv().apiKey}` } });
81
+ await res.text();
82
+ version = res.headers.get('openwop-version');
83
+ } catch {
84
+ return softSkip('blocked', `header-less GET ${path} unreachable (fetch failed)`);
85
+ }
86
+ expect(version?.trim().split('.')[0], req('openwop.requirement.0172.preferred-version-default.unversioned-is-v2', 'spec/v2/core/versioning.md §1.2, §1.3', `a header-less request on an unversioned operation path MUST be served major 2 and say so in OpenWOP-Version — the preferredVersion default is for /.well-known/openwop only (got ${JSON.stringify(version)})`)).toBe('2');
87
+ });
70
88
  it('through the overlap preferredVersion names a 1.x member', async () => {
71
89
  const doc = await discovery();
72
90
  if (!doc) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
@@ -38,12 +38,14 @@ describe('RFC 0175 §D.4 — refresh-sla (unaided; gated on a2a or mcp)', () =>
38
38
  { family: 'a2a', offered: 'versions', preferred: 'preferredVersion', floor: 'minimumVersion' },
39
39
  { family: 'mcp', offered: 'revisions', preferred: 'preferredVersion', floor: 'minimumRevision' },
40
40
  ];
41
- let checked = 0;
42
- const now = Date.now();
41
+ const advertised: Array<{ f: (typeof facets)[number]; rec: Record<string, unknown> }> = [];
43
42
  for (const f of facets) {
44
43
  const rec = await familyAdvertised(f.family);
45
- if (!rec) continue;
46
- checked++;
44
+ if (rec) advertised.push({ f, rec });
45
+ }
46
+ if (advertised.length === 0) return softSkip('inapplicable', 'host advertises neither a2a nor mcp — no refreshedAt to check');
47
+ const now = Date.now();
48
+ for (const { f, rec } of advertised) {
47
49
  const refreshedAt = rec['refreshedAt'];
48
50
  expect(
49
51
  typeof refreshedAt === 'string' && DATE.test(refreshedAt),
@@ -73,6 +75,5 @@ describe('RFC 0175 §D.4 — refresh-sla (unaided; gated on a2a or mcp)', () =>
73
75
  req('openwop.requirement.0175.refresh-sla', 'interop.md §The facets', `${f.family}.${f.floor} MUST be one of the offered ${f.offered}[] — a floor below every offer refuses nothing`),
74
76
  ).toContain(rec[f.floor]);
75
77
  }
76
- if (checked === 0) return softSkip('inapplicable', 'host advertises neither a2a nor mcp — no refreshedAt to check');
77
78
  });
78
79
  });
@@ -10,9 +10,9 @@
10
10
  * the caller is authorized to read the run: for a run the caller cannot read
11
11
  * the answer MUST be the one the host gives without the header.
12
12
  *
13
- * Off the core-standard floor: `v2-sse-last-event-id` is the floor scenario and
14
- * this file adds legs a committed claim was never measured against (rc.59
15
- * precedent). Promotion waits until the three bundle hosts are measured.
13
+ * On the core-standard floor since 2.42.7, beside `v2-sse-last-event-id`. It was
14
+ * held off until the three bundle hosts were measured (rc.59 precedent); all
15
+ * three recorded it executed-pass, two with partial-witness on the SHOULD leg only.
16
16
  *
17
17
  * Legs, on one completed noop run:
18
18
  * 1. a future id (last + 1000) ⇒ 200, zero frames, closed by the server — a
@@ -92,6 +92,8 @@ describe('v2 sse-last-event-id (events.md §SSE frames)', () => {
92
92
  // values: resumption MUST emit a state.snapshot first — only where the host serves values.
93
93
  const probe = await http(() => driver.get(`/runs/${enc(c.runId)}/events?streamMode=bogus`, { headers: { Accept: 'text/event-stream' } }));
94
94
  const supported = (probe?.json as { details?: { supported?: unknown } } | null)?.details?.supported;
95
+ // partial-witness-ok: every Last-Event-ID MUST was asserted above on the debug stream; the
96
+ // state.snapshot-first rule binds only a host serving values mode.
95
97
  if (!Array.isArray(supported) || !supported.includes('values')) return softSkip('inapplicable', `values mode not served (details.supported: ${JSON.stringify(supported)}) — the state.snapshot-first resumption leg does not apply`);
96
98
  const values = await subscribe(`/runs/${enc(c.runId)}/events?streamMode=values`, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(first) });
97
99
  expect(values.status, req(ID, DOC, `a values resume MUST answer 200 — got ${values.status}`)).toBe(200);