@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
@@ -16,6 +16,8 @@
16
16
  * That is the whole mechanism. Everything else here is bookkeeping.
17
17
  */
18
18
 
19
+ import { appendFileSync, readFileSync, existsSync } from 'node:fs';
20
+
19
21
  /** RFC 0148 §A. Exactly one of these per requirement, per run. */
20
22
  export type Disposition =
21
23
  /** The assertion executed against the target and passed. */
@@ -54,9 +56,21 @@ export interface LedgerEntry {
54
56
  readonly disposition: Disposition;
55
57
  /** Why — required for every disposition except `executed-pass`. */
56
58
  readonly detail?: string;
59
+ /** How many `expect` assertions actually ran for this requirement (RFC 0148
60
+ * §C `assertionCount`). `executed-pass` with `0` is the vacuous pass the
61
+ * program exists to make visible; the runner treats it as unclassified for
62
+ * a claimed profile's floor. */
63
+ readonly assertionCount?: number;
57
64
  }
58
65
 
59
66
  const ledger = new Map<string, LedgerEntry>();
67
+ /**
68
+ * Append-only journal of every recording in call order (duplicates included).
69
+ * `setup.ts` marks the journal length before a file's tests and reads what was
70
+ * recorded since, so a file's own gate decisions can be found even when the
71
+ * same requirement id was recorded earlier in the worker.
72
+ */
73
+ const journal: LedgerEntry[] = [];
60
74
 
61
75
  /**
62
76
  * Record a requirement's outcome. Recording the same id twice with different
@@ -68,6 +82,7 @@ export function recordRequirement(
68
82
  requirementId: string,
69
83
  disposition: Disposition,
70
84
  detail?: string,
85
+ extras?: { assertionCount?: number },
71
86
  ): void {
72
87
  const prior = ledger.get(requirementId);
73
88
  if (prior !== undefined && prior.disposition !== disposition) {
@@ -82,7 +97,59 @@ export function recordRequirement(
82
97
  'Anything other than executed-pass MUST say why, or the ledger records an outcome nobody can act on.',
83
98
  );
84
99
  }
85
- ledger.set(requirementId, detail === undefined ? { requirementId, disposition } : { requirementId, disposition, detail });
100
+ const entry: LedgerEntry = {
101
+ requirementId,
102
+ disposition,
103
+ ...(detail === undefined ? {} : { detail }),
104
+ ...(extras?.assertionCount === undefined ? {} : { assertionCount: extras.assertionCount }),
105
+ };
106
+ ledger.set(requirementId, entry);
107
+ journal.push(entry);
108
+ // File sink (RFC 0148 acceptance item 2, S6). The in-memory map lives in a
109
+ // vitest worker; the `--certify` runner is a separate process reading a
110
+ // per-file JSON report. When the runner sets OPENWOP_LEDGER_PATH the worker
111
+ // appends every recording as one JSONL line so the runner can build
112
+ // requirement-level dispositions from what scenarios actually recorded —
113
+ // rather than inferring them from per-file pass/fail/skip, which cannot
114
+ // tell `skipped` from `inapplicable` from `blocked`. Best-effort: a sink
115
+ // failure must never turn a real assertion into a crash.
116
+ const sink = process.env['OPENWOP_LEDGER_PATH'];
117
+ if (sink !== undefined && sink !== '' && sink !== suspendedSink) {
118
+ try {
119
+ appendFileSync(sink, JSON.stringify(entry) + '\n');
120
+ } catch {
121
+ /* ignore — the in-memory ledger is still authoritative for this worker */
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Read a ledger JSONL file written through the sink above. Duplicate lines for
128
+ * the same id (a scenario re-recording the same disposition, or several files
129
+ * gating the same profile) collapse to one entry; a CONFLICT (two different
130
+ * dispositions for one id across workers) resolves to the least certifiable
131
+ * — `executed-fail` > `blocked` > `executed-pass` > `skipped` > `inapplicable`
132
+ * — because "one worker said it failed" outranks "another said it passed", and
133
+ * an unresolvable disagreement must never round toward certification.
134
+ * Returns an empty array when the file is absent.
135
+ */
136
+ export function readLedgerFile(path: string): readonly LedgerEntry[] {
137
+ if (!existsSync(path)) return [];
138
+ const rank: Record<Disposition, number> = { 'executed-fail': 0, blocked: 1, 'executed-pass': 2, skipped: 3, inapplicable: 4 };
139
+ const merged = new Map<string, LedgerEntry>();
140
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
141
+ if (line.trim() === '') continue;
142
+ let e: LedgerEntry;
143
+ try {
144
+ e = JSON.parse(line) as LedgerEntry;
145
+ } catch {
146
+ continue;
147
+ }
148
+ if (typeof e.requirementId !== 'string' || !DISPOSITIONS.includes(e.disposition)) continue;
149
+ const prior = merged.get(e.requirementId);
150
+ if (prior === undefined || rank[e.disposition] < rank[prior.disposition]) merged.set(e.requirementId, e);
151
+ }
152
+ return [...merged.values()].sort((a, b) => a.requirementId.localeCompare(b.requirementId));
86
153
  }
