@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.
- package/README.md +3 -3
- package/coverage.md +6 -0
- package/dist/cli.js +89 -45
- package/dist/lib/certification-bundle-verify.js +274 -0
- package/dist/lib/profiles.js +102 -19
- package/dist/lib/requirement-ledger.js +182 -0
- package/dist/lib/requirement-registry.js +110 -0
- package/dist/lib/scenario-disposition.js +249 -0
- package/dist/lib/soft-skip.js +82 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +1 -1
- package/fixtures.md +1 -1
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/README.md +1 -0
- package/schemas/capabilities.schema.json +2 -2
- package/schemas/compensation-policy.schema.json +64 -0
- package/schemas/run-snapshot.schema.json +5 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +125 -0
- package/schemas/workflow-definition.schema.json +4 -0
- package/src/cli.ts +99 -45
- package/src/lib/a2a-fake-peer.ts +290 -110
- package/src/lib/certification-bundle-verify.ts +356 -0
- package/src/lib/error-envelope.ts +77 -0
- package/src/lib/llm-cache-key-recipe.ts +6 -5
- package/src/lib/mcp-fake-server.ts +235 -72
- package/src/lib/profiles.ts +132 -19
- package/src/lib/requirement-ledger.ts +98 -1
- package/src/lib/requirement-registry.ts +47 -4
- package/src/lib/scenario-disposition.ts +291 -0
- package/src/lib/soft-skip.ts +89 -0
- package/src/lib/workflow-chain-expansion.ts +189 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +179 -0
- package/src/scenarios/a2a-1-0-task-roundtrip.test.ts +109 -0
- package/src/scenarios/a2a-card-runtime-consistency.test.ts +167 -0
- package/src/scenarios/a2a-peer-authority.test.ts +99 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +18 -6
- package/src/scenarios/agent-loop-iteration-monotonic.test.ts +3 -2
- package/src/scenarios/agent-loop-stateful-resume.test.ts +3 -2
- package/src/scenarios/agent-loop-version5-shape.test.ts +2 -1
- package/src/scenarios/agent-loop-workspace-snapshot.test.ts +4 -3
- package/src/scenarios/approval-gate-flow.test.ts +5 -4
- package/src/scenarios/artifact-type-store-without-render.test.ts +2 -1
- package/src/scenarios/auth-scim-profile.test.ts +5 -4
- package/src/scenarios/authorization-fail-closed.test.ts +4 -3
- package/src/scenarios/certification-bundle-non-vacuous.test.ts +249 -0
- package/src/scenarios/certification-bundle-redaction.test.ts +182 -0
- package/src/scenarios/certification-floor-enforcement.test.ts +30 -6
- package/src/scenarios/chain-compensation-expansion.test.ts +243 -0
- package/src/scenarios/commitment-fired.test.ts +3 -2
- package/src/scenarios/compensation-behavior.test.ts +63 -0
- package/src/scenarios/compensation-profile.test.ts +144 -1
- package/src/scenarios/conformance-advertised-seam-required.test.ts +118 -0
- package/src/scenarios/conformance-execution-witness.test.ts +104 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +3 -2
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +26 -0
- package/src/scenarios/credential-payload-redaction.test.ts +4 -3
- package/src/scenarios/credentials-capability-shape.test.ts +4 -3
- package/src/scenarios/cross-engine-append-behavior.test.ts +5 -4
- package/src/scenarios/cross-engine-append-ordering.test.ts +3 -2
- package/src/scenarios/cross-host-ancestry-endpoint.test.ts +5 -4
- package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
- package/src/scenarios/cross-workspace-isolation.test.ts +4 -3
- package/src/scenarios/data-residency-admission.test.ts +6 -6
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +5 -4
- package/src/scenarios/distillation-index-roundtrip.test.ts +3 -2
- package/src/scenarios/distillation-secret-carryforward.test.ts +3 -2
- package/src/scenarios/distillation-shape.test.ts +2 -1
- package/src/scenarios/distillation-stable-archive.test.ts +4 -3
- package/src/scenarios/distillation-token-budget.test.ts +4 -3
- package/src/scenarios/envelope-recovery-applied.test.ts +3 -2
- package/src/scenarios/envelope-refusal-shape.test.ts +4 -3
- package/src/scenarios/error-envelope-canonical-shape.test.ts +64 -0
- package/src/scenarios/feedback-correction-redaction.test.ts +4 -3
- package/src/scenarios/feedback-cross-tenant-isolation.test.ts +4 -3
- package/src/scenarios/feedback-fork-not-copied.test.ts +6 -5
- package/src/scenarios/feedback-on-terminal-run.test.ts +4 -3
- package/src/scenarios/feedback-record-and-list.test.ts +4 -3
- package/src/scenarios/feedback-unsupported-501.test.ts +3 -2
- package/src/scenarios/fixtures-valid.test.ts +16 -18
- package/src/scenarios/fs-path-traversal.test.ts +3 -2
- package/src/scenarios/heartbeat-capability-shape.test.ts +2 -1
- package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +4 -3
- package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +6 -5
- package/src/scenarios/heartbeat-runtime-bound.test.ts +4 -3
- package/src/scenarios/mcp-2026-07-28-discover.test.ts +207 -0
- package/src/scenarios/mcp-cache-tenant-scope.test.ts +66 -0
- package/src/scenarios/mcp-current-auth-boundary.test.ts +55 -0
- package/src/scenarios/mcp-extension-opacity.test.ts +79 -0
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +140 -0
- package/src/scenarios/mcp-stateless-request.test.ts +77 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +4 -3
- package/src/scenarios/mcp-toolcall-redaction.test.ts +2 -1
- package/src/scenarios/memory-attribution-emits-on-write.test.ts +6 -5
- package/src/scenarios/memory-attribution-no-content.test.ts +4 -3
- package/src/scenarios/memory-attribution-replay-stable.test.ts +6 -5
- package/src/scenarios/memory-attribution-tenant-scoped.test.ts +4 -3
- package/src/scenarios/memory-compaction-event-emitted.test.ts +3 -2
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +6 -5
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +4 -3
- package/src/scenarios/memory-consolidation-idempotent.test.ts +4 -3
- package/src/scenarios/memory-injection-budget.test.ts +4 -3
- package/src/scenarios/metric-emission.test.ts +4 -3
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
- package/src/scenarios/multi-agent-handoff-state-machine.test.ts +4 -3
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +5 -4
- package/src/scenarios/multi-party-conversation-behavioral.test.ts +3 -2
- package/src/scenarios/multi-region-idempotency-behavior.test.ts +7 -6
- package/src/scenarios/multi-region-idempotency.test.ts +4 -3
- package/src/scenarios/normative-example-extraction.test.ts +242 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +4 -3
- package/src/scenarios/oauth-capability-shape.test.ts +4 -3
- package/src/scenarios/oauth-connector-redaction.test.ts +4 -3
- package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +309 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +2 -1
- package/src/scenarios/otel-emission-grpc.test.ts +4 -3
- package/src/scenarios/otel-emission.test.ts +4 -3
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +5 -4
- package/src/scenarios/otel-trace-propagation.test.ts +4 -3
- package/src/scenarios/pack-registry-isolation.test.ts +4 -3
- package/src/scenarios/pack-registry-publish.test.ts +65 -3
- package/src/scenarios/pack-registry.test.ts +22 -0
- package/src/scenarios/profile-discovery-core-alias.test.ts +137 -0
- package/src/scenarios/profileDerivation.test.ts +2 -1
- package/src/scenarios/provider-usage.test.ts +2 -2
- package/src/scenarios/rate-limit-envelope.test.ts +2 -1
- package/src/scenarios/registry-public.test.ts +6 -5
- package/src/scenarios/replay-divergence-at-refusal.test.ts +9 -8
- package/src/scenarios/replay-llm-cache-key-portable.test.ts +19 -13
- package/src/scenarios/replay-llm-cache-key.test.ts +120 -47
- package/src/scenarios/replay-observable-sequence-determinism.test.ts +5 -4
- package/src/scenarios/replay-side-effect-suppression.test.ts +20 -1
- package/src/scenarios/requirement-ledger.test.ts +29 -6
- package/src/scenarios/runner-ledger.test.ts +279 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +6 -5
- package/src/scenarios/sandbox-memory-cap.test.ts +3 -2
- package/src/scenarios/sandbox-mvp-behavior.test.ts +11 -10
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +3 -2
- package/src/scenarios/stream-text-fixture.test.ts +5 -4
- package/src/scenarios/subrun-approval-fail-closed.test.ts +3 -2
- package/src/scenarios/subrun-approval-gate.test.ts +4 -3
- package/src/scenarios/subrun-attestation-shape.test.ts +2 -1
- package/src/scenarios/subrun-checksum-stable.test.ts +4 -3
- package/src/scenarios/table-schema-enforcement.test.ts +2 -2
- package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +3 -2
- package/src/scenarios/tool-hooks-content-free.test.ts +3 -2
- package/src/scenarios/tool-hooks-rate-limit.test.ts +3 -2
- package/src/scenarios/tool-hooks-secret-redaction.test.ts +3 -2
- package/src/scenarios/tool-hooks-shape.test.ts +2 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +3 -2
- package/src/scenarios/version-fold.test.ts +5 -4
- package/src/scenarios/voice-streamref-tenant-bound.test.ts +2 -1
- package/src/scenarios/voice-transcription-streaming.test.ts +2 -1
- package/src/scenarios/voice-transcription-unadvertised.test.ts +2 -1
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +5 -4
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +3 -2
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +3 -2
- package/src/scenarios/wasm-pack-load.test.ts +4 -3
- package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -3
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +3 -2
- package/src/scenarios/webhook-signed-delivery.test.ts +4 -3
- package/src/scenarios/workflow-primary-output-annotation.test.ts +13 -23
- package/src/scenarios/workload-identity-behavior.test.ts +7 -4
- package/src/scenarios/workspace-cross-tenant-isolation-blackbox.test.ts +3 -2
- package/src/scenarios/workspace-cross-tenant-isolation.test.ts +3 -2
- 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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// having failed
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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', {});
|