@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,182 @@
1
+ /**
2
+ * RFC 0148 §C — `certification-bundle-redaction`: secret canaries never enter
3
+ * evidence (`threat-model-secret-leakage.md` §SR-1 applied to the one
4
+ * observable channel the suite itself produces).
5
+ *
6
+ * A certification bundle captures the discovery document verbatim, host
7
+ * self-description, and per-requirement `detail` strings written by scenarios
8
+ * and gates. Any of those can carry a credential — a host that echoes its
9
+ * bearer token in an error, a scenario that quotes a response body, an
10
+ * operator secret in an `OPENWOP_*` variable a scenario reports. RFC 0148 §C
11
+ * says a canary never enters evidence; the only way to make that TRUE rather
12
+ * than hoped-for is to scrub the finished document at the emitter with every
13
+ * secret the run was given, and to have the consumer verifier reject the one
14
+ * canary it can know without knowing any operator secret.
15
+ *
16
+ * Legs:
17
+ * - `scrubEvidence` replaces every occurrence of every configured secret —
18
+ * nested values AND object keys — with a stable non-reversible marker, and
19
+ * reports where; empty secrets are ignored (scrubbing "" would blank the
20
+ * document); the longest secret wins when one is a prefix of another.
21
+ * - `evidenceSecretsFromEnv` selects `OPENWOP_*` key/token/secret/password
22
+ * variables and always includes the conformance canary.
23
+ * - `verifyBundleV2` REJECTS a bundle carrying the conformance canary
24
+ * anywhere (`secret-canary`), and accepts the scrubbed twin.
25
+ * - the emitter's own configuration digest is a digest: the API key it was
26
+ * handed is not a substring of anything in the document.
27
+ *
28
+ * Server-free, always-on; MUST NOT capability-skip (RFC 0148 §Conformance).
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { createHash } from 'node:crypto';
33
+ import { PROFILE_FLOOR_SCENARIOS } from '../lib/profiles.js';
34
+ import { requirementIdForScenario, requirementIdForPrefix } from '../lib/requirement-registry.js';
35
+ import {
36
+ CONFORMANCE_SECRET_CANARY,
37
+ evidenceSecretsFromEnv,
38
+ findLiteral,
39
+ redactionMarker,
40
+ scrubEvidence,
41
+ verifyBundleV2,
42
+ type BundleV2Like,
43
+ type BundleV2Requirement,
44
+ } from '../lib/certification-bundle-verify.js';
45
+
46
+ export const HOST_CALLBACK_NOT_REQUIRED = 'server-free: scrubs and verifies bundle documents built in-process; no host is contacted';
47
+
48
+ const HEX = 'b'.repeat(64);
49
+ const API_KEY = 'sk-live-0123456789abcdefFEDCBA';
50
+ const DISCOVERY = {
51
+ protocolVersion: '1.0',
52
+ supportedEnvelopes: ['clarification.request'],
53
+ supportedTransports: ['rest'],
54
+ schemaVersions: { workflow: '1.0' },
55
+ limits: { clarificationRounds: 3, schemaRounds: 3, envelopesPerTurn: 8 },
56
+ };
57
+
58
+ function rows(): BundleV2Requirement[] {
59
+ const f = PROFILE_FLOOR_SCENARIOS['openwop-core-standard'];
60
+ if (f === undefined) throw new Error('core-standard floor missing');
61
+ const out: BundleV2Requirement[] = f.required.map((file) => ({ requirementId: requirementIdForScenario(file), scenarioId: file, disposition: 'executed-pass', assertionCount: 5 }));
62
+ for (const p of f.requiredAnyPrefix ?? []) {
63
+ out.push({ requirementId: `openwop.scenario.${p}alpha`, scenarioId: `${p}alpha.test.ts`, disposition: 'executed-pass', assertionCount: 2 });
64
+ out.push({ requirementId: requirementIdForPrefix(p), scenarioId: `${p}*`, disposition: 'executed-pass', assertionCount: 2 });
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function bundle(rs: readonly BundleV2Requirement[], host: Record<string, unknown> = { name: 'h', version: '1' }, document: Record<string, unknown> = DISCOVERY): BundleV2Like {
70
+ const c = (d: string): number => rs.filter((r) => r.disposition === d).length;
71
+ return {
72
+ bundleVersion: '2',
73
+ suite: { package: '@openwop/openwop-conformance', version: '1.114.0' },
74
+ host,
75
+ discovery: { url: 'https://example.invalid/.well-known/openwop', sha256: HEX, document },
76
+ claimedProfiles: ['openwop-core-standard'],
77
+ results: {
78
+ totals: { executedPass: c('executed-pass'), executedFail: c('executed-fail'), skipped: c('skipped'), inapplicable: c('inapplicable'), blocked: c('blocked') },
79
+ requirements: [...rs],
80
+ },
81
+ scenarioManifestSha256: HEX,
82
+ targetConfigurationSha256: createHash('sha256').update(`base-url|${API_KEY}`).digest('hex'),
83
+ } as BundleV2Like;
84
+ }
85
+
86
+ describe('RFC 0148 §C — certification-bundle-redaction: secret canaries never enter evidence', () => {
87
+ it('scrubEvidence replaces every occurrence of every secret in nested values and reports where', () => {
88
+ const rs = rows();
89
+ rs[0] = { ...(rs[0] as BundleV2Requirement), disposition: 'executed-fail', assertionCount: 3, detail: `401 body: {"error":"bad key ${API_KEY}"} (canary ${CONFORMANCE_SECRET_CANARY} echoed)` };
90
+ const b = bundle(rs);
91
+ const { value, redactedAt } = scrubEvidence(b, [API_KEY, CONFORMANCE_SECRET_CANARY]);
92
+ expect(findLiteral(value, API_KEY)).toEqual([]);
93
+ expect(findLiteral(value, CONFORMANCE_SECRET_CANARY)).toEqual([]);
94
+ expect(redactedAt).toEqual(['$.results.requirements[0].detail']);
95
+ const detail = value.results.requirements[0]?.detail ?? '';
96
+ expect(detail).toContain(redactionMarker(API_KEY));
97
+ expect(detail).toContain(redactionMarker(CONFORMANCE_SECRET_CANARY));
98
+ // untouched fields are byte-identical
99
+ expect(value.discovery).toEqual(b.discovery);
100
+ expect(value.results.totals).toEqual(b.results.totals);
101
+ });
102
+
103
+ it('scrubs the captured discovery document and host self-description too — evidence is the WHOLE document', () => {
104
+ const b = bundle(rows(), { name: 'h', version: '1', vendor: `acme (token ${API_KEY})` }, { ...DISCOVERY, ['x-host-debug']: { echo: CONFORMANCE_SECRET_CANARY } });
105
+ const { value, redactedAt } = scrubEvidence(b, [API_KEY, CONFORMANCE_SECRET_CANARY]);
106
+ expect(findLiteral(value, API_KEY)).toEqual([]);
107
+ expect(findLiteral(value, CONFORMANCE_SECRET_CANARY)).toEqual([]);
108
+ expect([...redactedAt].sort()).toEqual(['$.discovery.document.x-host-debug.echo', '$.host.vendor']);
109
+ });
110
+
111
+ it('scrubs object KEYS as well as values — a secret used as a map key is still a secret', () => {
112
+ const doc = { ...DISCOVERY, ['x-host-keys']: { [API_KEY]: 'tenant-a' } };
113
+ const { value } = scrubEvidence(bundle(rows(), { name: 'h', version: '1' }, doc), [API_KEY]);
114
+ expect(findLiteral(value, API_KEY)).toEqual([]);
115
+ const keys = Object.keys((value.discovery.document as Record<string, Record<string, unknown>>)['x-host-keys'] ?? {});
116
+ expect(keys).toEqual([redactionMarker(API_KEY)]);
117
+ });
118
+
119
+ it('empty and whitespace-only secrets are ignored (scrubbing "" would blank every string)', () => {
120
+ const b = bundle(rows());
121
+ const { value, redactedAt } = scrubEvidence(b, ['', ' ']);
122
+ expect(value).toEqual(b);
123
+ expect(redactedAt).toEqual([]);
124
+ });
125
+
126
+ it('when one secret is a prefix of another the longer one is scrubbed whole (no partial marker inside a marker)', () => {
127
+ const short = 'abc123';
128
+ const long = 'abc123-longer-secret';
129
+ const { value } = scrubEvidence({ detail: `x ${long} y ${short} z` }, [short, long]);
130
+ expect(value.detail).toBe(`x ${redactionMarker(long)} y ${redactionMarker(short)} z`);
131
+ });
132
+
133
+ it('the redaction marker is stable, non-reversible, and does not contain the secret', () => {
134
+ const m = redactionMarker(API_KEY);
135
+ expect(m).toBe(redactionMarker(API_KEY));
136
+ expect(m).not.toContain(API_KEY);
137
+ expect(m).toMatch(/^«redacted:[0-9a-f]{12}»$/);
138
+ expect(redactionMarker('other')).not.toBe(m);
139
+ });
140
+
141
+ it('evidenceSecretsFromEnv selects OPENWOP_* key/token/secret/password variables, the handed credential, and ALWAYS the canary', () => {
142
+ const env = {
143
+ OPENWOP_API_KEY: 'k1',
144
+ OPENWOP_MESSAGING_BRIDGE_TOKEN: 't1',
145
+ OPENWOP_DB_PASSWORD: 'p1',
146
+ OPENWOP_CLIENT_SECRET: 's1',
147
+ OPENWOP_BASE_URL: 'https://example.invalid', // not a secret
148
+ OPENWOP_REQUIRE_BEHAVIOR: 'true',
149
+ PATH: '/usr/bin',
150
+ SOME_OTHER_TOKEN: 'not-ours',
151
+ } as NodeJS.ProcessEnv;
152
+ const secrets = evidenceSecretsFromEnv(env, [API_KEY, undefined, '']);
153
+ expect(secrets.sort()).toEqual([API_KEY, 'k1', 'p1', 's1', 't1', CONFORMANCE_SECRET_CANARY].sort());
154
+ });
155
+
156
+ it('verifyBundleV2 REJECTS a bundle carrying the conformance canary anywhere, and accepts the scrubbed twin', () => {
157
+ const rs = rows();
158
+ rs[1] = { ...(rs[1] as BundleV2Requirement), detail: `resolved ${CONFORMANCE_SECRET_CANARY} in plain text` };
159
+ const leaked = bundle(rs);
160
+ const v = verifyBundleV2(leaked);
161
+ expect(v.evidenceValid).toBe(false);
162
+ expect(v.rejections.map((r) => r.kind)).toEqual(['secret-canary']);
163
+ expect(v.rejections[0]?.detail).toContain('$.results.requirements[1].detail');
164
+
165
+ const { value: clean } = scrubEvidence(leaked, [CONFORMANCE_SECRET_CANARY]);
166
+ const v2 = verifyBundleV2(clean);
167
+ expect(v2.evidenceValid).toBe(true);
168
+ expect(v2.certified).toBe(true);
169
+ });
170
+
171
+ it('the canary in a discovery-document KEY is still a rejection (keys are evidence)', () => {
172
+ const doc = { ...DISCOVERY, [`x-host-${CONFORMANCE_SECRET_CANARY}`]: true };
173
+ const v = verifyBundleV2(bundle(rows(), { name: 'h', version: '1' }, doc));
174
+ expect(v.rejections.some((r) => r.kind === 'secret-canary')).toBe(true);
175
+ });
176
+
177
+ it('the target-configuration digest is a digest: the API key is not a substring of any string in the document', () => {
178
+ const b = bundle(rows());
179
+ expect((b as unknown as { targetConfigurationSha256: string }).targetConfigurationSha256).toMatch(/^[0-9a-f]{64}$/);
180
+ expect(findLiteral(b, API_KEY)).toEqual([]);
181
+ });
182
+ });
@@ -38,17 +38,41 @@ const bundleClaiming = (profiles: readonly string[], passed: readonly string[],
38
38
 
39
39
  describe('RFC 0148 §C — floor enforcement is not vacuous', () => {
40
40
  it('a profile with NO floor definition is unprovable, not proven', () => {
41
- // `openwop-interrupts` is deliberately untranscribed: its prose section does
42
- // not yet name a settled floor set. Claiming it must therefore fail — the
43
- // corpus cannot substantiate it, which is a different thing from the host
44
- // having failed something.
45
- const v = verifyBundleProfile(bundleClaiming(['openwop-interrupts'], []), 'openwop-interrupts');
46
- expect(v.floorUnspecified, 'no floor set is defined for openwop-interrupts').toBe(true);
41
+ // A profile the floor map does not know (here a name outside the catalog;
42
+ // until 2026-08-16 `openwop-interrupts` played this role its floor is now
43
+ // transcribed, RFC 0148 §C G7). Claiming it must fail the corpus cannot
44
+ // substantiate it, which is a different thing from the host having failed
45
+ // something.
46
+ const v = verifyBundleProfile(bundleClaiming(['openwop-not-in-catalog'], []), 'openwop-not-in-catalog');
47
+ expect(v.floorUnspecified, 'no floor set is defined for a profile outside the floor map').toBe(true);
47
48
  expect(v.floorProven, 'RFC 0148 §C: an undefined floor MUST NOT satisfy the floor condition').toBe(false);
48
49
  expect(v.valid).toBe(false);
49
50
  expect(v.missingFloor, 'nothing is "missing" — the floor was never evaluable').toEqual([]);
50
51
  });
51
52
 
53
+ it('G7: a discovery-conditional floor (openwop-replay-fork) requires the branch the document advertises — and nothing when it advertises none', () => {
54
+ const doc = (modes: string[]) => ({ protocolVersion: '1.0', supportedEnvelopes: [], schemaVersions: {}, limits: { clarificationRounds: 1, schemaRounds: 1, envelopesPerTurn: 1 }, replay: { supported: true, modes } });
55
+ const claim = (modes: string[], passed: string[]) => ({ discovery: { document: doc(modes) }, claimedProfiles: ['openwop-replay-fork'], results: { passed, failed: [] } });
56
+ // replay-only host: replayDeterminism suffices; replay-fork is NOT required
57
+ let v = verifyBundleProfile(claim(['replay'], ['replayDeterminism.test.ts', 'replay-side-effect-suppression.test.ts']), 'openwop-replay-fork');
58
+ expect(v.floorUnspecified).toBe(false);
59
+ expect(v.floorProven).toBe(true);
60
+ // determinism alone does NOT prove replay mode: caveat 1 (no re-fire) is part of the branch
61
+ v = verifyBundleProfile(claim(['replay'], ['replayDeterminism.test.ts']), 'openwop-replay-fork');
62
+ expect(v.floorProven).toBe(false);
63
+ expect(v.missingFloor).toEqual(['replay-side-effect-suppression.test.ts']);
64
+ // branch-only host: replay-fork suffices
65
+ v = verifyBundleProfile(claim(['branch'], ['replay-fork.test.ts']), 'openwop-replay-fork');
66
+ expect(v.floorProven).toBe(true);
67
+ // both advertised: both required
68
+ v = verifyBundleProfile(claim(['replay', 'branch'], ['replay-fork.test.ts', 'replay-side-effect-suppression.test.ts']), 'openwop-replay-fork');
69
+ expect(v.floorProven).toBe(false);
70
+ expect(v.missingFloor).toEqual(['replayDeterminism.test.ts']);
71
+ // a mode the floor does not know: nothing required → unprovable, not proven
72
+ v = verifyBundleProfile(claim(['exotic'], []), 'openwop-replay-fork');
73
+ expect(v.floorProven).toBe(false);
74
+ });
75
+
52
76
  it('a discovery-only profile with an EXPLICIT empty floor is proven by its predicate alone', () => {
53
77
  // The distinction the fix turns on: `openwop-fixtures` is discovery-payload
54
78
  // -only by `profiles.md`, so an empty floor is a decision on record. Without
@@ -0,0 +1,243 @@
1
+ /**
2
+ * RFC 0157 (RFC 0013 revision × RFC 0151 §B) — a chain fragment can carry a
3
+ * compensator, and the chain-level policy survives expansion.
4
+ *
5
+ * Measured before this landed: `workflow-chain-pack-manifest.schema.json`
6
+ * `$defs.FragmentNode` carried exactly `id/typeId/name/position/config/inputs`
7
+ * and the manifest mentioned `compensation` nowhere — so on the workflow shape a
8
+ * chain-first host actually ships, no node could own an inverse action and no
9
+ * unwind could occur; RFC 0151 §B was reachable only through a hand-authored
10
+ * `POST /v1/workflows`. The `FragmentNode` description even carried a
11
+ * maintenance note saying new `WorkflowNode` fields must be mirrored here, and
12
+ * `compensation` (b4796755) was not.
13
+ *
14
+ * What this file pins:
15
+ *
16
+ * - schema: `FragmentNode.compensation` is a byte-mirror of
17
+ * `WorkflowNode.compensation` (apart from its own description) and
18
+ * `WorkflowChain.compensation` is a byte-mirror of
19
+ * `compensation-policy.schema.json` — mirrors, not `$ref`s, so the manifest
20
+ * stays self-contained (the S14 lesson: a new cross-file `$ref` breaks every
21
+ * fixed-list validator downstream);
22
+ * - a manifest with both surfaces validates; a foreign key inside either is
23
+ * rejected;
24
+ * - expansion (`carryCompensation`, the spec-authoritative reference below the
25
+ * mirrored core): the declaration survives verbatim onto the expanded node,
26
+ * `{{params.*}}` inside `inputMapping` are substituted, fragment node-id
27
+ * references inside `inputMapping` are rewritten with the expansion prefix
28
+ * exactly as edge refs are, an unresolvable `compensation.nodeTypeId` fails
29
+ * with `chain_unresolvable_typeid` BEFORE any node is emitted, and the
30
+ * chain-level policy is copied when the parent has none / accepted when
31
+ * equal / refused with `chain_compensation_policy_conflict` otherwise.
32
+ *
33
+ * Server-free. The host half (a host's own expander honouring the same rules)
34
+ * is witnessed by the live `workflow-chain-host-expansion` path once a host
35
+ * adopts RFC 0157.
36
+ *
37
+ * @see spec/v1/workflow-chain-packs.md §"Compensation (RFC 0157)"
38
+ * @see RFCS/0157-chain-fragments-carry-compensation.md
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { readFileSync, readdirSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import Ajv2020 from 'ajv/dist/2020.js';
45
+ import { SCHEMAS_DIR } from '../lib/paths.js';
46
+ import {
47
+ expandChain,
48
+ carryCompensation,
49
+ expandChainWithCompensation,
50
+ ChainUnresolvableTypeIdError,
51
+ ChainCompensationPolicyConflictError,
52
+ type WorkflowChainWithCompensation,
53
+ type ChainCompensationPolicy,
54
+ type FragmentNodeCompensation,
55
+ } from '../lib/workflow-chain-expansion.js';
56
+
57
+ function schema(name: string): Record<string, unknown> {
58
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
59
+ }
60
+ function stripDescription(o: unknown): unknown {
61
+ if (Array.isArray(o)) return o.map(stripDescription);
62
+ if (o !== null && typeof o === 'object') {
63
+ const out: Record<string, unknown> = {};
64
+ for (const [k, v] of Object.entries(o as Record<string, unknown>)) {
65
+ if (k === 'description' || k === '$schema' || k === '$id' || k === 'title') continue;
66
+ out[k] = stripDescription(v);
67
+ }
68
+ return out;
69
+ }
70
+ return o;
71
+ }
72
+ function manifestValidator() {
73
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
74
+ for (const f of readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json'))) ajv.addSchema(schema(f), f);
75
+ return ajv.getSchema('workflow-chain-pack-manifest.schema.json') ?? ajv.compile(schema('workflow-chain-pack-manifest.schema.json'));
76
+ }
77
+
78
+ const POLICY: ChainCompensationPolicy = { triggers: ['node-failure', 'run-cancel'], approvalScope: 'declared' };
79
+
80
+ const CHAIN: WorkflowChainWithCompensation = {
81
+ chainId: 'vendor.shop.reserveAndCharge',
82
+ version: '1.0.0',
83
+ label: 'Reserve + charge',
84
+ description: 'reserve inventory, then charge; both compensable',
85
+ parameters: { type: 'object', properties: { sku: { type: 'string' } } },
86
+ dag: {
87
+ nodes: [
88
+ {
89
+ id: 'reserve',
90
+ typeId: 'vendor.shop.reserve',
91
+ config: { sku: '{{params.sku}}' },
92
+ compensation: {
93
+ nodeTypeId: 'vendor.shop.release',
94
+ inputMapping: { reservationId: '${nodes.reserve.output.id}', sku: '{{params.sku}}' },
95
+ retry: { maxAttempts: 3, backoffMs: 250 },
96
+ },
97
+ },
98
+ {
99
+ id: 'charge',
100
+ typeId: 'vendor.pay.charge',
101
+ compensation: { nodeTypeId: 'vendor.pay.refund', inputMapping: { chargeId: 'nodes.charge.output.chargeId' }, requiresApproval: true },
102
+ },
103
+ { id: 'notify', typeId: 'core.noop' },
104
+ ],
105
+ edges: [
106
+ { from: 'reserve', to: 'charge' },
107
+ { from: 'charge', to: 'notify' },
108
+ ],
109
+ },
110
+ compensation: POLICY,
111
+ };
112
+
113
+ const RESOLVE_ALL = () => true;
114
+ const CTX = { expansionId: 'e1', params: { sku: 'SKU-9' }, isTypeIdResolvable: RESOLVE_ALL };
115
+
116
+ function manifestWith(chain: unknown) {
117
+ return {
118
+ name: 'vendor.shop.chains',
119
+ version: '1.0.0',
120
+ kind: 'workflow-chain',
121
+ description: 'test',
122
+ engines: { openwop: '>=1.0' },
123
+ chains: [chain],
124
+ };
125
+ }
126
+
127
+ describe('RFC 0157 — schema: the chain manifest mirrors the compensation shapes (self-contained, no cross-file $ref)', () => {
128
+ it('FragmentNode.compensation is a byte-mirror of WorkflowNode.compensation', () => {
129
+ const chainSchema = schema('workflow-chain-pack-manifest.schema.json') as {
130
+ $defs: { FragmentNode: { properties: Record<string, unknown> }; WorkflowChain: { properties: Record<string, unknown> } };
131
+ };
132
+ const wd = schema('workflow-definition.schema.json') as { $defs: { WorkflowNode: { properties: Record<string, unknown> } } };
133
+ expect(chainSchema.$defs.FragmentNode.properties['compensation'], 'FragmentNode MUST carry `compensation`').toBeDefined();
134
+ expect(stripDescription(chainSchema.$defs.FragmentNode.properties['compensation'])).toEqual(
135
+ stripDescription(wd.$defs.WorkflowNode.properties['compensation']),
136
+ );
137
+ });
138
+
139
+ it('WorkflowChain.compensation is a byte-mirror of compensation-policy.schema.json', () => {
140
+ const chainSchema = schema('workflow-chain-pack-manifest.schema.json') as { $defs: { WorkflowChain: { properties: Record<string, unknown> } } };
141
+ const policy = schema('compensation-policy.schema.json');
142
+ expect(chainSchema.$defs.WorkflowChain.properties['compensation'], 'WorkflowChain MUST carry the policy').toBeDefined();
143
+ expect(stripDescription(chainSchema.$defs.WorkflowChain.properties['compensation'])).toEqual(stripDescription(policy));
144
+ });
145
+
146
+ it('the manifest stays self-contained — no cross-file $ref was introduced', () => {
147
+ const raw = readFileSync(join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json'), 'utf8');
148
+ const refs = [...raw.matchAll(/"\$ref":\s*"([^"]+)"/g)].map((m) => m[1]);
149
+ for (const r of refs) expect(r.startsWith('#/'), `cross-file $ref ${r} would break fixed-list validators downstream`).toBe(true);
150
+ });
151
+
152
+ it('a manifest declaring both surfaces validates; a foreign key inside either is rejected', () => {
153
+ const validate = manifestValidator();
154
+ expect(validate(manifestWith(CHAIN)), JSON.stringify(validate.errors)).toBe(true);
155
+ const badNode = structuredClone(CHAIN) as unknown as { dag: { nodes: Array<Record<string, unknown>> } };
156
+ (badNode.dag.nodes[0]!['compensation'] as Record<string, unknown>)['rollback'] = true;
157
+ expect(validate(manifestWith(badNode)), 'the mirrored node declaration MUST stay closed').toBe(false);
158
+ const badPolicy = structuredClone(CHAIN) as unknown as { compensation: Record<string, unknown> };
159
+ badPolicy.compensation['approvalScope'] = 'none';
160
+ expect(validate(manifestWith(badPolicy)), 'the mirrored policy keeps the escalate-only approval scope').toBe(false);
161
+ const noTriggers = structuredClone(CHAIN) as unknown as { compensation: Record<string, unknown> };
162
+ noTriggers.compensation = {};
163
+ expect(validate(manifestWith(noTriggers)), 'a policy without triggers is not a policy').toBe(false);
164
+ });
165
+ });
166
+
167
+ describe('RFC 0157 — expansion carries the declaration and the policy', () => {
168
+ it('the declaration survives verbatim onto the expanded node, params substituted, node-id refs rewritten', () => {
169
+ const out = expandChainWithCompensation(CHAIN, CTX);
170
+ const nodes = out.nodes as ReadonlyArray<(typeof out.nodes)[number] & { compensation?: FragmentNodeCompensation }>;
171
+ const reserve = nodes.find((n) => n.id.endsWith('_reserve'));
172
+ const charge = nodes.find((n) => n.id.endsWith('_charge'));
173
+ const notify = nodes.find((n) => n.id.endsWith('_notify'));
174
+ expect(reserve?.compensation?.nodeTypeId).toBe('vendor.shop.release');
175
+ expect(reserve?.compensation?.retry).toEqual({ maxAttempts: 3, backoffMs: 250 });
176
+ // {{params.sku}} substituted (5b); ${nodes.reserve…} rewritten with the prefix (6b)
177
+ expect(reserve?.compensation?.inputMapping?.['sku']).toBe('SKU-9');
178
+ expect(reserve?.compensation?.inputMapping?.['reservationId']).toBe(`\${nodes.${reserve?.id}.output.id}`);
179
+ expect(charge?.compensation?.inputMapping?.['chargeId']).toBe(`nodes.${charge?.id}.output.chargeId`);
180
+ expect(charge?.compensation?.requiresApproval).toBe(true);
181
+ expect(notify?.compensation, 'a node that declared none gains none').toBeUndefined();
182
+ // the mirrored core is untouched: plain expandChain still knows nothing of it
183
+ const plain = expandChain(CHAIN, CTX);
184
+ expect((plain.nodes[0] as { compensation?: unknown }).compensation).toBeUndefined();
185
+ });
186
+
187
+ it('a reference to a NON-fragment node id inside inputMapping passes through unchanged', () => {
188
+ const chain = structuredClone(CHAIN);
189
+ (chain.dag.nodes[0]!.compensation as { inputMapping: Record<string, unknown> }).inputMapping = {
190
+ parentRef: '${nodes.upstream-in-parent.output.x}',
191
+ };
192
+ const out = expandChainWithCompensation(chain, CTX);
193
+ const nodes = out.nodes as ReadonlyArray<(typeof out.nodes)[number] & { compensation?: FragmentNodeCompensation }>;
194
+ const reserve = nodes.find((n) => n.id.endsWith('_reserve'));
195
+ expect(reserve?.compensation?.inputMapping?.['parentRef']).toBe('${nodes.upstream-in-parent.output.x}');
196
+ });
197
+
198
+ it('an unresolvable compensation.nodeTypeId fails with chain_unresolvable_typeid before any node is emitted', () => {
199
+ const resolvable = (t: string) => t !== 'vendor.pay.refund';
200
+ let thrown: unknown;
201
+ try {
202
+ expandChainWithCompensation(CHAIN, { ...CTX, isTypeIdResolvable: resolvable });
203
+ } catch (e) {
204
+ thrown = e;
205
+ }
206
+ expect(thrown).toBeInstanceOf(ChainUnresolvableTypeIdError);
207
+ expect((thrown as ChainUnresolvableTypeIdError).typeId).toBe('vendor.pay.refund');
208
+ expect(String((thrown as Error).message)).toContain('chain_unresolvable_typeid');
209
+ });
210
+
211
+ it('the chain policy becomes settings.compensation when the parent has none', () => {
212
+ const out = expandChainWithCompensation(CHAIN, CTX);
213
+ expect(out.settingsCompensation).toEqual(POLICY);
214
+ expect(out.settingsCompensation, 'a copy, not the same object').not.toBe(POLICY);
215
+ });
216
+
217
+ it('a parent policy that is deep-equal (any key order) is accepted; a differing one is refused, never merged', () => {
218
+ const sameOtherOrder: ChainCompensationPolicy = { approvalScope: 'declared', triggers: ['node-failure', 'run-cancel'] };
219
+ expect(expandChainWithCompensation(CHAIN, CTX, sameOtherOrder).settingsCompensation).toEqual(sameOtherOrder);
220
+ const differs: ChainCompensationPolicy = { triggers: ['node-failure'] };
221
+ let thrown: unknown;
222
+ try {
223
+ expandChainWithCompensation(CHAIN, CTX, differs);
224
+ } catch (e) {
225
+ thrown = e;
226
+ }
227
+ expect(thrown).toBeInstanceOf(ChainCompensationPolicyConflictError);
228
+ expect((thrown as ChainCompensationPolicyConflictError).code).toBe('chain_compensation_policy_conflict');
229
+ });
230
+
231
+ it('a chain with no policy inherits the parent policy (or none) and carries no declaration it did not make', () => {
232
+ const chain = structuredClone(CHAIN);
233
+ delete (chain as { compensation?: unknown }).compensation;
234
+ for (const n of chain.dag.nodes as unknown as Array<{ compensation?: unknown }>) delete n.compensation;
235
+ const parent: ChainCompensationPolicy = { triggers: ['operator-request'] };
236
+ const withParent = expandChainWithCompensation(chain, CTX, parent);
237
+ expect(withParent.settingsCompensation).toBe(parent);
238
+ const nodes = withParent.nodes as ReadonlyArray<{ compensation?: unknown }>;
239
+ expect(nodes.every((n) => n.compensation === undefined)).toBe(true);
240
+ const withoutParent = carryCompensation(chain, expandChain(chain, CTX), CTX);
241
+ expect(withoutParent.settingsCompensation).toBeUndefined();
242
+ });
243
+ });
@@ -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
 
23
24
  interface CommitmentCaps {
@@ -44,14 +45,14 @@ async function commitmentsSupported(): Promise<boolean> {
44
45
 
45
46
  describe('commitment-fired: fire contract (RFC 0068 §C, capability-gated)', () => {
46
47
  it('a fired commitment emits a content-free event with memory provenance, exactly once', async () => {
47
- if (!(await commitmentsSupported())) return; // capability absent — gated skip
48
+ if (!(await commitmentsSupported())) return softSkip('inapplicable', 'capability absent — gated skip');
48
49
 
49
50
  const res = await driver.post('/v1/host/sample/commitment/fire', {
50
51
  memoryRef: 'mem://conformance/commitments',
51
52
  condition: 'predicate',
52
53
  includeIntentionCanary: true,
53
54
  });
54
- if (res.status === 404 || res.status === 501) return; // seam not wired — soft-skip
55
+ if (res.status === 404 || res.status === 501) return softSkip('blocked', 'seam not wired — soft-skip');
55
56
 
56
57
  expect(res.status, driver.describe('RFC 0068 §C', 'an advertised commitment seam MUST succeed')).toBe(200);
57
58
  const r = res.json as FireResult;
@@ -32,6 +32,11 @@
32
32
  * - **No provider bodies or credentials in events.** §D and §G. These events
33
33
  * land in the durable log, which is the least revocable place a credential
34
34
  * can go.
35
+ * - **The snapshot rollup agrees with the events.** §D `compensationStatus` on
36
+ * `RunSnapshot` (UQ3 resolved 2026-08-16) is the deterministic fold defined in
37
+ * `spec/v1/compensation.md`; an advertising host MUST carry it, and a value
38
+ * that disagrees with the emitted events is a projection bug a client would
39
+ * act on.
35
40
  */