87
154
 
88
155
  /**
@@ -110,8 +177,38 @@ export function snapshot(): readonly LedgerEntry[] {
110
177
  }
111
178
 
112
179
  /** Test-support only. Production runs record once and read once. */
180
+ let suspendedSink: string | undefined;
181
+ /**
182
+ * Scenarios that exercise `recordRequirement` itself (the ledger's own unit
183
+ * tests) record REAL requirement ids as fixtures — e.g. the whole core-standard
184
+ * floor as `executed-pass`. With the file sink on, those fixture rows would
185
+ * reach the runner's ledger and, under the reader's conflict rank, out-vote a
186
+ * genuine `inapplicable` recorded by the scenario that owns the requirement.
187
+ * Such a test file calls this in `beforeAll` and the returned restore in
188
+ * `afterAll`: recordings made in between are kept in memory but NOT appended to
189
+ * the sink that was active at the time of the call. A different path set by the
190
+ * test itself (its own scratch sink) is unaffected.
191
+ */
192
+ export function suspendSinkForFixtures(): () => void {
193
+ suspendedSink = process.env['OPENWOP_LEDGER_PATH'];
194
+ return () => {
195
+ suspendedSink = undefined;
196
+ };
197
+ }
198
+
199
+ /** Number of recordings so far in this worker (a mark for `journalSince`). */
200
+ export function journalLength(): number {
201
+ return journal.length;
202
+ }
203
+
204
+ /** Every recording made since `mark` (from `journalLength()`), in call order. */
205
+ export function journalSince(mark: number): readonly LedgerEntry[] {
206
+ return journal.slice(mark);
207
+ }
208
+
113
209
  export function resetLedger(): void {
114
210
  ledger.clear();
211
+ journal.length = 0;
115
212
  }
116
213
 
