@openwop/openwop-conformance 1.106.1 → 1.124.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 (166) hide show
  1. package/README.md +3 -3
  2. package/coverage.md +6 -0
  3. package/dist/cli.js +89 -45
  4. package/dist/lib/certification-bundle-verify.js +274 -0
  5. package/dist/lib/profiles.js +102 -19
  6. package/dist/lib/requirement-ledger.js +182 -0
  7. package/dist/lib/requirement-registry.js +110 -0
  8. package/dist/lib/scenario-disposition.js +249 -0
  9. package/dist/lib/soft-skip.js +82 -0
  10. package/fixtures/conformance-a2a-task-roundtrip.json +1 -1
  11. package/fixtures.md +1 -1
  12. package/package.json +1 -1
  13. package/schemas/CORPUS-STAMP.json +2 -2
  14. package/schemas/README.md +1 -0
  15. package/schemas/capabilities.schema.json +2 -2
  16. package/schemas/compensation-policy.schema.json +64 -0
  17. package/schemas/run-snapshot.schema.json +5 -0
  18. package/schemas/workflow-chain-pack-manifest.schema.json +125 -0
  19. package/schemas/workflow-definition.schema.json +4 -0
  20. package/src/cli.ts +99 -45
  21. package/src/lib/a2a-fake-peer.ts +290 -110
  22. package/src/lib/certification-bundle-verify.ts +356 -0
  23. package/src/lib/error-envelope.ts +77 -0
  24. package/src/lib/llm-cache-key-recipe.ts +6 -5
  25. package/src/lib/mcp-fake-server.ts +235 -72
  26. package/src/lib/profiles.ts +132 -19
  27. package/src/lib/requirement-ledger.ts +98 -1
  28. package/src/lib/requirement-registry.ts +47 -4
  29. package/src/lib/scenario-disposition.ts +291 -0
  30. package/src/lib/soft-skip.ts +89 -0
  31. package/src/lib/workflow-chain-expansion.ts +189 -0
  32. package/src/scenarios/a2a-1-0-agent-card.test.ts +179 -0
  33. package/src/scenarios/a2a-1-0-task-roundtrip.test.ts +109 -0
  34. package/src/scenarios/a2a-card-runtime-consistency.test.ts +167 -0
  35. package/src/scenarios/a2a-peer-authority.test.ts +99 -0
  36. package/src/scenarios/a2a-task-roundtrip.test.ts +18 -6
  37. package/src/scenarios/agent-loop-iteration-monotonic.test.ts +3 -2
  38. package/src/scenarios/agent-loop-stateful-resume.test.ts +3 -2
  39. package/src/scenarios/agent-loop-version5-shape.test.ts +2 -1
  40. package/src/scenarios/agent-loop-workspace-snapshot.test.ts +4 -3
  41. package/src/scenarios/approval-gate-flow.test.ts +5 -4
  42. package/src/scenarios/artifact-type-store-without-render.test.ts +2 -1
  43. package/src/scenarios/auth-scim-profile.test.ts +5 -4
  44. package/src/scenarios/authorization-fail-closed.test.ts +4 -3
  45. package/src/scenarios/certification-bundle-non-vacuous.test.ts +249 -0
  46. package/src/scenarios/certification-bundle-redaction.test.ts +182 -0
  47. package/src/scenarios/certification-floor-enforcement.test.ts +30 -6
  48. package/src/scenarios/chain-compensation-expansion.test.ts +243 -0
  49. package/src/scenarios/commitment-fired.test.ts +3 -2
  50. package/src/scenarios/compensation-behavior.test.ts +63 -0
  51. package/src/scenarios/compensation-profile.test.ts +144 -1
  52. package/src/scenarios/conformance-advertised-seam-required.test.ts +118 -0
  53. package/src/scenarios/conformance-execution-witness.test.ts +104 -0
  54. package/src/scenarios/conversationCapabilityNegotiation.test.ts +3 -2
  55. package/src/scenarios/core-manifest-and-extension-registry.test.ts +26 -0
  56. package/src/scenarios/credential-payload-redaction.test.ts +4 -3
  57. package/src/scenarios/credentials-capability-shape.test.ts +4 -3
  58. package/src/scenarios/cross-engine-append-behavior.test.ts +5 -4
  59. package/src/scenarios/cross-engine-append-ordering.test.ts +3 -2
  60. package/src/scenarios/cross-host-ancestry-endpoint.test.ts +5 -4
  61. package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
  62. package/src/scenarios/cross-workspace-isolation.test.ts +4 -3
  63. package/src/scenarios/data-residency-admission.test.ts +6 -6
  64. package/src/scenarios/deadletter-retry-exhaustion.test.ts +5 -4
  65. package/src/scenarios/distillation-index-roundtrip.test.ts +3 -2
  66. package/src/scenarios/distillation-secret-carryforward.test.ts +3 -2
  67. package/src/scenarios/distillation-shape.test.ts +2 -1
  68. package/src/scenarios/distillation-stable-archive.test.ts +4 -3
  69. package/src/scenarios/distillation-token-budget.test.ts +4 -3
  70. package/src/scenarios/envelope-recovery-applied.test.ts +3 -2
  71. package/src/scenarios/envelope-refusal-shape.test.ts +4 -3
  72. package/src/scenarios/error-envelope-canonical-shape.test.ts +64 -0
  73. package/src/scenarios/feedback-correction-redaction.test.ts +4 -3
  74. package/src/scenarios/feedback-cross-tenant-isolation.test.ts +4 -3
  75. package/src/scenarios/feedback-fork-not-copied.test.ts +6 -5
  76. package/src/scenarios/feedback-on-terminal-run.test.ts +4 -3
  77. package/src/scenarios/feedback-record-and-list.test.ts +4 -3
  78. package/src/scenarios/feedback-unsupported-501.test.ts +3 -2
  79. package/src/scenarios/fixtures-valid.test.ts +16 -18
  80. package/src/scenarios/fs-path-traversal.test.ts +3 -2
  81. package/src/scenarios/heartbeat-capability-shape.test.ts +2 -1
  82. package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +4 -3
  83. package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +6 -5
  84. package/src/scenarios/heartbeat-runtime-bound.test.ts +4 -3
  85. package/src/scenarios/mcp-2026-07-28-discover.test.ts +207 -0
  86. package/src/scenarios/mcp-cache-tenant-scope.test.ts +66 -0
  87. package/src/scenarios/mcp-current-auth-boundary.test.ts +55 -0
  88. package/src/scenarios/mcp-extension-opacity.test.ts +79 -0
  89. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +140 -0
  90. package/src/scenarios/mcp-stateless-request.test.ts +77 -0
  91. package/src/scenarios/mcp-tool-roundtrip.test.ts +4 -3
  92. package/src/scenarios/mcp-toolcall-redaction.test.ts +2 -1
  93. package/src/scenarios/memory-attribution-emits-on-write.test.ts +6 -5
  94. package/src/scenarios/memory-attribution-no-content.test.ts +4 -3
  95. package/src/scenarios/memory-attribution-replay-stable.test.ts +6 -5
  96. package/src/scenarios/memory-attribution-tenant-scoped.test.ts +4 -3
  97. package/src/scenarios/memory-compaction-event-emitted.test.ts +3 -2
  98. package/src/scenarios/memory-compaction-provenance-tag.test.ts +6 -5
  99. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +4 -3
  100. package/src/scenarios/memory-consolidation-idempotent.test.ts +4 -3
  101. package/src/scenarios/memory-injection-budget.test.ts +4 -3
  102. package/src/scenarios/metric-emission.test.ts +4 -3
  103. package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
  104. package/src/scenarios/multi-agent-handoff-state-machine.test.ts +4 -3
  105. package/src/scenarios/multi-agent-memory-lifecycle.test.ts +5 -4
  106. package/src/scenarios/multi-party-conversation-behavioral.test.ts +3 -2
  107. package/src/scenarios/multi-region-idempotency-behavior.test.ts +7 -6
  108. package/src/scenarios/multi-region-idempotency.test.ts +4 -3
  109. package/src/scenarios/normative-example-extraction.test.ts +242 -0
  110. package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +4 -3
  111. package/src/scenarios/oauth-capability-shape.test.ts +4 -3
  112. package/src/scenarios/oauth-connector-redaction.test.ts +4 -3
  113. package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +309 -0
  114. package/src/scenarios/org-position-no-authority-escalation.test.ts +2 -1
  115. package/src/scenarios/otel-emission-grpc.test.ts +4 -3
  116. package/src/scenarios/otel-emission.test.ts +4 -3
  117. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +5 -4
  118. package/src/scenarios/otel-trace-propagation.test.ts +4 -3
  119. package/src/scenarios/pack-registry-isolation.test.ts +4 -3
  120. package/src/scenarios/pack-registry-publish.test.ts +65 -3
  121. package/src/scenarios/pack-registry.test.ts +22 -0
  122. package/src/scenarios/profile-discovery-core-alias.test.ts +137 -0
  123. package/src/scenarios/profileDerivation.test.ts +2 -1
  124. package/src/scenarios/provider-usage.test.ts +2 -2
  125. package/src/scenarios/rate-limit-envelope.test.ts +2 -1
  126. package/src/scenarios/registry-public.test.ts +6 -5
  127. package/src/scenarios/replay-divergence-at-refusal.test.ts +9 -8
  128. package/src/scenarios/replay-llm-cache-key-portable.test.ts +19 -13
  129. package/src/scenarios/replay-llm-cache-key.test.ts +120 -47
  130. package/src/scenarios/replay-observable-sequence-determinism.test.ts +5 -4
  131. package/src/scenarios/replay-side-effect-suppression.test.ts +20 -1
  132. package/src/scenarios/requirement-ledger.test.ts +29 -6
  133. package/src/scenarios/runner-ledger.test.ts +279 -0
  134. package/src/scenarios/runtime-requires-install-gate.test.ts +6 -5
  135. package/src/scenarios/sandbox-memory-cap.test.ts +3 -2
  136. package/src/scenarios/sandbox-mvp-behavior.test.ts +11 -10
  137. package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
  138. package/src/scenarios/sandbox-timeout-cap.test.ts +3 -2
  139. package/src/scenarios/stream-text-fixture.test.ts +5 -4
  140. package/src/scenarios/subrun-approval-fail-closed.test.ts +3 -2
  141. package/src/scenarios/subrun-approval-gate.test.ts +4 -3
  142. package/src/scenarios/subrun-attestation-shape.test.ts +2 -1
  143. package/src/scenarios/subrun-checksum-stable.test.ts +4 -3
  144. package/src/scenarios/table-schema-enforcement.test.ts +2 -2
  145. package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +3 -2
  146. package/src/scenarios/tool-hooks-content-free.test.ts +3 -2
  147. package/src/scenarios/tool-hooks-rate-limit.test.ts +3 -2
  148. package/src/scenarios/tool-hooks-secret-redaction.test.ts +3 -2
  149. package/src/scenarios/tool-hooks-shape.test.ts +2 -1
  150. package/src/scenarios/trigger-bridge-delivery.test.ts +3 -2
  151. package/src/scenarios/version-fold.test.ts +5 -4
  152. package/src/scenarios/voice-streamref-tenant-bound.test.ts +2 -1
  153. package/src/scenarios/voice-transcription-streaming.test.ts +2 -1
  154. package/src/scenarios/voice-transcription-unadvertised.test.ts +2 -1
  155. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +5 -4
  156. package/src/scenarios/wasm-pack-invoke-completed.test.ts +3 -2
  157. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +3 -2
  158. package/src/scenarios/wasm-pack-load.test.ts +4 -3
  159. package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -3
  160. package/src/scenarios/wasm-pack-replay-determinism.test.ts +3 -2
  161. package/src/scenarios/webhook-signed-delivery.test.ts +4 -3
  162. package/src/scenarios/workflow-primary-output-annotation.test.ts +13 -23
  163. package/src/scenarios/workload-identity-behavior.test.ts +7 -4
  164. package/src/scenarios/workspace-cross-tenant-isolation-blackbox.test.ts +3 -2
  165. package/src/scenarios/workspace-cross-tenant-isolation.test.ts +3 -2
  166. package/src/setup.ts +95 -1