36
41
 
37
42
  import { describe, it, expect } from 'vitest';
@@ -140,6 +145,64 @@ describe('RFC 0151 §C — compensation lifecycle (capability-gated behavior)',
140
145
  ).toBe(0);
141
146
  });
142
147
 
148
+ it('the run snapshot carries the §D rollup, and it agrees with the events', async () => {
149
+ if (!behaviorGate(PROFILE, await advertised())) return;
150
+ const seam = await driver.post('/v1/host/sample/test/compensation/unwind', {});
151
+ if (seam.status === 404) return; // covered by the seam assertion above
152
+ const body = seam.json as { runId?: string; events?: { type: string }[] };
153
+ expect(
154
+ typeof body.runId === 'string' && body.runId.length > 0,
155
+ driver.describe(
156
+ 'host-sample-test-seams.md §21',
157
+ 'the unwind seam MUST return the `runId` it created — without it the §D rollup has no ' +
158
+ 'black-box path to a witness and lands as another shape-only claim',
159
+ ),
160
+ ).toBe(true);
161
+ const snap = await driver.get(`/v1/runs/${encodeURIComponent(body.runId ?? '')}`);
162
+ expect(snap.status, driver.describe('rest-endpoints.md', 'GET /v1/runs/{runId} for the seam run')).toBe(200);
163
+ const status = (snap.json as { compensationStatus?: unknown }).compensationStatus;
164
+ expect(
165
+ status,
166
+ driver.describe(
167
+ 'compensation.md §"Run rollup: compensationStatus"',
168
+ 'a host that advertises `compensation` MUST include `compensationStatus` on every snapshot ' +
169
+ '(`none` when idle) — presence is the wire witness of the advert',
170
+ ),
171
+ ).toBeDefined();
172
+ const types = (body.events ?? []).map((e) => e.type);
173
+ // The fold, applied to what the seam reports: a clean unwind ends `completed`; a
174
+ // recorded `manual_intervention_required` dominates; a plan that never started is
175
+ // `pending`. Anything else the seam produced is a shape this leg cannot judge from
176
+ // outside, and it says so rather than passing.
177
+ const expected = types.includes('compensation.manual_intervention_required')
178
+ ? 'manual'
179
+ : types.includes('compensation.completed')
180
+ ? 'completed'
181
+ : types.includes('compensation.started')
182
+ ? null
183
+ : types.includes('compensation.requested')
184
+ ? 'pending'
185
+ : 'none';
186
+ if (expected === null) {
187
+ expect(
188
+ ['running', 'partial', 'failed'].includes(String(status)),
189
+ driver.describe(
190
+ 'compensation.md §"Run rollup: compensationStatus"',
191
+ 'an unwind that started and did not complete MUST report `running`, `partial`, or `failed` ' +
192
+ `— got ${String(status)}`,
193
+ ),
194
+ ).toBe(true);
195
+ return;
196
+ }
197
+ expect(
198
+ status,
199
+ driver.describe(
200
+ 'compensation.md §"Run rollup: compensationStatus"',
201
+ `the rollup MUST be the deterministic fold of the compensation.* events — events ${JSON.stringify(types)} fold to \`${expected}\``,
202
+ ),
203
+ ).toBe(expected);
204
+ });
205
+
143
206
  it('compensation events carry no provider bodies or credentials', async () => {
144
207
  if (!behaviorGate(PROFILE, await advertised())) return;
145
208
  const seam = await driver.post('/v1/host/sample/test/compensation/unwind', {});