117
214
  export interface ProfileVerdict {
@@ -42,12 +42,50 @@ export function requirementIdForPrefix(prefix: string): string {
42
42
  * to close. `null` forces the caller to decide between `discoveryOnly` (an empty
43
43
  * floor by design) and unspecified (no floor written yet).
44
44
  */
45
- export function requirementsFor(profile: string): readonly string[] | null {
45
+ /** Read a dot-path (RFC 0073 root families) out of a discovery document. */
46
+ function readPath(doc: Readonly<Record<string, unknown>>, path: string): unknown {
47
+ let cur: unknown = doc;
48
+ for (const seg of path.split('.')) {
49
+ if (cur === null || typeof cur !== 'object') return undefined;
50
+ cur = (cur as Record<string, unknown>)[seg];
51
+ }
52
+ return cur;
53
+ }
54
+
55
+ /**
56
+ * The scenario FILES a profile's floor requires against a given discovery
57
+ * document — the unconditional `required` list plus every `conditional` branch
58
+ * whose `path` array includes its `includes` value. `null` when the floor is
59
+ * conditional and no document was supplied (unevaluable ≠ empty), or when the
60
+ * profile has no floor at all.
61
+ */
62
+ export function floorFilesFor(profile: string, document?: Readonly<Record<string, unknown>>): readonly string[] | null {
63
+ const floor = PROFILE_FLOOR_SCENARIOS[profile];
64
+ if (floor === undefined) return null;
65
+ const files = [...floor.required];
66
+ if (floor.conditional !== undefined && floor.conditional.length > 0) {
67
+ if (document === undefined) return null;
68
+ for (const c of floor.conditional) {
69
+ const arr = readPath(document, c.path);
70
+ if (Array.isArray(arr) && arr.includes(c.includes)) files.push(...c.required);
71
+ }
72
+ }
73
+ return [...new Set(files)];
74
+ }
75
+
76
+ /**
77
+ * Requirement ids for a profile's floor. `document` is needed for a
78
+ * discovery-conditional floor (RFC 0148 §C G7 — `openwop-replay-fork`): without
79
+ * it such a floor is UNEVALUABLE and this returns `null`, never `[]`.
80
+ */
81
+ export function requirementsFor(profile: string, document?: Readonly<Record<string, unknown>>): readonly string[] | null {
46
82
  const floor = PROFILE_FLOOR_SCENARIOS[profile];
47
83
  if (floor === undefined) return null;
48
84
  if (floor.discoveryOnly === true) return [];
85
+ const files = floorFilesFor(profile, document);
86
+ if (files === null) return null;
49
87
  return [
50
- ...floor.required.map(requirementIdForScenario),
88
+ ...files.map(requirementIdForScenario),
51
89
  ...(floor.requiredAnyPrefix ?? []).map(requirementIdForPrefix),
52
90
  ];
53
91
  }
@@ -55,8 +93,13 @@ export function requirementsFor(profile: string): readonly string[] | null {
55
93
  /** Every registered requirement ID across every profile with a runtime floor. */
56
94
  export function allRequirements(): readonly string[] {
57
95
  const ids = new Set<string>();
58
- for (const profile of Object.keys(PROFILE_FLOOR_SCENARIOS)) {
59
- for (const id of requirementsFor(profile) ?? []) ids.add(id);
96
+ for (const [profile, floor] of Object.entries(PROFILE_FLOOR_SCENARIOS)) {
97
+ if (floor.discoveryOnly === true) continue;
98
+ // every branch of a conditional floor is a registered requirement
99
+ for (const f of floor.required) ids.add(requirementIdForScenario(f));
100
+ for (const c of floor.conditional ?? []) for (const f of c.required) ids.add(requirementIdForScenario(f));
101
+ for (const p of floor.requiredAnyPrefix ?? []) ids.add(requirementIdForPrefix(p));
102
+ void profile;
60
103
  }
61
104
  return [...ids].sort();
62
105
  }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * RFC 0148 §A / acceptance item 2 (S6) — file-level requirement recording and
3
+ * the runner's derivation.
4
+ *
5
+ * Two halves that meet through the ledger file sink:
6
+ *
7
+ * 1. **In the vitest worker** (`setup.ts` hooks): every scenario FILE records
8
+ * exactly one disposition for its own requirement id when it finishes —
9
+ * `executed-fail` if any test failed, `executed-pass` if any test passed and
10
+ * none failed, and for a file whose tests ALL skipped: `inapplicable` /
11
+ * `skipped` if a `behaviorGate` inside the file recorded one of those for the
12
+ * profile it gates on (the honest reason the tests did not run), else
13
+ * `blocked` — an all-skipped file with no recorded reason is an unclassified
14
+ * return, and §A resolves it to `blocked`, never to a pass.
15
+ *
16
+ * 2. **In the `--certify` runner** (`deriveRequirementDispositions`): every
17
+ * scenario file's requirement is taken FROM THE LEDGER when present, falling
18
+ * back to the vitest per-file report only for pass/fail (which the ledger
19
+ * would agree with) and to `blocked` (unclassified) otherwise; the floor's
20
+ * prefix requirements (`openwop.floor.any.<prefix>`) are derived from the
21
+ * files that match; and a claimed profile whose floor contains ANY requirement
22
+ * with no ledger entry is flagged `unclassified` so the runner can REJECT the
23
+ * certification rather than round the silence up.
24
+ *
25
+ * Pure functions, no I/O, so `runner-ledger.test.ts` can pin them without a
26
+ * host or a vitest subprocess.
27
+ */
28
+
29
+ import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
30
+ import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
31
+ import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
32
+ import { CERTIFIABLE, type Disposition, type LedgerEntry } from './requirement-ledger.js';
33
+
34
+ /** All scenario basenames that appear in some profile's runtime floor. */
35
+ export function floorScenarioFiles(): ReadonlySet<string> {
36
+ const out = new Set<string>();
37
+ for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) {
38
+ for (const f of floor.required) out.add(f);
39
+ for (const c of floor.conditional ?? []) for (const f of c.required) out.add(f);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** The requirement id a scenario FILE records under: the §A floor id when the
45
+ * file is part of a floor, else the runner's per-scenario id. */
46
+ export function requirementIdForFile(basename: string): string {
47
+ return floorScenarioFiles().has(basename)
48
+ ? requirementIdForScenario(basename)
49
+ : `openwop.scenario.${basename.replace(/\.test\.ts$/, '')}`;
50
+ }
51
+
52
+ export type FileTestState = 'pass' | 'fail' | 'skip';
53
+
54
+ /** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
55
+ * the ONE disposition the file records. */
56
+ export function fileDisposition(
57
+ states: readonly FileTestState[],
58
+ gateReason: 'inapplicable' | 'skipped' | undefined,
59
+ assertionCount?: number,
60
+ ): { disposition: Disposition; detail?: string } {
61
+ if (states.some((s) => s === 'fail')) return { disposition: 'executed-fail', detail: 'one or more assertions in the file failed' };
62
+ if (states.some((s) => s === 'pass')) {
63
+ // A test that early-returned through `behaviorGate` is reported by vitest
64
+ // as a pass with zero assertions. When EVERY passing test in the file did
65
+ // that (assertionCount 0) and the gate recorded why, the file's honest
66
+ // disposition is the gate's — `inapplicable` / `skipped` with its reason —
67
+ // not a vacuous executed-pass. Without a gate reason a zero-assertion pass
68
+ // stays `executed-pass` with `assertionCount: 0`, which certification
69
+ // rejects as unclassified (RFC 0148 §A).
70
+ if (assertionCount === 0 && gateReason === 'inapplicable') {
71
+ return { disposition: 'inapplicable', detail: 'every test returned early through behaviorGate with zero assertions: profile not advertised in the captured discovery set' };
72
+ }
73
+ if (assertionCount === 0 && gateReason === 'skipped') {
74
+ return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
75
+ }
76
+ return { disposition: 'executed-pass' };
77
+ }
78
+ if (gateReason === 'inapplicable') return { disposition: 'inapplicable', detail: 'every test skipped: profile not advertised in the captured discovery set (behaviorGate)' };
79
+ if (gateReason === 'skipped') return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
80
+ return {
81
+ disposition: 'blocked',
82
+ detail:
83
+ states.length === 0
84
+ ? 'no test executed and no disposition recorded — unclassified return (RFC 0148 §A resolves it to blocked)'
85
+ : 'every test skipped with no recorded reason — unclassified return (RFC 0148 §A resolves it to blocked)',
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The runner's file-level record (RFC 0148 §A), as `setup.ts` computes it in
91
+ * `afterAll`. Pure so `conformance-execution-witness.test.ts` can pin the rule
92
+ * the hooks apply:
93
+ * - a failed test ⇒ executed-fail; a witnessed pass ⇒ executed-pass;
94
+ * - a zero-assertion "pass" ⇒ the file's noted reason (`softSkip` /
95
+ * `seamAbsent`: inapplicable | skipped | blocked) or a behaviorGate reason;
96
+ * - a zero-assertion "pass" with NO reason ⇒ `blocked` + the marker detail —
97
+ * an early return can never become a pass.
98
+ */
99
+ export function resolveFileRecord(
100
+ states: readonly FileTestState[],
101
+ gateReason: 'inapplicable' | 'skipped' | undefined,
102
+ assertionCount: number,
103
+ noted: { kind: 'inapplicable' | 'skipped' | 'blocked'; reason: string } | null,
104
+ ): { disposition: Disposition; detail?: string } {
105
+ let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
106
+ if (disposition === 'executed-pass' && assertionCount === 0) {
107
+ if (noted !== null) {
108
+ disposition = noted.kind;
109
+ detail = noted.reason;
110
+ } else {
111
+ disposition = 'blocked';
112
+ detail = UNCLASSIFIED_RETURN_DETAIL;
113
+ }
114
+ }
115
+ return detail === undefined ? { disposition } : { disposition, detail };
116
+ }
117
+
118
+ export interface DerivedRequirement {
119
+ readonly requirementId: string;
120
+ readonly scenarioId: string;
121
+ readonly disposition: Disposition;
122
+ readonly detail?: string;
123
+ /** RFC 0148 §C — present when the ledger recorded it. */
124
+ readonly assertionCount?: number;
125
+ }
126
+
127
+ export interface DerivedProfileVerdict {
128
+ readonly profile: string;
129
+ /** Floor requirement ids with NO ledger entry (unclassified returns). */
130
+ readonly unclassified: readonly string[];
131
+ /** Floor requirement ids whose disposition is not certifiable. */
132
+ readonly blocking: readonly string[];
133
+ readonly certifiable: boolean;
134
+ /**
135
+ * `PROFILE_FLOOR_SCENARIOS[profile].runtimeDerived`: the profile is HELD only
136
+ * when every floor requirement is a witnessed `executed-pass`. When false for
137
+ * such a profile the emitter drops it from `claimedProfiles` — the host does
138
+ * not hold it — rather than reporting a rejection or a blocked claim.
139
+ */
140
+ readonly runtimeDerived: boolean;
141
+ /** For runtime-derived profiles: every floor row is a witnessed executed-pass. */
142
+ readonly held: boolean;
143
+ }
144
+
145
+ export interface Derivation {
146
+ readonly requirements: readonly DerivedRequirement[];
147
+ readonly totals: { executedPass: number; executedFail: number; skipped: number; inapplicable: number; blocked: number };
148
+ readonly verdicts: readonly DerivedProfileVerdict[];
149
+ /** True when ANY claimed profile has an unclassified floor requirement. The
150
+ * runner MUST reject certification in that case (RFC 0148 acceptance item 2). */
151
+ readonly rejectUnclassified: boolean;
152
+ /** Whether a ledger was available at all (false ⇒ every row is report-derived). */
153
+ readonly ledgerPresent: boolean;
154
+ }
155
+
156
+ /**
157
+ * Runner half. `reportStates` is what vitest's JSON report said per file
158
+ * (`passed`/`failed`/`skipped`); `ledger` is what the workers recorded.
159
+ */
160
+ export function deriveRequirementDispositions(
161
+ reportStates: ReadonlyMap<string, 'passed' | 'failed' | 'skipped'>,
162
+ ledger: readonly LedgerEntry[],
163
+ claimedProfiles: readonly string[],
164
+ /** The captured discovery document — needed to evaluate a discovery-conditional floor (G7). */
165
+ document?: Readonly<Record<string, unknown>>,
166
+ ): Derivation {
167
+ const byId = new Map(ledger.map((e) => [e.requirementId, e] as const));
168
+ const ledgerPresent = ledger.length > 0;
169
+ const rows: DerivedRequirement[] = [];
170
+ const perFile = new Map<string, DerivedRequirement>();
171
+
172
+ for (const [file, state] of [...reportStates.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
173
+ const id = requirementIdForFile(file);
174
+ const rec = byId.get(id);
175
+ let row: DerivedRequirement;
176
+ if (rec !== undefined) {
177
+ row = {
178
+ requirementId: id,
179
+ scenarioId: file,
180
+ disposition: rec.disposition,
181
+ ...(rec.detail === undefined ? {} : { detail: rec.detail }),
182
+ ...(rec.assertionCount === undefined ? {} : { assertionCount: rec.assertionCount }),
183
+ };
184
+ } else if (state === 'passed') {
185
+ row = ledgerPresent
186
+ ? { requirementId: id, scenarioId: file, disposition: 'executed-pass', detail: 'report-derived: vitest passed the file but no disposition was recorded (assertion count unknown) — unclassified for a claimed floor' }
187
+ : { requirementId: id, scenarioId: file, disposition: 'executed-pass' };
188
+ } else if (state === 'failed') {
189
+ row = { requirementId: id, scenarioId: file, disposition: 'executed-fail', detail: 'the scenario executed and failed (report-derived; no ledger entry)' };
190
+ } else {
191
+ row = {
192
+ requirementId: id,
193
+ scenarioId: file,
194
+ disposition: 'blocked',
195
+ detail: ledgerPresent
196
+ ? 'unclassified return: the file recorded no disposition — RFC 0148 §A resolves it to blocked, never to a pass'
197
+ : 'runner cannot classify a skipped file without a ledger; RFC 0148 §A resolves an unclassifiable requirement to blocked',
198
+ };
199
+ }
200
+ rows.push(row);
201
+ perFile.set(file, row);
202
+ }
203
+
204
+ // Prefix requirements: derived from the matching files.
205
+ const prefixIds = new Set<string>();
206
+ for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
207
+ for (const prefix of [...prefixIds].sort()) {
208
+ const matching = [...perFile.entries()].filter(([f]) => f.startsWith(prefix)).map(([, r]) => r);
209
+ const id = requirementIdForPrefix(prefix);
210
+ let row: DerivedRequirement;
211
+ if (matching.some((r) => r.disposition === 'executed-pass')) {
212
+ // Witnessed by the matching passes: the summary row carries their
213
+ // combined assertion count so a consumer reading only this row still
214
+ // sees a witnessed pass (RFC 0148 §C `assertionCount`).
215
+ const witnessed = matching.filter((r) => r.disposition === 'executed-pass').reduce((n, r) => n + (r.assertionCount ?? 0), 0);
216
+ row = { requirementId: id, scenarioId: `${prefix}*`, disposition: 'executed-pass', assertionCount: witnessed };
217
+ } else if (matching.some((r) => r.disposition === 'executed-fail')) {
218
+ row = { requirementId: id, scenarioId: `${prefix}*`, disposition: 'executed-fail', detail: `no ${prefix}* scenario passed and at least one failed` };
219
+ } else if (matching.length === 0) {
220
+ row = { requirementId: id, scenarioId: `${prefix}*`, disposition: 'blocked', detail: `no ${prefix}* scenario ran — unclassified return` };
221
+ } else {
222
+ // all matching files are skipped/inapplicable/blocked: the prefix requirement is met by ANY pass, so none ⇒ blocked
223
+ row = { requirementId: id, scenarioId: `${prefix}*`, disposition: 'blocked', detail: `no ${prefix}* scenario executed a passing assertion (${matching.map((r) => r.disposition).join(', ')})` };
224
+ }
225
+ rows.push(row);
226
+ }
227
+
228
+ const totals = { executedPass: 0, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 };
229
+ for (const r of rows) {
230
+ if (r.disposition === 'executed-pass') totals.executedPass++;
231
+ else if (r.disposition === 'executed-fail') totals.executedFail++;
232
+ else if (r.disposition === 'skipped') totals.skipped++;
233
+ else if (r.disposition === 'inapplicable') totals.inapplicable++;
234
+ else totals.blocked++;
235
+ }
236
+
237
+ const rowById = new Map(rows.map((r) => [r.requirementId, r] as const));
238
+ const verdicts: DerivedProfileVerdict[] = [];
239
+ for (const profile of claimedProfiles) {
240
+ const ids = requirementsFor(profile, document);
241
+ if (ids === null) {
242
+ const floor = PROFILE_FLOOR_SCENARIOS[profile];
243
+ const why = floor === undefined ? `(no floor defined for ${profile})` : `(discovery-conditional floor for ${profile} is unevaluable without the discovery document)`;
244
+ verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false });
245
+ continue;
246
+ }
247
+ const unclassified: string[] = [];
248
+ const blocking: string[] = [];
249
+ let witnessedPasses = 0;
250
+ for (const id of ids) {
251
+ const r = rowById.get(id);
252
+ const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
253
+ if (r !== undefined && r.disposition === 'executed-pass' && (r.assertionCount ?? 0) > 0) witnessedPasses += 1;
254
+ // Unclassified: no row, or a report-derived blocked (nothing recorded), or a
255
+ // VACUOUS pass — executed-pass with assertionCount 0 is a witness of nothing
256
+ // (RFC 0148 §A: "a required behavior MUST NOT be certified without a target
257
+ // execution witness"), so for a claimed floor it counts as unclassified.
258
+ const vacuous =
259
+ r !== undefined &&
260
+ ((r.disposition === 'executed-pass' && r.assertionCount === 0) ||
261
+ // the runner's own §A resolution of a zero-assertion file that noted no
262
+ // reason: honest as a row, still an unclassified return for a floor
263
+ (r.disposition === 'blocked' && r.detail === UNCLASSIFIED_RETURN_DETAIL));
264
+ // With a ledger present, ANY floor row that did not come from the ledger is
265
+ // unclassified: silence is evidence of nothing (RFC 0148 §A). Without a
266
+ // ledger only report-blocked rows are unclassified (the pre-S6 reading).
267
+ const silent = !fromLedger && (ledgerPresent || r?.disposition === 'blocked');
268
+ if (r === undefined || silent || vacuous) unclassified.push(id);
269
+ // Unclassified always blocks: a requirement nobody recorded cannot certify.
270
+ if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition)) blocking.push(id);
271
+ }
272
+ // discoveryOnly floors have ids.length === 0 and certify by design here (the
273
+ // requirement-ledger's verifyProfileRequirements is stricter; the runner
274
+ // consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately).
275
+ const discoveryOnly = PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
276
+ const runtimeDerived = PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
277
+ // A runtime-derived profile is HELD only when every floor row is a witnessed
278
+ // pass ("derivable from which scenarios pass" — profiles.md). Anything else
279
+ // means the host does not hold it: not a rejection, not a blocked claim.
280
+ const held = ids.length > 0 && witnessedPasses === ids.length;
281
+ verdicts.push({
282
+ profile,
283
+ unclassified: runtimeDerived && !held ? [] : unclassified,
284
+ blocking: runtimeDerived && !held ? ids.filter((id) => rowById.get(id)?.disposition !== 'executed-pass' || (rowById.get(id)?.assertionCount ?? 0) === 0) : blocking,
285
+ certifiable: runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
286
+ runtimeDerived,
287
+ held,
288
+ });
289
+ }
290
+ return { requirements: rows, totals, verdicts, rejectUnclassified: verdicts.some((v) => v.unclassified.length > 0), ledgerPresent };
291
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * RFC 0148 §A — say WHY a scenario returned early.
3
+ *
4
+ * A test that returns before its first `expect` reports to vitest as a pass
5
+ * with zero assertions. Under the ledger that is an UNCLASSIFIED return: the
6
+ * runner cannot tell "capability not advertised" from "seam not mounted" from
7
+ * "operator opted out". `behaviorGate` records the first two cases for
8
+ * profile-gated files; every other early return in the corpus was silent.
9
+ *
10
+ * `softSkip(kind, reason)` notes the reason for the CURRENT test file (from
11
+ * vitest's `expect.getState().testPath`) and returns `undefined`, so an
12
+ * early return becomes one expression:
13
+ *
14
+ * if (!(await advertised())) return softSkip('inapplicable', 'host does not advertise X');
15
+ * if (res.status === 404) return softSkip('blocked', 'seam /v1/host/sample/... not mounted');
16
+ *
17
+ * `setup.ts` reads the notes when it records the file: a file whose passes are
18
+ * all zero-assertion takes the noted disposition (`inapplicable` / `skipped` /
19
+ * `blocked`, worst-first if mixed) with the joined reasons; a file with real
20
+ * assertions is `executed-pass` regardless of notes. A zero-assertion file with
21
+ * NO note at all is recorded `blocked` — "unclassified return; RFC 0148 §A
22
+ * resolves it to blocked" — and stays UNCLASSIFIED for certification (a floor
23
+ * row with that disposition still rejects), so the honest bundle row and the
24
+ * pressure to say why both survive.
25
+ */
26
+
27
+ import { expect } from 'vitest';
28
+ import { basename } from 'node:path';
29
+
30
+ export type SoftSkipKind = 'inapplicable' | 'skipped' | 'blocked';
31
+
32
+ /** Detail marker the runner writes for a zero-assertion file that noted nothing. */
33
+ export const UNCLASSIFIED_RETURN_DETAIL = 'every test returned early with zero assertions and no recorded reason — unclassified return; RFC 0148 §A resolves it to blocked (add softSkip(kind, reason) at the early return)';
34
+
35
+ const notes = new Map<string, Array<{ kind: SoftSkipKind; reason: string }>>();
36
+
37
+ function currentFile(): string | null {
38
+ try {
39
+ const p = (expect.getState() as { testPath?: string }).testPath;
40
+ return typeof p === 'string' && p.length > 0 ? basename(p) : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Note an early return for the current file. Returns undefined so `return softSkip(...)` reads naturally. */
47
+ export function softSkip(kind: SoftSkipKind, reason: string): undefined {
48
+ const file = currentFile();
49
+ if (file === null) return undefined;
50
+ const arr = notes.get(file) ?? [];
51
+ if (!arr.some((n) => n.kind === kind && n.reason === reason)) arr.push({ kind, reason });
52
+ notes.set(file, arr);
53
+ return undefined;
54
+ }
55
+
56
+ /**
57
+ * An advertised capability whose observation seam is absent or refused
58
+ * (404 / 403 / `null` from a seam helper): the host made a claim the suite
59
+ * cannot check. Default mode notes `blocked` with the reason (RFC 0148 §A);
60
+ * under `OPENWOP_REQUIRE_BEHAVIOR=true` it FAILS (RFC 0148 §B / RFC 0139 G14
61
+ * flip) — advertised behaviour MUST be present. Use as `return seamAbsent(...)`.
62
+ */
63
+ export function seamAbsent(reason: string): undefined {
64
+ if (process.env['OPENWOP_REQUIRE_BEHAVIOR'] === 'true') {
65
+ throw new Error(`RFC 0148 §B: advertised behaviour is not observable — ${reason} (OPENWOP_REQUIRE_BEHAVIOR=true fails an advertised-missing seam)`);
66
+ }
67
+ return softSkip('blocked', reason);
68
+ }
69
+
70
+ const RANK: Record<SoftSkipKind, number> = { blocked: 0, skipped: 1, inapplicable: 2 };
71
+
72
+ /**
73
+ * The noted disposition for a file, worst-first when mixed (`blocked` beats
74
+ * `skipped` beats `inapplicable` — a file that could not check one thing is
75
+ * not certifiable on the strength of another thing being inapplicable), with
76
+ * the reasons joined. `null` when nothing was noted.
77
+ */
78
+ export function softSkipDisposition(file: string): { kind: SoftSkipKind; reason: string } | null {
79
+ const arr = notes.get(file);
80
+ if (arr === undefined || arr.length === 0) return null;
81
+ const kind = [...arr].sort((a, b) => RANK[a.kind] - RANK[b.kind])[0]!.kind;
82
+ const reason = arr.map((n) => (arr.length > 1 ? `[${n.kind}] ${n.reason}` : n.reason)).join('; ');
83
+ return { kind, reason };
84
+ }
85
+
86
+ /** Test hook. */
87
+ export function resetSoftSkips(): void {
88
+ notes.clear();
89
+ }