@@ -0,0 +1,309 @@
1
+ /**
2
+ * RFC 0149 §A / §Conformance — `openapi-asyncapi-sdk-parity`.
3
+ *
4
+ * "SDK operation URLs and AsyncAPI bindings MUST match the resolved OpenAPI
5
+ * path" (§A), and "SDK repositories consume a generated canonical
6
+ * operation-path manifest" (§Conformance). `openapi-resolved-paths.test.ts`
7
+ * landed the OpenAPI half (exactly one `/v1` per resolved operation); this
8
+ * scenario lands the AsyncAPI half and the manifest, and witnesses the SDK
9
+ * half when the SDK repository is reachable.
10
+ *
11
+ * Legs:
12
+ * 1. `spec/v1/operation-path-manifest.json` — the canonical resolved
13
+ * operation-path manifest — is in sync with `api/openapi.yaml`: this file
14
+ * re-derives the rows from the contract with its own scanner and compares,
15
+ * so the generator (`scripts/generate-operation-path-manifest.mjs`) and
16
+ * the suite cannot silently disagree; every row carries exactly one `/v1`
17
+ * (none for `/.well-known/*`); the digest matches the body.
18
+ * 2. AsyncAPI bindings: every channel `address` (resolved under
19
+ * `servers.*.pathname`) is an OpenAPI path key with a GET operation, with
20
+ * exactly one `/v1`; the AsyncAPI server pathname carries the version
21
+ * segment exactly once. The AsyncAPI document names the same wire
22
+ * the OpenAPI document does — no second contract for the event stream.
23
+ * 3. SDK parity (`openwop-sdks`): the SDK repository's
24
+ * `sdk/parity-expectations.json` covers every CANONICAL manifest
25
+ * operation with the same method + resolved path, declares no operation
26
+ * the manifest lacks, and the TypeScript client issues every path an
27
+ * operation declared `ts: typed` names. Reached through
28
+ * `OPENWOP_SDKS_DIR` or the sibling checkout `../openwop-sdks`. When the
29
+ * SDK tree is NOT reachable the requirement is recorded `blocked` with the
30
+ * reason — RFC 0148 §A: an unwitnessed requirement is blocked, never a
31
+ * pass — and the leg returns without asserting.
32
+ *
33
+ * Server-free (reads files only). Legs 1–2 always-on.
34
+ */
35
+
36
+ import { describe, it, expect } from 'vitest';
37
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
38
+ import { createHash } from 'node:crypto';
39
+ import { join, resolve } from 'node:path';
40
+ import { API_DIR, V1_DIR } from '../lib/paths.js';
41
+ import { recordRequirement } from '../lib/requirement-ledger.js';
42
+
43
+ export const HOST_CALLBACK_NOT_REQUIRED = 'server-free: compares contract documents and (when reachable) SDK sources on disk; no host is contacted';
44
+
45
+ /** RFC 0148 §A requirement id for the SDK half, recorded blocked when unwitnessable. */
46
+ export const SDK_PATH_PARITY_REQUIREMENT = 'openwop.requirement.rfc0149.sdk-path-parity';
47
+
48
+ const OPENAPI = readFileSync(join(API_DIR, 'openapi.yaml'), 'utf8');
49
+ const ASYNCAPI = readFileSync(join(API_DIR, 'asyncapi.yaml'), 'utf8');
50
+ const MANIFEST_PATH = V1_DIR === null ? null : join(V1_DIR, 'operation-path-manifest.json');
51
+
52
+ const VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
53
+
54
+ interface Op { operationId: string | null; method: string; pathKey: string; tag: string | null }
55
+ interface Row { operationId: string; method: string; pathKey: string; resolvedPath: string; tag: string; class: string }
56
+
57
+ function serverUrls(raw: string): string[] {
58
+ const urls: string[] = [];
59
+ let inServers = false;
60
+ for (const line of raw.split('\n')) {
61
+ if (/^servers:/.test(line)) { inServers = true; continue; }
62
+ if (inServers && /^[^\s#]/.test(line)) break;
63
+ if (!inServers) continue;
64
+ const m = /^\s*-\s*url:\s*(\S+)\s*$/.exec(line);
65
+ if (m?.[1] !== undefined) urls.push(m[1]);
66
+ }
67
+ return urls;
68
+ }
69
+ function resolvedPath(serverUrl: string, pathKey: string): string {
70
+ const afterScheme = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
71
+ const slash = afterScheme.indexOf('/');
72
+ const basePath = slash === -1 ? '' : afterScheme.slice(slash);
73
+ return `${basePath.replace(/\/$/, '')}${pathKey}`;
74
+ }
75
+ function operations(raw: string): Op[] {
76
+ const rows: Op[] = [];
77
+ let inPaths = false;
78
+ let pathKey: string | null = null;
79
+ let op: Op | null = null;
80
+ for (const line of raw.split('\n')) {
81
+ if (/^paths:/.test(line)) { inPaths = true; continue; }
82
+ if (inPaths && /^[^\s#]/.test(line)) break;
83
+ if (!inPaths) continue;
84
+ const p = /^ {2}(\/\S*):\s*$/.exec(line);
85
+ if (p?.[1] !== undefined) { pathKey = p[1]; op = null; continue; }
86
+ const v = /^ {4}([a-z]+):\s*$/.exec(line);
87
+ if (v?.[1] !== undefined && VERBS.has(v[1]) && pathKey !== null) {
88
+ op = { operationId: null, method: v[1].toUpperCase(), pathKey, tag: null };
89
+ rows.push(op);
90
+ continue;
91
+ }
92
+ if (op === null) continue;
93
+ const id = /^ {6}operationId:\s*([A-Za-z0-9_]+)\s*$/.exec(line);
94
+ if (id?.[1] !== undefined) { op.operationId = id[1]; continue; }
95
+ const t = /^ {6}tags:\s*\[([^\]]*)\]\s*$/.exec(line);
96
+ if (t?.[1] !== undefined) { op.tag = (t[1].split(',')[0] ?? '').trim(); continue; }
97
+ }
98
+ return rows;
99
+ }
100
+ function classify(tag: string | null): string {
101
+ if (tag === 'host') return 'host-extension';
102
+ if (tag === 'packs-test') return 'test-catalog';
103
+ return 'canonical';
104
+ }
105
+ function deriveRows(): { serverUrl: string; rows: Row[] } {
106
+ const servers = serverUrls(OPENAPI);
107
+ const base = servers[0] ?? '';
108
+ const rows: Row[] = operations(OPENAPI).map((o) => ({
109
+ operationId: o.operationId ?? '',
110
+ method: o.method,
111
+ pathKey: o.pathKey,
112
+ resolvedPath: resolvedPath(base, o.pathKey),
113
+ tag: o.tag ?? '',
114
+ class: classify(o.tag),
115
+ }));
116
+ rows.sort((a, b) => a.resolvedPath.localeCompare(b.resolvedPath) || a.method.localeCompare(b.method));
117
+ return { serverUrl: base, rows };
118
+ }
119
+ function v1Segments(p: string): number {
120
+ return (p.match(/\/v1(?=\/|$)/g) ?? []).length;
121
+ }
122
+
123
+ /* ─── AsyncAPI scanning ─────────────────────────────────────────────────── */
124
+
125
+ function asyncapiServerPathnames(raw: string): string[] {
126
+ const out: string[] = [];
127
+ let inServers = false;
128
+ for (const line of raw.split('\n')) {
129
+ if (/^servers:/.test(line)) { inServers = true; continue; }
130
+ if (inServers && /^[^\s#]/.test(line)) break;
131
+ if (!inServers) continue;
132
+ const m = /^\s+pathname:\s*(\S+)\s*$/.exec(line);
133
+ if (m?.[1] !== undefined) out.push(m[1]);
134
+ }
135
+ return out;
136
+ }
137
+ /** Channel `address:` values (a `null` address is a logical channel and is skipped). */
138
+ function asyncapiChannelAddresses(raw: string): { channel: string; address: string | null }[] {
139
+ const out: { channel: string; address: string | null }[] = [];
140
+ let inChannels = false;
141
+ let channel: string | null = null;
142
+ for (const line of raw.split('\n')) {
143
+ if (/^channels:/.test(line)) { inChannels = true; continue; }
144
+ if (inChannels && /^[^\s#]/.test(line)) break;
145
+ if (!inChannels) continue;
146
+ const c = /^ {2}([A-Za-z][\w.-]*):\s*$/.exec(line);
147
+ if (c?.[1] !== undefined) { channel = c[1]; continue; }
148
+ const a = /^ {4}address:\s*(\S+)\s*$/.exec(line);
149
+ if (a?.[1] !== undefined && channel !== null) out.push({ channel, address: a[1] === 'null' ? null : a[1] });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /* ─── SDK repository probing ────────────────────────────────────────────── */
155
+
156
+ function sdksDir(): string | null {
157
+ const env = process.env['OPENWOP_SDKS_DIR'];
158
+ if (env !== undefined && env !== '' && existsSync(join(env, 'sdk', 'parity-expectations.json'))) return resolve(env);
159
+ if (V1_DIR === null) return null;
160
+ const sibling = resolve(V1_DIR, '..', '..', '..', 'openwop-sdks');
161
+ return existsSync(join(sibling, 'sdk', 'parity-expectations.json')) ? sibling : null;
162
+ }
163
+ function collectTs(dir: string): string {
164
+ let out = '';
165
+ for (const name of readdirSync(dir)) {
166
+ const p = join(dir, name);
167
+ if (statSync(p).isDirectory()) { if (!/node_modules|__tests__/.test(name)) out += collectTs(p); continue; }
168
+ if (name.endsWith('.ts') && !/\.test\./.test(name)) out += readFileSync(p, 'utf8') + '\n';
169
+ }
170
+ return out;
171
+ }
172
+ /**
173
+ * The most distinctive STATIC fragment of a templated path: everything up to
174
+ * the first `{param}` (an SDK builds the rest with `encodeURIComponent`).
175
+ * `/v1/runs/{runId}:fork` → `/v1/runs/` … plus the tail `:fork` checked
176
+ * separately when the template ends in a static suffix.
177
+ */
178
+ function staticFragments(pathTpl: string): string[] {
179
+ const head = pathTpl.split('{')[0] ?? pathTpl;
180
+ const tail = /\}([^{}]*)$/.exec(pathTpl)?.[1] ?? '';
181
+ const frags = [head];
182
+ if (tail !== '' && tail !== '/') frags.push(tail);
183
+ return frags;
184
+ }
185
+
186
+ describe('RFC 0149 §A/§Conformance — the canonical operation-path manifest is in sync with the contract', () => {
187
+ it('spec/v1/operation-path-manifest.json exists in the corpus layout and re-derives byte-for-byte from api/openapi.yaml', () => {
188
+ if (MANIFEST_PATH === null) return; // published tarball: prose dir absent (see leg below)
189
+ expect(existsSync(MANIFEST_PATH), 'RFC 0149 §Conformance: the manifest MUST be published (run scripts/generate-operation-path-manifest.mjs --write)').toBe(true);
190
+ const doc = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as { serverUrl: string; operations: Row[]; digest: string; counts: Record<string, number> } & Record<string, unknown>;
191
+ const { serverUrl, rows } = deriveRows();
192
+ expect(doc.serverUrl).toBe(serverUrl);
193
+ expect(doc.operations).toEqual(rows);
194
+ expect(doc.counts['total']).toBe(rows.length);
195
+ const { digest: _d, ...body } = doc;
196
+ expect(doc.digest).toBe(createHash('sha256').update(JSON.stringify(body), 'utf8').digest('hex'));
197
+ });
198
+
199
+ it('every operation resolves to exactly one /v1 segment (none for /.well-known/*), has an operationId, and ids are unique', () => {
200
+ const { rows } = deriveRows();
201
+ expect(rows.length).toBeGreaterThan(40);
202
+ const ids = new Set<string>();
203
+ for (const r of rows) {
204
+ expect(r.operationId, `${r.method} ${r.pathKey} has no operationId`).not.toBe('');
205
+ expect(ids.has(r.operationId), `duplicate operationId ${r.operationId}`).toBe(false);
206
+ ids.add(r.operationId);
207
+ const n = v1Segments(r.resolvedPath);
208
+ if (r.pathKey.startsWith('/.well-known/')) expect(n, `${r.resolvedPath} MUST be unversioned`).toBe(0);
209
+ else expect(n, `${r.operationId} resolves to ${r.resolvedPath}`).toBe(1);
210
+ }
211
+ // the classes are exhaustive and the canonical set is the large majority
212
+ expect(rows.every((r) => ['canonical', 'host-extension', 'test-catalog'].includes(r.class))).toBe(true);
213
+ expect(rows.filter((r) => r.class === 'canonical').length).toBeGreaterThan(rows.length / 2);
214
+ });
215
+ });
216
+
217
+ describe('RFC 0149 §A — AsyncAPI bindings name the same wire the OpenAPI document does', () => {
218
+ it('the AsyncAPI server pathname carries the version segment exactly once', () => {
219
+ const pathnames = asyncapiServerPathnames(ASYNCAPI);
220
+ expect(pathnames.length).toBeGreaterThan(0);
221
+ for (const p of pathnames) expect(v1Segments(p), `asyncapi servers pathname ${p}`).toBe(1);
222
+ });
223
+
224
+ it('every addressed channel resolves (server pathname + address) to an OpenAPI path key that has a GET operation, with exactly one /v1', () => {
225
+ const pathnames = asyncapiServerPathnames(ASYNCAPI);
226
+ const channels = asyncapiChannelAddresses(ASYNCAPI);
227
+ const addressed = channels.filter((c) => c.address !== null);
228
+ expect(addressed.length, 'at least one channel is bound to a wire address').toBeGreaterThan(0);
229
+ const { rows } = deriveRows();
230
+ const getPaths = new Set(rows.filter((r) => r.method === 'GET').map((r) => r.pathKey));
231
+ for (const base of pathnames) {
232
+ for (const c of addressed) {
233
+ const resolved = `${base.replace(/\/$/, '')}${c.address as string}`;
234
+ expect(v1Segments(resolved), `channel ${c.channel} resolves to ${resolved}`).toBe(1);
235
+ expect(getPaths.has(resolved), `channel ${c.channel} → ${resolved} is not a GET path in api/openapi.yaml`).toBe(true);
236
+ }
237
+ }
238
+ });
239
+
240
+ it('logical (address: null) channels are the only ones without a wire address, and they are documented as logical', () => {
241
+ const channels = asyncapiChannelAddresses(ASYNCAPI);
242
+ const logical = channels.filter((c) => c.address === null).map((c) => c.channel);
243
+ // heartbeat is the known logical channel (host-capabilities.md §host.heartbeat)
244
+ for (const name of logical) expect(name, `logical channel ${name} must be documented as such`).toMatch(/heartbeat/i);
245
+ });
246
+ });
247
+
248
+ describe('RFC 0149 §A — SDK operation URLs match the resolved OpenAPI paths (openwop-sdks parity manifest)', () => {
249
+ it('sdk/parity-expectations.json covers every canonical operation with the same method + path, declares nothing the contract lacks, and the TypeScript client issues every ts-typed path', () => {
250
+ const dir = sdksDir();
251
+ if (dir === null) {
252
+ recordRequirement(
253
+ SDK_PATH_PARITY_REQUIREMENT,
254
+ 'blocked',
255
+ 'openwop-sdks is not reachable from this runner (set OPENWOP_SDKS_DIR or check the sibling repository out next to this one); the SDK half of RFC 0149 §A parity is unwitnessed here',
256
+ );
257
+ return;
258
+ }
259
+ const exp = JSON.parse(readFileSync(join(dir, 'sdk', 'parity-expectations.json'), 'utf8')) as {
260
+ operations: { operationId: string; method: string; path: string; ts: string; py: string; go: string; note?: string }[];
261
+ };
262
+ const { rows } = deriveRows();
263
+ const byId = new Map(rows.map((r) => [r.operationId, r] as const));
264
+ const expById = new Map(exp.operations.map((e) => [e.operationId, e] as const));
265
+
266
+ // (a) every canonical operation is declared, with the same method and RESOLVED path
267
+ const missing = rows.filter((r) => r.class === 'canonical' && !expById.has(r.operationId)).map((r) => r.operationId);
268
+ expect(missing, 'canonical operations with no SDK parity declaration').toEqual([]);
269
+ const drift: string[] = [];
270
+ for (const r of rows) {
271
+ const e = expById.get(r.operationId);
272
+ if (e === undefined) continue;
273
+ if (e.method.toUpperCase() !== r.method || e.path !== r.resolvedPath) drift.push(`${r.operationId}: sdk says ${e.method} ${e.path}, contract resolves ${r.method} ${r.resolvedPath}`);
274
+ }
275
+ expect(drift, 'SDK-declared operation URLs MUST match the resolved OpenAPI path (RFC 0149 §A)').toEqual([]);
276
+
277
+ // (b) nothing declared that the contract lacks (orphan drift)
278
+ const orphans = exp.operations.filter((e) => !byId.has(e.operationId)).map((e) => e.operationId);
279
+ expect(orphans, 'SDK parity entries for operationIds the contract no longer has').toEqual([]);
280
+
281
+ // (c) the TypeScript client actually issues every ts-typed path
282
+ const tsSrc = collectTs(join(dir, 'sdk', 'typescript', 'src'));
283
+ const notIssued: string[] = [];
284
+ for (const e of exp.operations) {
285
+ if (e.ts !== 'typed') continue;
286
+ const frags = staticFragments(e.path);
287
+ if (!frags.every((f) => tsSrc.includes(f))) notIssued.push(`${e.operationId} (${e.path})`);
288
+ }
289
+ expect(notIssued, 'operations declared ts-typed whose path the TypeScript client never issues').toEqual([]);
290
+
291
+ // (d) and no HOST-client-issued /v1 literal resolves outside the contract's
292
+ // canonical+host set. Scoped to the OpenwopClient modules (`client.ts`,
293
+ // `sse.ts`): the SDK also carries a pack-REGISTRY client
294
+ // (`registry-helpers.ts` — `/v1/packs/*`, `/v1/index.json`, issued to a
295
+ // registry base) and provider-shaped types (`/v1/chat/completions`, RFC
296
+ // 0108, issued to a provider) that are not host operations.
297
+ const hostClientSrc = ['client.ts', 'sse.ts']
298
+ .map((f) => join(dir, 'sdk', 'typescript', 'src', f))
299
+ .filter((f) => existsSync(f))
300
+ .map((f) => readFileSync(f, 'utf8'))
301
+ .join('\n');
302
+ const issued = [...hostClientSrc.matchAll(/[`'"](\/v1\/[^`'"$?]*)/g)].map((m) => (m[1] ?? '').replace(/\/$/, ''));
303
+ const known = new Set(rows.map((r) => r.pathKey.split('{')[0] ?? r.pathKey));
304
+ const unknown = [...new Set(issued)].filter((p) => ![...known].some((k) => p.startsWith(k.replace(/\/$/, ''))) && !p.startsWith('/v1/host/'));
305
+ expect(unknown, 'the host client issues /v1 paths the contract does not define (outside /v1/host/* extensions)').toEqual([]);
306
+
307
+ recordRequirement(SDK_PATH_PARITY_REQUIREMENT, 'executed-pass', undefined, { assertionCount: 6 });
308
+ });
309
+ });
@@ -31,6 +31,7 @@
31
31
  */
32
32
 
33
33
  import { describe, it, expect } from 'vitest';
34
+ import { seamAbsent } from '../lib/soft-skip.js';
34
35
  import { driver } from '../lib/driver.js';
35
36
  import { behaviorGate } from '../lib/behavior-gate.js';
36
37
  import { readOrgChartCap, getOrgChart, getDepartmentView, AUTHORITY_FIELDS } from '../lib/agentOrgChart.js';
@@ -52,7 +53,7 @@ describe('org-position-no-authority-escalation (RFC 0087 §B, behavioral)', () =
52
53
  if (!behaviorGate('openwop-org-position-no-authority', cap?.supported === true)) return;
53
54
 
54
55
  const chart = await getOrgChart();
55
- if (chart === null) return; // advertised but read not served yet — soft-skip
56
+ if (chart === null) return seamAbsent('host advertises org-position authority but GET /v1/agents/org-chart is not served');
56
57
 
57
58
  for (const m of chart.members ?? []) {
58
59
  expectNoAuthority(m as Record<string, unknown>, 'an org-chart member');
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { describe, it, expect } from 'vitest';
29
+ import { softSkip } from '../lib/soft-skip.js';
29
30
  import { driver } from '../lib/driver.js';
30
31
  import { pollUntilTerminal } from '../lib/polling.js';
31
32
  import { isFixtureAdvertised } from '../lib/fixtures.js';
@@ -61,12 +62,12 @@ describe('otel-emission-grpc: OTLP/gRPC export path', () => {
61
62
  if (!getCollector()) {
62
63
  // eslint-disable-next-line no-console
63
64
  console.warn('[otel-emission-grpc] collector not started; set OPENWOP_OTEL_COLLECTOR=true to run');
64
- return;
65
+ return softSkip('blocked', '[otel-emission-grpc] collector not started; set OPENWOP_OTEL_COLLECTOR=true to run');
65
66
  }
66
67
  if (!isFixtureAdvertised(FIXTURE)) {
67
68
  // eslint-disable-next-line no-console
68
69
  console.warn(`[otel-emission-grpc] fixture ${FIXTURE} not advertised; skipping`);
69
- return;
70
+ return softSkip('inapplicable', '[otel-emission-grpc] fixture … not advertised; skipping');
70
71
  }
71
72
  if (!(await advertisesGrpcExport())) {
72
73
  // eslint-disable-next-line no-console
@@ -74,7 +75,7 @@ describe('otel-emission-grpc: OTLP/gRPC export path', () => {
74
75
  '[otel-emission-grpc] host does not advertise capabilities.observability.otel.exportProtocols including "grpc"; skipping. ' +
75
76
  'Hosts MAY opt into gRPC export by emitting OTLP via the OTLP/gRPC transport and adding `"grpc"` to the array.',
76
77
  );
77
- return;
78
+ return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!(await advertisesGrpcExport())` returned early');
78
79
  }
79
80
 
80
81
  const collector = getCollector()!;
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { describe, it, expect } from 'vitest';
27
+ import { softSkip } from '../lib/soft-skip.js';
27
28
  import { driver } from '../lib/driver.js';
28
29
  import { pollUntilTerminal } from '../lib/polling.js';
29
30
  import { isFixtureAdvertised } from '../lib/fixtures.js';
@@ -54,17 +55,17 @@ describe('otel-emission: required run-level + node-level attributes', () => {
54
55
  if (!getCollector()) {
55
56
  // eslint-disable-next-line no-console
56
57
  console.warn('[otel-emission] collector not started; set OPENWOP_OTEL_COLLECTOR=true to run');
57
- return;
58
+ return softSkip('blocked', '[otel-emission] collector not started; set OPENWOP_OTEL_COLLECTOR=true to run');
58
59
  }
59
60
  if (!isFixtureAdvertised(FIXTURE)) {
60
61
  // eslint-disable-next-line no-console
61
62
  console.warn(`[otel-emission] fixture ${FIXTURE} not advertised; skipping`);
62
- return;
63
+ return softSkip('inapplicable', '[otel-emission] fixture … not advertised; skipping');
63
64
  }
64
65
  if (!(await isObservabilityAdvertised())) {
65
66
  // eslint-disable-next-line no-console
66
67
  console.warn('[otel-emission] host does not advertise capabilities.observability; skipping');
67
- return;
68
+ return softSkip('inapplicable', '[otel-emission] host does not advertise capabilities.observability; skipping');
68
69
  }
69
70
 
70
71
  const collector = getCollector()!;
@@ -34,6 +34,7 @@
34
34
  */
35
35
 
36
36
  import { describe, it, expect } from 'vitest';
37
+ import { softSkip } from '../lib/soft-skip.js';
37
38
  import { driver } from '../lib/driver.js';
38
39
  import { pollUntilTerminal } from '../lib/polling.js';
39
40
  import { isFixtureAdvertised } from '../lib/fixtures.js';
@@ -89,22 +90,22 @@ describe('otel-trace-propagation-subworkflow: traceparent threads parent → chi
89
90
  // capability-opt-out would lie about observability claims.
90
91
  // eslint-disable-next-line no-console
91
92
  console.warn(`[${SCENARIO_ID}] scenario opted out via OPENWOP_OPTED_OUT_SCENARIOS; skipping`);
92
- return;
93
+ return softSkip('blocked', '[…] scenario opted out via OPENWOP_OPTED_OUT_SCENARIOS; skipping (see message)');
93
94
  }
94
95
  if (!getCollector()) {
95
96
  // eslint-disable-next-line no-console
96
97
  console.warn('[otel-trace-propagation-subworkflow] collector not started; skipping');
97
- return;
98
+ return softSkip('blocked', '[otel-trace-propagation-subworkflow] collector not started; skipping');
98
99
  }
99
100
  if (!isFixtureAdvertised(PARENT_FIXTURE)) {
100
101
  // eslint-disable-next-line no-console
101
102
  console.warn(`[otel-trace-propagation-subworkflow] ${PARENT_FIXTURE} not advertised; skipping`);
102
- return;
103
+ return softSkip('inapplicable', '[otel-trace-propagation-subworkflow] … not advertised; skipping');
103
104
  }
104
105
  if (!(await isObservabilityAdvertised())) {
105
106
  // eslint-disable-next-line no-console
106
107
  console.warn('[otel-trace-propagation-subworkflow] capabilities.observability not advertised; skipping');
107
- return;
108
+ return softSkip('inapplicable', '[otel-trace-propagation-subworkflow] capabilities.observability not advertised; skipping');
108
109
  }
109
110
 
110
111
  const collector = getCollector()!;
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { describe, it, expect } from 'vitest';
21
+ import { softSkip } from '../lib/soft-skip.js';
21
22
  import { driver } from '../lib/driver.js';
22
23
  import { pollUntilTerminal } from '../lib/polling.js';
23
24
  import { isFixtureAdvertised } from '../lib/fixtures.js';
@@ -56,15 +57,15 @@ describe('otel-trace-propagation: inbound traceparent threads to emitted spans',
56
57
  if (!getCollector()) {
57
58
  // eslint-disable-next-line no-console
58
59
  console.warn('[otel-trace-propagation] collector not started; skipping');
59
- return;
60
+ return softSkip('blocked', '[otel-trace-propagation] collector not started; skipping');
60
61
  }
61
62
  if (!isFixtureAdvertised(FIXTURE)) {
62
- return;
63
+ return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!isFixtureAdvertised(FIXTURE)` returned early');
63
64
  }
64
65
  if (!(await isObservabilityAdvertised())) {
65
66
  // eslint-disable-next-line no-console
66
67
  console.warn('[otel-trace-propagation] capabilities.observability not advertised; skipping');
67
- return;
68
+ return softSkip('inapplicable', '[otel-trace-propagation] capabilities.observability not advertised; skipping');
68
69
  }
69
70
 
70
71
  const collector = getCollector()!;
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { describe, it, expect } from 'vitest';
23
+ import { softSkip } from '../lib/soft-skip.js';
23
24
  import { driver } from '../lib/driver.js';
24
25
 
25
26
  interface DiscoveryDoc {
@@ -51,8 +52,8 @@ function freshPackName(): string {
51
52
  describe('pack-registry-isolation: test catalog MUST NOT bleed into production (RFC 0025 §C.1)', () => {
52
53
  it('a pack PUT to /v1/packs-test/{name} MUST NOT appear in GET /v1/packs/{name}', async () => {
53
54
  const adv = await getTestModeAdvertisement();
54
- if (!adv || !adv.supported) return; // host doesn't advertise the seam
55
- if (!adv.isolated) return; // host explicitly disclaims the invariant — no contract to assert
55
+ if (!adv || !adv.supported) return softSkip('inapplicable', 'host doesn\'t advertise the seam');
56
+ if (!adv.isolated) return softSkip('blocked', 'host explicitly disclaims the invariant — no contract to assert (!adv.isolated)');
56
57
 
57
58
  const name = freshPackName();
58
59
  const version = '1.0.0';
@@ -71,7 +72,7 @@ describe('pack-registry-isolation: test catalog MUST NOT bleed into production (
71
72
  // If the seam returns 404, the test-mode endpoint isn't actually
72
73
  // wired up despite the advertisement — pack-registry-publish.test.ts
73
74
  // catches that drift in 24 other scenarios; soft-skip here.
74
- if (putRes.status === 404) return;
75
+ if (putRes.status === 404) return softSkip('blocked', 'precondition not met — `putRes.status === 404` returned early (seam, prior step, or fixture unavailable)');
75
76
 
76
77
  // Probe the production namespace. The invariant: a pack written
77
78
  // via /v1/packs-test/* MUST NOT be retrievable via /v1/packs/*.
@@ -22,18 +22,51 @@
22
22
 
23
23
  import { describe, it, expect } from 'vitest';
24
24
  import { driver } from '../lib/driver.js';
25
+ import { recordRequirement } from '../lib/requirement-ledger.js';
26
+ import { requirementIdForScenario } from '../lib/requirement-registry.js';
25
27
 
26
28
  interface DiscoveryDoc {
27
29
  capabilities?: Record<string, unknown>;
30
+ packs?: Record<string, unknown>;
28
31
  }
29
32
 
33
+ /** RFC 0148 §A requirement id this file's floor row is recorded under. */
34
+ const REQUIREMENT_ID = requirementIdForScenario('pack-registry-publish.test.ts');
35
+ let advertisedCache: boolean | null = null;
36
+
37
+ /**
38
+ * Gate on `packs.testMode.supported` — read at the document ROOT (RFC 0073;
39
+ * `capabilities.*` wrapper tolerated for legacy documents). When the seam is
40
+ * NOT advertised the file records its floor row as `inapplicable` with the
41
+ * reason, ONCE, so the ledger says why every leg returned early: RFC 0025 §A
42
+ * makes the test-mode namespace optional, `pack-registry-publish` is the
43
+ * read/write half of `openwop-node-packs`, and a host without it does not hold
44
+ * that profile (it MAY hold the read-only sub-profile) — which is a fact about
45
+ * the host, not a defect, and not a vacuous pass. `--certify` drops a
46
+ * runtime-derived profile the host does not hold from `claimedProfiles`.
47
+ */
30
48
  async function isTestModeAdvertised(): Promise<boolean> {
49
+ if (advertisedCache !== null) return advertisedCache;
31
50
  const res = await driver.get('/.well-known/openwop');
32
51
  const body = res.json as DiscoveryDoc | undefined;
52
+ const rootPacks = body?.packs && typeof body.packs === 'object' ? body.packs : undefined;
33
53
  const top = body?.capabilities as Record<string, unknown> | undefined;
34
- const packs = top && typeof top === 'object' ? (top['packs'] as Record<string, unknown> | undefined) : undefined;
54
+ const wrappedPacks = top && typeof top === 'object' ? (top['packs'] as Record<string, unknown> | undefined) : undefined;
55
+ const packs = rootPacks ?? wrappedPacks;
35
56
  const testMode = packs && typeof packs === 'object' ? (packs['testMode'] as Record<string, unknown> | undefined) : undefined;
36
- return Boolean(testMode && testMode['supported'] === true);
57
+ advertisedCache = Boolean(testMode && testMode['supported'] === true);
58
+ if (!advertisedCache) {
59
+ try {
60
+ recordRequirement(
61
+ REQUIREMENT_ID,
62
+ 'inapplicable',
63
+ 'host does not advertise packs.testMode.supported (RFC 0025 §A optional test-mode namespace) — the publish error catalog is unobservable without it; openwop-node-packs (read/write) is not held by this host',
64
+ );
65
+ } catch {
66
+ /* already recorded by an earlier leg in this worker */
67
+ }
68
+ }
69
+ return advertisedCache;
37
70
  }
38
71
 
39
72
  /** Disposable pack name for an isolated test publish. */
@@ -48,9 +81,38 @@ function freshPackName(scope: string = 'core'): string {
48
81
  * The shape-only error-catalog tests below only need the host's first
49
82
  * validation step (URL pattern, body-presence, etc.) to fire. */
50
83
  async function putTest(name: string, version: string, body: unknown, extraHeaders: Record<string, string> = {}) {
51
- return driver.put(`/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.tgz`, body, {
84
+ const res = await driver.put(`/v1/packs-test/${encodeURIComponent(name)}/-/${encodeURIComponent(version)}.tgz`, body, {
52
85
  headers: { 'Content-Type': 'application/octet-stream', ...extraHeaders },
53
86
  });
87
+ if (res.status === 404) noteSeamMissing();
88
+ return res;
89
+ }
90
+
91
+ /**
92
+ * Advertised but not mounted: `packs.testMode.supported: true` yet
93
+ * `/v1/packs-test/*` answers 404. That is not "inapplicable" — the host said
94
+ * it exposes the seam. Record `blocked` with the reason (RFC 0148 §A) so the
95
+ * early returns below are not vacuous passes; under
96
+ * `OPENWOP_REQUIRE_BEHAVIOR=true` an advertised-missing seam is a failure
97
+ * (RFC 0148 §B), so throw there.
98
+ */
99
+ let seamMissingNoted = false;
100
+ function noteSeamMissing(): void {
101
+ if (seamMissingNoted) return;
102
+ seamMissingNoted = true;
103
+ if (process.env['OPENWOP_REQUIRE_BEHAVIOR'] === 'true') {
104
+ // Strict: the assertion failure below IS the disposition (executed-fail).
105
+ throw new Error('RFC 0148 §B: host advertises packs.testMode.supported but /v1/packs-test/* is not mounted (404) — advertised behavior MUST be present under OPENWOP_REQUIRE_BEHAVIOR=true');
106
+ }
107
+ try {
108
+ recordRequirement(
109
+ REQUIREMENT_ID,
110
+ 'blocked',
111
+ 'host advertises packs.testMode.supported: true but /v1/packs-test/* returned 404 — advertised seam not mounted; publish error catalog unwitnessable',
112
+ );
113
+ } catch {
114
+ /* already recorded */
115
+ }
54
116
  }
55
117
 
56
118
  /** GET signature; soft-skip on 404 (different from "404 signature_not_available"). */
@@ -45,6 +45,11 @@
45
45
 
46
46
  import { describe, it, expect } from 'vitest';
47
47
  import { driver } from '../lib/driver.js';
48
+ import { recordRequirement } from '../lib/requirement-ledger.js';
49
+ import { requirementIdForScenario } from '../lib/requirement-registry.js';
50
+
51
+ /** RFC 0148 §A requirement id this file's floor row is recorded under. */
52
+ const REQUIREMENT_ID = requirementIdForScenario('pack-registry.test.ts');
48
53
 
49
54
  /** A nonsense pack name in the `private.*` scope. Hosts MUST NOT have
50
55
  * shipped a real pack at this name (the namespace is scoped to a
@@ -84,6 +89,23 @@ async function probeRegistry(): Promise<RegistryProbeResult> {
84
89
  // host-specific shape.
85
90
  const looksLikeWop = res.status === 200 || (typeof body === 'object' && body !== null && ('error' in body || 'results' in body));
86
91
  cachedProbe = { registryPresent: looksLikeWop, probeStatus: res.status };
92
+ if (!looksLikeWop) {
93
+ // RFC 0148 §A: say WHY every leg below returns early. RFC 0025 makes the
94
+ // `/v1/packs/*` read surface unconditional-when-shipped with no discovery
95
+ // advert, so "no registry" is only observable by probing; the honest
96
+ // disposition is `inapplicable` with the probe result — the host does not
97
+ // hold `openwop-node-packs` (a runtime-derived profile), which is a fact
98
+ // about the host, not a defect and not a vacuous pass.
99
+ try {
100
+ recordRequirement(
101
+ REQUIREMENT_ID,
102
+ 'inapplicable',
103
+ `host ships no pack registry (GET /v1/packs/-/search?q= → ${res.status}, non-OpenWOP body); RFC 0025 read surface is unadvertised-when-shipped, so absence is a probe result — openwop-node-packs is not held by this host`,
104
+ );
105
+ } catch {
106
+ /* already recorded */
107
+ }
108
+ }
87
109
  return cachedProbe;
88
110
  }
89
111