@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,356 @@
1
+ /**
2
+ * RFC 0148 §A/§C — consumer-side verification of a certification bundle v2,
3
+ * and the evidence scrubber the emitter runs before a bundle is written.
4
+ *
5
+ * `verifyBundle()` in `profiles.ts` reads bundle v1 (`results.passed` lists).
6
+ * v2 replaced the lists with per-requirement dispositions, and a v2 consumer
7
+ * MUST re-derive from those rows rather than trust `claimedProfiles` — RFC
8
+ * 0089 §B still binds, and RFC 0148 adds what v1 could not say:
9
+ *
10
+ * - a required row that is MISSING is an unwitnessed requirement — the bundle
11
+ * is REJECTED as evidence, not merely "not certified";
12
+ * - an `executed-pass` with `assertionCount: 0` (or with no count at all) is
13
+ * a witness of nothing — REJECTED;
14
+ * - two rows for one requirement is exactly-one-disposition-per-requirement
15
+ * broken — REJECTED;
16
+ * - totals that disagree with the rows they summarize are a tampered or
17
+ * hand-edited witness — REJECTED;
18
+ * - the conformance secret canary anywhere in the document is evidence that
19
+ * leaked a secret — REJECTED (`threat-model-secret-leakage.md` §SR-1);
20
+ * - a required row that is honestly `blocked` or `executed-fail` is VALID
21
+ * evidence of a claim that does NOT certify. That is the state of the
22
+ * host, not a defect in the bundle, and the two verdicts are kept apart.
23
+ *
24
+ * Server-free; no I/O.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto';
28
+ import {
29
+ PROFILE_FLOOR_SCENARIOS,
30
+ profileDerivable,
31
+ type DiscoveryPayload,
32
+ } from './profiles.js';
33
+ import { CERTIFIABLE, DISPOSITIONS, type Disposition } from './requirement-ledger.js';
34
+ import { floorFilesFor, requirementIdForPrefix, requirementIdForScenario } from './requirement-registry.js';
35
+ import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
36
+
37
+ /**
38
+ * The BYOK conformance canary (`fixtures.md` §conformance-secrets-roundtrip;
39
+ * SR-1). Its raw value MUST NOT appear on any observable channel — variables,
40
+ * events, debug bundles, logs — and a certification bundle is one more
41
+ * observable channel. A verifier can check for THIS literal without knowing
42
+ * any operator secret, which is why it is the one canary the verifier binds.
43
+ */
44
+ export const CONFORMANCE_SECRET_CANARY = 'openwop-conformance-canary-secret';
45
+
46
+ /** Minimal v2 shape the verifier reads (schema validation is a separate step). */
47
+ export interface BundleV2Requirement {
48
+ readonly requirementId: string;
49
+ readonly scenarioId: string;
50
+ readonly disposition: string;
51
+ readonly assertionCount?: number;
52
+ readonly detail?: string;
53
+ }
54
+ export interface BundleV2Like {
55
+ readonly bundleVersion: string;
56
+ readonly discovery: { readonly document: DiscoveryPayload; readonly sha256?: string; readonly url?: string };
57
+ readonly claimedProfiles: readonly string[];
58
+ readonly aliases?: readonly string[];
59
+ readonly results: {
60
+ readonly totals: {
61
+ readonly executedPass: number;
62
+ readonly executedFail: number;
63
+ readonly skipped: number;
64
+ readonly inapplicable: number;
65
+ readonly blocked: number;
66
+ };
67
+ readonly requirements: readonly BundleV2Requirement[];
68
+ };
69
+ }
70
+
71
+ export type BundleRejectionKind =
72
+ | 'not-v2'
73
+ | 'unknown-disposition'
74
+ | 'duplicate-requirement'
75
+ | 'totals-mismatch'
76
+ | 'reason-missing'
77
+ | 'secret-canary'
78
+ | 'unwitnessed-requirement'
79
+ | 'vacuous-pass';
80
+
81
+ export interface BundleRejection {
82
+ readonly kind: BundleRejectionKind;
83
+ /** The profile the rejection is scoped to, when it is (unwitnessed / vacuous). */
84
+ readonly profile?: string;
85
+ readonly requirementId?: string;
86
+ readonly detail: string;
87
+ }
88
+
89
+ export interface BundleV2ProfileVerdict {
90
+ readonly profile: string;
91
+ /** §B(1): derivable from the captured discovery document. */
92
+ readonly derivable: boolean;
93
+ /** No floor is defined for this profile — the claim is unprovable (RFC 0148 §C G6). */
94
+ readonly floorUnspecified: boolean;
95
+ /** Required requirement ids for this profile (floor files + satisfied prefixes). */
96
+ readonly required: readonly string[];
97
+ /** Rows for required ids that are missing, or `executed-pass` with no witness. */
98
+ readonly rejections: readonly BundleRejection[];
99
+ /** Required rows recorded honestly but not in the CERTIFIABLE set (`blocked`, `executed-fail`). */
100
+ readonly notCertifiable: readonly string[];
101
+ /** The evidence for this profile is well-formed (no rejections). */
102
+ readonly evidenceValid: boolean;
103
+ /** derivable ∧ evidenceValid ∧ every required row certifiable. */
104
+ readonly certified: boolean;
105
+ }
106
+
107
+ export interface BundleV2Verdict {
108
+ /** The document is acceptable evidence: no rejection of any kind. */
109
+ readonly evidenceValid: boolean;
110
+ /** Every claimed profile is certified. Implies `evidenceValid`. */
111
+ readonly certified: boolean;
112
+ /** Bundle-wide rejections (shape, duplicates, totals, canary). */
113
+ readonly rejections: readonly BundleRejection[];
114
+ readonly profiles: readonly BundleV2ProfileVerdict[];
115
+ }
116
+
117
+ const scenarioBasename = (id: string): string => id.split('/').pop() ?? id;
118
+
119
+ /** Walk every string in a JSON value. */
120
+ function* strings(value: unknown, path = '$'): Generator<{ path: string; value: string }> {
121
+ if (typeof value === 'string') {
122
+ yield { path, value };
123
+ return;
124
+ }
125
+ if (Array.isArray(value)) {
126
+ for (let i = 0; i < value.length; i += 1) yield* strings(value[i], `${path}[${i}]`);
127
+ return;
128
+ }
129
+ if (value !== null && typeof value === 'object') {
130
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
131
+ yield { path: `${path}.${k}`, value: k };
132
+ yield* strings(v, `${path}.${k}`);
133
+ }
134
+ }
135
+ }
136
+
137
+ /** Where (JSON paths) a literal appears anywhere in the value — keys included. */
138
+ export function findLiteral(value: unknown, literal: string): string[] {
139
+ const hits: string[] = [];
140
+ if (literal === '') return hits;
141
+ for (const s of strings(value)) if (s.value.includes(literal)) hits.push(s.path);
142
+ return hits;
143
+ }
144
+
145
+ /**
146
+ * Required requirement ids for a claimed profile, derived from the SAME floor
147
+ * map the emitter uses. For a `requiredAnyPrefix` entry the requirement is
148
+ * satisfied by any row whose scenario matches the prefix; the id recorded for
149
+ * it is the prefix requirement id, and the rows that satisfy it are the
150
+ * matching scenario rows.
151
+ */
152
+ function requiredFor(profile: string, document: Readonly<Record<string, unknown>>): { files: string[]; prefixes: string[]; discoveryOnly: boolean } | null {
153
+ const floor = PROFILE_FLOOR_SCENARIOS[profile];
154
+ if (floor === undefined) return null;
155
+ // Discovery-conditional floors (G7) are evaluated against the bundle's own
156
+ // captured document — the same evidence the claim was derived from.
157
+ const files = floorFilesFor(profile, document);
158
+ if (files === null) return null;
159
+ return {
160
+ files: [...files],
161
+ prefixes: [...(floor.requiredAnyPrefix ?? [])],
162
+ discoveryOnly: floor.discoveryOnly === true,
163
+ };
164
+ }
165
+
166
+ function isWitnessedPass(row: BundleV2Requirement): boolean {
167
+ return row.disposition === 'executed-pass' && typeof row.assertionCount === 'number' && row.assertionCount > 0;
168
+ }
169
+
170
+ /**
171
+ * The emitter's own RFC 0148 §A resolution of a zero-assertion file that noted
172
+ * no reason: recorded `blocked` with a fixed marker detail. Honest as a row;
173
+ * for a REQUIRED requirement it is still an unclassified return, and a claim
174
+ * carrying it is rejected exactly as a vacuous pass would be.
175
+ */
176
+ function isUnclassifiedReturn(row: BundleV2Requirement): boolean {
177
+ return row.disposition === 'blocked' && row.detail === UNCLASSIFIED_RETURN_DETAIL;
178
+ }
179
+
180
+ /**
181
+ * Verify a v2 bundle. Pure; the caller validates against
182
+ * `certification-bundle-v2.schema.json` separately (this function tolerates a
183
+ * schema-invalid document and reports what it can).
184
+ */
185
+ export function verifyBundleV2(bundle: BundleV2Like): BundleV2Verdict {
186
+ const rejections: BundleRejection[] = [];
187
+ if (bundle.bundleVersion !== '2') {
188
+ rejections.push({ kind: 'not-v2', detail: `bundleVersion is ${JSON.stringify(bundle.bundleVersion)}, expected "2"` });
189
+ }
190
+
191
+ const rows = bundle.results?.requirements ?? [];
192
+ const byId = new Map<string, BundleV2Requirement[]>();
193
+ for (const r of rows) {
194
+ if (!(DISPOSITIONS as readonly string[]).includes(r.disposition)) {
195
+ rejections.push({ kind: 'unknown-disposition', requirementId: r.requirementId, detail: `${r.requirementId}: disposition ${JSON.stringify(r.disposition)} is not one of the five RFC 0148 §A names` });
196
+ }
197
+ if (r.disposition !== 'executed-pass' && (r.detail === undefined || r.detail.trim() === '')) {
198
+ rejections.push({ kind: 'reason-missing', requirementId: r.requirementId, detail: `${r.requirementId}: '${r.disposition}' recorded without a reason (RFC 0148 §A)` });
199
+ }
200
+ const arr = byId.get(r.requirementId) ?? [];
201
+ arr.push(r);
202
+ byId.set(r.requirementId, arr);
203
+ }
204
+ for (const [id, arr] of byId) {
205
+ if (arr.length > 1) rejections.push({ kind: 'duplicate-requirement', requirementId: id, detail: `${id}: ${arr.length} rows — exactly one disposition per requirement per run (RFC 0148 §A)` });
206
+ }
207
+
208
+ // Totals MUST summarize the rows. A hand-edited total is a tampered witness.
209
+ const count = (d: Disposition): number => rows.filter((r) => r.disposition === d).length;
210
+ const t = bundle.results?.totals;
211
+ if (t !== undefined) {
212
+ const expected = { executedPass: count('executed-pass'), executedFail: count('executed-fail'), skipped: count('skipped'), inapplicable: count('inapplicable'), blocked: count('blocked') };
213
+ for (const k of Object.keys(expected) as (keyof typeof expected)[]) {
214
+ if (t[k] !== expected[k]) rejections.push({ kind: 'totals-mismatch', detail: `totals.${k} is ${String(t[k])} but the rows count ${expected[k]}` });
215
+ }
216
+ }
217
+
218
+ // The canary MUST NOT be anywhere in evidence — keys included.
219
+ for (const p of findLiteral(bundle, CONFORMANCE_SECRET_CANARY)) {
220
+ rejections.push({ kind: 'secret-canary', detail: `conformance secret canary present at ${p} — evidence leaked a secret (SR-1)` });
221
+ }
222
+
223
+ const profiles: BundleV2ProfileVerdict[] = (bundle.claimedProfiles ?? []).map((profile) => {
224
+ const derivable = profileDerivable(bundle.discovery?.document ?? {}, profile);
225
+ const req = requiredFor(profile, (bundle.discovery?.document ?? {}) as Readonly<Record<string, unknown>>);
226
+ if (req === null) {
227
+ return { profile, derivable, floorUnspecified: true, required: [], rejections: [], notCertifiable: [], evidenceValid: true, certified: false };
228
+ }
229
+ const required: string[] = [];
230
+ const profileRejections: BundleRejection[] = [];
231
+ const notCertifiable: string[] = [];
232
+
233
+ for (const file of req.files) {
234
+ const id = requirementIdForScenario(scenarioBasename(file));
235
+ required.push(id);
236
+ const arr = byId.get(id) ?? [];
237
+ const row = arr[0];
238
+ if (row === undefined) {
239
+ profileRejections.push({ kind: 'unwitnessed-requirement', profile, requirementId: id, detail: `${id}: no row — an unwitnessed floor requirement (RFC 0148 §A resolves it to blocked; a claim carrying it is rejected)` });
240
+ continue;
241
+ }
242
+ if (row.disposition === 'executed-pass' && !isWitnessedPass(row)) {
243
+ profileRejections.push({ kind: 'vacuous-pass', profile, requirementId: id, detail: `${id}: executed-pass with assertionCount ${String(row.assertionCount)} — a witness of nothing` });
244
+ continue;
245
+ }
246
+ if (isUnclassifiedReturn(row)) {
247
+ profileRejections.push({ kind: 'unwitnessed-requirement', profile, requirementId: id, detail: `${id}: every test returned early with zero assertions and no recorded reason — an unclassified return the emitter resolved to blocked (RFC 0148 §A); a claim carrying it is rejected` });
248
+ continue;
249
+ }
250
+ if (!(CERTIFIABLE as readonly string[]).includes(row.disposition)) notCertifiable.push(id);
251
+ }
252
+ for (const prefix of req.prefixes) {
253
+ const id = requirementIdForPrefix(prefix);
254
+ required.push(id);
255
+ // Satisfied by the rows of matching scenarios; the emitter also writes a
256
+ // summary row under the prefix id. Either form is accepted; the rule is
257
+ // the same — every matching row witnessed, at least one present.
258
+ const matching = rows.filter((r) => scenarioBasename(r.scenarioId).startsWith(prefix) && r.requirementId !== id);
259
+ const summary = byId.get(id)?.[0];
260
+ if (matching.length === 0 && summary === undefined) {
261
+ profileRejections.push({ kind: 'unwitnessed-requirement', profile, requirementId: id, detail: `${id}: no scenario matching '${prefix}' recorded a row` });
262
+ continue;
263
+ }
264
+ const considered = matching.length > 0 ? matching : [summary as BundleV2Requirement];
265
+ const vacuous = considered.filter((r) => (r.disposition === 'executed-pass' && !isWitnessedPass(r)) || isUnclassifiedReturn(r));
266
+ if (vacuous.length > 0) {
267
+ profileRejections.push({ kind: 'vacuous-pass', profile, requirementId: id, detail: `${id}: ${vacuous.map((r) => r.scenarioId).join(', ')} executed-pass with no witnessed assertion` });
268
+ continue;
269
+ }
270
+ // `requiredAnyPrefix`: ANY witnessed pass among the matching scenarios
271
+ // satisfies the requirement (the emitter's summary row says the same).
272
+ if (!considered.some((r) => isWitnessedPass(r))) notCertifiable.push(id);
273
+ }
274
+
275
+ const evidenceValid = profileRejections.length === 0;
276
+ const certified = derivable && evidenceValid && notCertifiable.length === 0 && (req.discoveryOnly || required.length > 0);
277
+ return { profile, derivable, floorUnspecified: false, required, rejections: profileRejections, notCertifiable, evidenceValid, certified };
278
+ });
279
+
280
+ const evidenceValid = rejections.length === 0 && profiles.every((p) => p.evidenceValid);
281
+ const certified = evidenceValid && profiles.length > 0 && profiles.every((p) => p.certified);
282
+ return { evidenceValid, certified, rejections, profiles };
283
+ }
284
+
285
+ /* ────────────────────────────────────────────────────────────────────────── */
286
+
287
+ /**
288
+ * Replacement for a scrubbed secret: a stable, non-reversible marker that
289
+ * still lets two occurrences of the same secret be recognized as the same.
290
+ */
291
+ export function redactionMarker(secret: string): string {
292
+ return `«redacted:${createHash('sha256').update(secret).digest('hex').slice(0, 12)}»`;
293
+ }
294
+
295
+ export interface ScrubResult<T> {
296
+ readonly value: T;
297
+ /** JSON paths at which a secret was replaced. */
298
+ readonly redactedAt: readonly string[];
299
+ }
300
+
301
+ /**
302
+ * Replace every occurrence of every secret in every string of `value` (keys
303
+ * included) with `redactionMarker(secret)`. Empty / whitespace-only secrets
304
+ * are ignored — scrubbing "" would blank every string. The emitter runs this
305
+ * over the finished bundle with the credentials it was given plus the
306
+ * conformance canary; RFC 0148 §C says secret canaries never enter evidence,
307
+ * and the only way to make that true regardless of what a scenario put in a
308
+ * `detail` string is to scrub the document, not to trust the scenarios.
309
+ */
310
+ export function scrubEvidence<T>(value: T, secrets: readonly string[]): ScrubResult<T> {
311
+ const live = [...new Set(secrets.filter((s) => typeof s === 'string' && s.trim() !== ''))].sort((a, b) => b.length - a.length);
312
+ const redactedAt: string[] = [];
313
+ if (live.length === 0) return { value, redactedAt };
314
+ const scrubString = (s: string, path: string): string => {
315
+ let out = s;
316
+ let hit = false;
317
+ for (const secret of live) {
318
+ if (out.includes(secret)) {
319
+ out = out.split(secret).join(redactionMarker(secret));
320
+ hit = true;
321
+ }
322
+ }
323
+ if (hit) redactedAt.push(path);
324
+ return out;
325
+ };
326
+ const walk = (v: unknown, path: string): unknown => {
327
+ if (typeof v === 'string') return scrubString(v, path);
328
+ if (Array.isArray(v)) return v.map((x, i) => walk(x, `${path}[${i}]`));
329
+ if (v !== null && typeof v === 'object') {
330
+ const out: Record<string, unknown> = {};
331
+ for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
332
+ out[scrubString(k, `${path}.${k}`)] = walk(x, `${path}.${k}`);
333
+ }
334
+ return out;
335
+ }
336
+ return v;
337
+ };
338
+ return { value: walk(value, '$') as T, redactedAt };
339
+ }
340
+
341
+ /**
342
+ * The secrets a `--certify` run must keep out of its own evidence: the
343
+ * credential it was handed, plus every `OPENWOP_*` environment value that
344
+ * names a key/token/secret/password, plus the conformance canary.
345
+ */
346
+ export function evidenceSecretsFromEnv(env: NodeJS.ProcessEnv, extra: readonly (string | undefined)[] = []): string[] {
347
+ const out = new Set<string>();
348
+ for (const [k, v] of Object.entries(env)) {
349
+ if (!k.startsWith('OPENWOP_')) continue;
350
+ if (!/(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/.test(k)) continue;
351
+ if (v !== undefined && v.trim() !== '') out.add(v);
352
+ }
353
+ for (const s of extra) if (s !== undefined && s.trim() !== '') out.add(s);
354
+ out.add(CONFORMANCE_SECRET_CANARY);
355
+ return [...out];
356
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The canonical HTTP error envelope (`schemas/error-envelope.schema.json`,
3
+ * `rest-endpoints.md` §"Error envelope") is FLAT:
4
+ *
5
+ * { "error": "<code>", "message": "<text>", "details"?: { … } }
6
+ *
7
+ * `additionalProperties: false`; `retriable`, `retryAfter`, `correlationId`,
8
+ * `supported`, `requested`, … live under `details`. Every conforming host,
9
+ * the TypeScript SDK's `ErrorEnvelope`, and the v1 lock all agree on this.
10
+ *
11
+ * Between 2026-06 and 2026-08 a NESTED shape — `{ error: { code, retriable } }`
12
+ * — crept into a few code-list entries of `rest-endpoints.md`, four
13
+ * `host-sample-test-seams.md` seam contracts (§19/§20/§22/§23) and ~15
14
+ * scenarios that read `body.error.code` off an HTTP response (S22, decided
15
+ * 2026-08-16: flat wins — the schema is authoritative and re-shaping `error`
16
+ * from string to object would break every conforming host under
17
+ * COMPATIBILITY.md §2.2). Three OTHER error objects are legitimately nested and
18
+ * are NOT this envelope: `RunSnapshot.error { code, message, retriable? }`
19
+ * (run-level), bulk-result items `{ ok:false, error:{ code } }`, and JSON-RPC
20
+ * bodies (MCP / A2A `{ error: { code, message } }`).
21
+ *
22
+ * These helpers read the CODE and the retriable hint from a canonical (flat)
23
+ * envelope, and — for a deprecation window ending with the first suite minor
24
+ * after 2026-11-10 — tolerate the legacy nested shape a seam may still emit,
25
+ * so a host is not made red for a shape the catalog itself prescribed until
26
+ * today. `assertCanonicalErrorEnvelope` is the strict form for legs that check
27
+ * the envelope's shape rather than only its code.
28
+ */
29
+
30
+ export interface CanonicalErrorEnvelope {
31
+ readonly error: string;
32
+ readonly message: string;
33
+ readonly details?: Readonly<Record<string, unknown>>;
34
+ }
35
+
36
+ /** The error code from a canonical envelope; the legacy nested `error.code` is tolerated (deprecation window). */
37
+ export function readErrorCode(body: unknown): string | undefined {
38
+ if (body === null || typeof body !== 'object') return undefined;
39
+ const e = (body as { error?: unknown }).error;
40
+ if (typeof e === 'string') return e;
41
+ if (e !== null && typeof e === 'object') {
42
+ const code = (e as { code?: unknown }).code;
43
+ return typeof code === 'string' ? code : undefined;
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /** `details.retriable` from a canonical envelope; legacy nested `error.retriable` tolerated. */
49
+ export function readRetriable(body: unknown): boolean | undefined {
50
+ if (body === null || typeof body !== 'object') return undefined;
51
+ const d = (body as { details?: unknown }).details;
52
+ if (d !== null && typeof d === 'object' && typeof (d as { retriable?: unknown }).retriable === 'boolean') {
53
+ return (d as { retriable: boolean }).retriable;
54
+ }
55
+ const e = (body as { error?: unknown }).error;
56
+ if (e !== null && typeof e === 'object' && typeof (e as { retriable?: unknown }).retriable === 'boolean') {
57
+ return (e as { retriable: boolean }).retriable;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** True iff the body is the legacy nested `{ error: { code } }` shape (for reporting, never for asserting a pass). */
63
+ export function isLegacyNestedEnvelope(body: unknown): boolean {
64
+ if (body === null || typeof body !== 'object') return false;
65
+ const e = (body as { error?: unknown }).error;
66
+ return e !== null && typeof e === 'object' && typeof (e as { code?: unknown }).code === 'string';
67
+ }
68
+
69
+ /** Strict shape check: flat, `error` + `message` strings, only the three top-level keys. */
70
+ export function isCanonicalErrorEnvelope(body: unknown): body is CanonicalErrorEnvelope {
71
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return false;
72
+ const b = body as Record<string, unknown>;
73
+ if (typeof b['error'] !== 'string' || b['error'].length === 0) return false;
74
+ if (typeof b['message'] !== 'string' || b['message'].length === 0) return false;
75
+ for (const k of Object.keys(b)) if (k !== 'error' && k !== 'message' && k !== 'details') return false;
76
+ return b['details'] === undefined || (b['details'] !== null && typeof b['details'] === 'object');
77
+ }
@@ -36,9 +36,11 @@ export function canonicalize(value: unknown): string {
36
36
  return JSON.stringify(value);
37
37
  }
38
38
 
39
- /** Project a raw recipe-input object to the closed set of fields per
40
- * `replay.md` §A omit absent optionals (do NOT emit null/default
41
- * placeholders), sort tools[] by name. */
39
+ /** @deprecated RETIRED v1 projection (pre RFC 0150 §C). Kept ONLY so a
40
+ * reader can see what the retired recipe excluded; no scenario may assert
41
+ * against it `replay-llm-cache-key{,-portable}.test.ts` did until suite
42
+ * 1.109.0 and thereby contradicted `semantic-digest-v2.test.ts` in the same
43
+ * package. Use `projectSemanticRequestV2` / `semanticRequestDigestV2`. */
42
44
  export function projectRecipe(raw: Record<string, unknown>): Record<string, unknown> {
43
45
  const out: Record<string, unknown> = { provider: raw.provider, model: raw.model, messages: raw.messages };
44
46
  if (Array.isArray(raw.tools) && raw.tools.length > 0) {
@@ -51,8 +53,7 @@ export function projectRecipe(raw: Record<string, unknown>): Record<string, unkn
51
53
  return out;
52
54
  }
53
55
 
54
- /** Compute the canonical LLM cache key per `replay.md` §B:
55
- * SHA-256(canonicalize(projectRecipe(input))) → lowercase hex. */
56
+ /** @deprecated RETIRED v1 key. See `projectRecipe`. Use `semanticRequestDigestV2`. */
56
57
  export function expectedCacheKey(input: Record<string, unknown>): string {
57
58
  return createHash('sha256').update(canonicalize(projectRecipe(input)), 'utf8').digest('hex');
58
59
  }