@openwop/openwop-conformance 2.42.5 → 2.42.7
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/CHANGELOG.md +26 -0
- package/README.md +3 -3
- package/coverage.md +1 -1
- package/dist/lib/soft-skip.js +13 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +430 -162
- package/scenario-majors.json +16 -3
- package/schemas/CORPUS-STAMP.json +64 -63
- package/src/lib/audit-checkpoint.ts +72 -0
- package/src/lib/era2-seed.ts +30 -0
- package/src/lib/ext-claims.ts +96 -0
- package/src/lib/soft-skip.ts +14 -0
- package/src/scenarios/a2a-peer-authority.test.ts +6 -13
- package/src/scenarios/a2a-task-roundtrip.test.ts +6 -34
- package/src/scenarios/a2a-version-negotiation.test.ts +6 -12
- package/src/scenarios/a2ui-surface-delta-transport.test.ts +23 -12
- package/src/scenarios/agent-channel-dispatch.test.ts +13 -4
- package/src/scenarios/agent-live-invocation-bracket.test.ts +1 -1
- package/src/scenarios/agent-manifest-runtime.test.ts +3 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +2 -2
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +2 -2
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +2 -2
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +2 -2
- package/src/scenarios/aiproviders-selfhosted-honesty.test.ts +2 -2
- package/src/scenarios/append-ordering.test.ts +26 -21
- package/src/scenarios/artifact-auth.test.ts +3 -1
- package/src/scenarios/artifact-type-registration-source.test.ts +2 -2
- package/src/scenarios/audit-checkpoint-signature.test.ts +63 -0
- package/src/scenarios/audit-checkpoint-vectors.test.ts +93 -0
- package/src/scenarios/auth-mtls.test.ts +7 -6
- package/src/scenarios/auth-subject-link-key-class.test.ts +3 -3
- package/src/scenarios/auth-subject-link.test.ts +19 -18
- package/src/scenarios/blob-presign-expiry.test.ts +2 -2
- package/src/scenarios/byok-roundtrip.test.ts +4 -1
- package/src/scenarios/compensation-behavior.test.ts +10 -5
- package/src/scenarios/conformance-advertised-seam-required.test.ts +6 -0
- package/src/scenarios/connection-pack-apihosts.test.ts +2 -2
- package/src/scenarios/connection-provider-resolution.test.ts +2 -2
- package/src/scenarios/context-budget-transcript-bound.test.ts +7 -3
- package/src/scenarios/context-summarization-replay.test.ts +2 -0
- package/src/scenarios/cost-attribution.test.ts +3 -1
- package/src/scenarios/debugBundle.test.ts +12 -4
- package/src/scenarios/discovery.test.ts +6 -2
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +2 -2
- package/src/scenarios/dispatch-input-mapping.test.ts +2 -2
- package/src/scenarios/dispatch-output-mapping.test.ts +15 -2
- package/src/scenarios/distillation-token-budget.test.ts +2 -2
- package/src/scenarios/durability-poison-exhaustion.test.ts +2 -2
- package/src/scenarios/envelope-nl-to-format-engaged.test.ts +2 -2
- package/src/scenarios/envelope-recovery-applied.test.ts +2 -2
- package/src/scenarios/envelope-refusal-shape.test.ts +2 -2
- package/src/scenarios/envelope-retry-attempted.test.ts +2 -2
- package/src/scenarios/envelope-retry-exhausted.test.ts +2 -2
- package/src/scenarios/envelope-tier-one-subset-static.test.ts +1 -1
- package/src/scenarios/envelope-truncation-cap-exhaustion.test.ts +2 -2
- package/src/scenarios/eventOrdering.test.ts +17 -10
- package/src/scenarios/form-content-instantiation.test.ts +2 -2
- package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +2 -2
- package/src/scenarios/highConcurrency.test.ts +9 -9
- package/src/scenarios/idempotency-concurrent-claim.test.ts +5 -5
- package/src/scenarios/idempotency-key-determinism.test.ts +8 -8
- package/src/scenarios/idempotency.test.ts +6 -6
- package/src/scenarios/idempotencyRetry.test.ts +16 -11
- package/src/scenarios/interrupt-token-matrix.test.ts +24 -11
- package/src/scenarios/mcp-cache-tenant-scope.test.ts +3 -3
- package/src/scenarios/mcp-current-auth-boundary.test.ts +9 -6
- package/src/scenarios/mcp-extension-opacity.test.ts +6 -5
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +9 -8
- package/src/scenarios/mcp-version-negotiation.test.ts +5 -10
- package/src/scenarios/media-url-inline-cap.test.ts +3 -2
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +2 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +2 -2
- package/src/scenarios/memory-consolidation-idempotent.test.ts +2 -2
- package/src/scenarios/memory-injection-budget.test.ts +2 -2
- package/src/scenarios/multi-party-conversation-behavioral.test.ts +2 -2
- package/src/scenarios/multi-region-idempotency-behavior.test.ts +9 -9
- package/src/scenarios/multi-region-idempotency.test.ts +2 -2
- package/src/scenarios/pack-registry-isolation.test.ts +13 -12
- package/src/scenarios/pack-registry.test.ts +3 -2
- package/src/scenarios/pause-resume.test.ts +30 -11
- package/src/scenarios/prompt-read-workspace-membership-enforced.test.ts +43 -34
- package/src/scenarios/provider-usage.test.ts +2 -2
- package/src/scenarios/redaction.test.ts +7 -5
- package/src/scenarios/redactionAdversarial.test.ts +5 -4
- package/src/scenarios/registry-public.test.ts +5 -1
- package/src/scenarios/replay-divergence-at-refusal.test.ts +10 -7
- package/src/scenarios/replay-fanout-suppression.test.ts +7 -4
- package/src/scenarios/replay-fork-arbitrary.test.ts +2 -2
- package/src/scenarios/replay-side-effect-suppression.test.ts +2 -2
- package/src/scenarios/replayDeterminism.test.ts +9 -4
- package/src/scenarios/runner-ledger.test.ts +4 -0
- package/src/scenarios/runtime-capabilities.test.ts +2 -0
- package/src/scenarios/speech-synthesis-roundtrip.test.ts +1 -1
- package/src/scenarios/streamReconnect.test.ts +2 -2
- package/src/scenarios/subrun-approval-gate.test.ts +2 -2
- package/src/scenarios/subworkflow-input-mapping.test.ts +2 -2
- package/src/scenarios/tool-catalog-projection.test.ts +10 -7
- package/src/scenarios/tool-session-lifecycle.test.ts +1 -1
- package/src/scenarios/trigger-ingestion.test.ts +2 -2
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +15 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +12 -0
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +39 -24
- package/src/scenarios/v2-ext-family-claims.test.ts +62 -0
- package/src/scenarios/v2-ext-rest-transport.test.ts +151 -0
- package/src/scenarios/v2-idempotency-in-flight.test.ts +3 -0
- package/src/scenarios/v2-legacy-profiles-absent.test.ts +4 -6
- package/src/scenarios/v2-mcp-tasks.test.ts +1 -1
- package/src/scenarios/v2-min-client-version.test.ts +2 -0
- package/src/scenarios/v2-refresh-sla.test.ts +6 -5
- package/src/scenarios/v2-sse-last-event-id-cursor.test.ts +3 -3
- package/src/scenarios/v2-sse-last-event-id.test.ts +2 -0
- package/src/scenarios/v2-version-header-honored.test.ts +3 -0
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +2 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +8 -1
- package/src/scenarios/verifier-gating.test.ts +2 -2
- package/src/scenarios/version-fold.test.ts +2 -2
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +7 -6
- package/src/scenarios/webhook-signed-delivery.test.ts +2 -0
- package/src/scenarios/workflow-chain-host-expansion.test.ts +10 -1
- package/src/scenarios/workflow-variable-format.test.ts +2 -2
- package/src/scenarios/workload-identity-chain-bounds.test.ts +6 -3
- package/vectors/audit-checkpoint-v1.json +260 -0
|
@@ -20,7 +20,7 @@ import { driver } from '../lib/driver.js';
|
|
|
20
20
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
21
21
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
22
22
|
import { req } from '../lib/requirement-ids.js';
|
|
23
|
-
import {
|
|
23
|
+
import { blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
24
24
|
|
|
25
25
|
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
26
26
|
const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
|
|
@@ -42,6 +42,13 @@ function getSeq(event: RawEvent): number | null {
|
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Every early return below follows the run-create assertion, so each is
|
|
46
|
+
// `blockedDespiteAssertions`: rest-endpoints.md §"Required endpoints" makes
|
|
47
|
+
// /events/poll a MUST on every host, and observability.md makes run.started and
|
|
48
|
+
// exactly one terminal event MUSTs, so a completed noop run on a conforming host
|
|
49
|
+
// always yields a readable events[] of at least two. A host that does not was
|
|
50
|
+
// never observed ordering anything — until 2026-09-27 these recorded a
|
|
51
|
+
// partial-witness PASS.
|
|
45
52
|
describe.skipIf(SKIP_NO_NOOP)('event-ordering: polling returns events in monotonic order', () => {
|
|
46
53
|
it('events from a single poll have non-decreasing sequence numbers', async () => {
|
|
47
54
|
const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
|
|
@@ -50,11 +57,11 @@ describe.skipIf(SKIP_NO_NOOP)('event-ordering: polling returns events in monoton
|
|
|
50
57
|
await pollUntilTerminal(runId);
|
|
51
58
|
|
|
52
59
|
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll`);
|
|
53
|
-
if (res.status !== 200) return
|
|
60
|
+
if (res.status !== 200) return blockedDespiteAssertions('precondition not met — `res.status !== 200` returned early (seam, prior step, or fixture unavailable)');
|
|
54
61
|
|
|
55
62
|
const body = res.json as { events?: RawEvent[] } | undefined;
|
|
56
|
-
if (!body?.events) return
|
|
57
|
-
if (body.events.length < 2) return
|
|
63
|
+
if (!body?.events) return blockedDespiteAssertions('precondition not met — `!body?.events` returned early (seam, prior step, or fixture unavailable)');
|
|
64
|
+
if (body.events.length < 2) return blockedDespiteAssertions('precondition not met — `body.events.length < 2` returned early (single-event runs have no ordering to verify) (seam, prior step, or fixture unavailable)'); // single-event runs have no ordering to verify
|
|
58
65
|
|
|
59
66
|
const seqs = body.events.map(getSeq);
|
|
60
67
|
for (let i = 1; i < seqs.length; i++) {
|
|
@@ -76,11 +83,11 @@ describe.skipIf(SKIP_NO_NOOP)('event-ordering: polling returns events in monoton
|
|
|
76
83
|
|
|
77
84
|
const a = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll`);
|
|
78
85
|
const b = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll`);
|
|
79
|
-
if (a.status !== 200 || b.status !== 200) return
|
|
86
|
+
if (a.status !== 200 || b.status !== 200) return blockedDespiteAssertions('precondition not met — `a.status !== 200 || b.status !== 200` returned early (seam, prior step, or fixture unavailable)');
|
|
80
87
|
|
|
81
88
|
const aBody = a.json as { events?: RawEvent[] } | undefined;
|
|
82
89
|
const bBody = b.json as { events?: RawEvent[] } | undefined;
|
|
83
|
-
if (!aBody?.events || !bBody?.events) return
|
|
90
|
+
if (!aBody?.events || !bBody?.events) return blockedDespiteAssertions('precondition not met — `!aBody?.events || !bBody?.events` returned early (seam, prior step, or fixture unavailable)');
|
|
84
91
|
|
|
85
92
|
expect(aBody.events.length, req('openwop.it.eventOrdering.repeated-polls-of-a-terminal-run-yield-identical-event-sequences',
|
|
86
93
|
'observability.md',
|
|
@@ -107,10 +114,10 @@ describe.skipIf(SKIP_NO_NOOP)('event-ordering: terminal run has at most one term
|
|
|
107
114
|
await pollUntilTerminal(runId);
|
|
108
115
|
|
|
109
116
|
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll`);
|
|
110
|
-
if (res.status !== 200) return
|
|
117
|
+
if (res.status !== 200) return blockedDespiteAssertions('precondition not met — `res.status !== 200` returned early (seam, prior step, or fixture unavailable)');
|
|
111
118
|
|
|
112
119
|
const body = res.json as { events?: RawEvent[] } | undefined;
|
|
113
|
-
if (!body?.events) return
|
|
120
|
+
if (!body?.events) return blockedDespiteAssertions('precondition not met — `!body?.events` returned early (seam, prior step, or fixture unavailable)');
|
|
114
121
|
|
|
115
122
|
const TERMINAL_TYPES = new Set(['run.completed', 'run.failed', 'run.cancelled']);
|
|
116
123
|
const terminalCount = body.events.filter((e) => typeof e.type === 'string' && TERMINAL_TYPES.has(e.type)).length;
|
|
@@ -128,10 +135,10 @@ describe.skipIf(SKIP_NO_NOOP)('event-ordering: terminal run has at most one term
|
|
|
128
135
|
await pollUntilTerminal(runId);
|
|
129
136
|
|
|
130
137
|
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll`);
|
|
131
|
-
if (res.status !== 200) return
|
|
138
|
+
if (res.status !== 200) return blockedDespiteAssertions('precondition not met — `res.status !== 200` returned early (seam, prior step, or fixture unavailable)');
|
|
132
139
|
|
|
133
140
|
const body = res.json as { events?: RawEvent[] } | undefined;
|
|
134
|
-
if (!body?.events || body.events.length === 0) return
|
|
141
|
+
if (!body?.events || body.events.length === 0) return blockedDespiteAssertions('precondition not met — `!body?.events || body.events.length === 0` returned early (seam, prior step, or fixture unavailable)');
|
|
135
142
|
|
|
136
143
|
const TERMINAL_TYPES = new Set(['run.completed', 'run.failed', 'run.cancelled']);
|
|
137
144
|
const lastEvent = body.events[body.events.length - 1]!;
|
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
PLAIN_TEXT_CONTROLS,
|
|
66
66
|
} from '../lib/formContentPacks.js';
|
|
67
67
|
import { req } from '../lib/requirement-ids.js';
|
|
68
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
68
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Conformance fixture templates a host wires the seam against.
|
|
@@ -140,7 +140,7 @@ describe('form-content-instantiation: a host instantiates a registered template
|
|
|
140
140
|
).toBe(true);
|
|
141
141
|
|
|
142
142
|
const field = fieldById(res.fields, VENDOR_FIELD_ID);
|
|
143
|
-
if (field === undefined) return
|
|
143
|
+
if (field === undefined) return blockedDespiteAssertions('the instantiate seam reports no fields[] entry for the vendor field — the plain-text degrade is unobserved');
|
|
144
144
|
expect(
|
|
145
145
|
field.control !== undefined && PLAIN_TEXT_CONTROLS.has(field.control),
|
|
146
146
|
req('openwop.it.form-content-instantiation.2-an-unrecognized-vendor-field-type-degrades-to-plain-text-and-does-not-fail-the',
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { describe, it, expect } from 'vitest';
|
|
14
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
14
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
15
15
|
import { readHeartbeatCap, heartbeatSupported, tickHeartbeat } from '../lib/heartbeat.js';
|
|
16
16
|
import { req } from '../lib/requirement-ids.js';
|
|
17
17
|
|
|
@@ -35,7 +35,7 @@ describe('heartbeat-idempotent-no-spam (RFC 0060 §B.5)', () => {
|
|
|
35
35
|
req('openwop.it.heartbeat-idempotent-no-spam.an-unchanged-tick-enqueues-nothing-only-a-transition-does', 'RFC 0060 §B.5', 'an unchanged tick MUST NOT emit heartbeat.stateChanged'),
|
|
36
36
|
).toBe(0);
|
|
37
37
|
const transition = await tickHeartbeat({ heartbeatId: hb, observedState: { unread: 3 } });
|
|
38
|
-
if (transition === null) return
|
|
38
|
+
if (transition === null) return blockedDespiteAssertions('the heartbeat seam answered the unchanged ticks but not the transition — the one-stateChanged-per-transition rule is unobserved');
|
|
39
39
|
expect(
|
|
40
40
|
changedCount(transition.json),
|
|
41
41
|
req('openwop.it.heartbeat-idempotent-no-spam.an-unchanged-tick-enqueues-nothing-only-a-transition-does', 'RFC 0060 §B.5', 'a transitioning tick MUST emit exactly one heartbeat.stateChanged'),
|
|
@@ -83,7 +83,7 @@ describe.skipIf(SKIP)(
|
|
|
83
83
|
expect(
|
|
84
84
|
succeeded.length + conflicted.length,
|
|
85
85
|
req('openwop.it.highConcurrency.10-parallel-requests-with-same-key-yield-one-runid-and-9-replays',
|
|
86
|
-
'idempotency.md §Concurrent duplicates',
|
|
86
|
+
'spec/v1/idempotency.md §Concurrent duplicates',
|
|
87
87
|
'every concurrent request MUST resolve to either the cached response or a deterministic 409',
|
|
88
88
|
),
|
|
89
89
|
).toBe(10);
|
|
@@ -92,7 +92,7 @@ describe.skipIf(SKIP)(
|
|
|
92
92
|
expect(
|
|
93
93
|
runIds.size,
|
|
94
94
|
req('openwop.it.highConcurrency.10-parallel-requests-with-same-key-yield-one-runid-and-9-replays',
|
|
95
|
-
'idempotency.md §Layer 1',
|
|
95
|
+
'spec/v1/idempotency.md §Layer 1',
|
|
96
96
|
'same idempotency key MUST yield exactly ONE runId across all successful responses',
|
|
97
97
|
),
|
|
98
98
|
).toBe(1);
|
|
@@ -103,7 +103,7 @@ describe.skipIf(SKIP)(
|
|
|
103
103
|
expect(
|
|
104
104
|
c.errorCode === 'idempotency_in_flight' || c.errorCode === undefined,
|
|
105
105
|
req('openwop.it.highConcurrency.10-parallel-requests-with-same-key-yield-one-runid-and-9-replays',
|
|
106
|
-
'idempotency.md',
|
|
106
|
+
'spec/v1/idempotency.md',
|
|
107
107
|
'409 on parallel idempotency-keyed retry MUST carry error="idempotency_in_flight"',
|
|
108
108
|
),
|
|
109
109
|
).toBe(true);
|
|
@@ -111,7 +111,7 @@ describe.skipIf(SKIP)(
|
|
|
111
111
|
expect(
|
|
112
112
|
c.retryAfter,
|
|
113
113
|
req('openwop.it.highConcurrency.10-parallel-requests-with-same-key-yield-one-runid-and-9-replays',
|
|
114
|
-
'idempotency.md',
|
|
114
|
+
'spec/v1/idempotency.md',
|
|
115
115
|
'409 idempotency_in_flight MUST include a numeric retryAfter',
|
|
116
116
|
),
|
|
117
117
|
).toBeGreaterThan(0);
|
|
@@ -150,7 +150,7 @@ describe.skipIf(SKIP)(
|
|
|
150
150
|
expect(
|
|
151
151
|
uniqueRunIds.size,
|
|
152
152
|
req('openwop.it.highConcurrency.10-parallel-requests-with-distinct-keys-yield-10-distinct-runids',
|
|
153
|
-
'idempotency.md §Layer 1',
|
|
153
|
+
'spec/v1/idempotency.md §Layer 1',
|
|
154
154
|
'distinct idempotency keys MUST yield distinct runIds (Layer 1 dedup is keyed)',
|
|
155
155
|
),
|
|
156
156
|
).toBe(succeededRunIds.length);
|
|
@@ -195,7 +195,7 @@ describe.skipIf(SKIP)(
|
|
|
195
195
|
expect(
|
|
196
196
|
runIds.size,
|
|
197
197
|
req('openwop.it.highConcurrency.5-sequential-retries-with-same-key-100ms-apart-all-succeed-idempotency-cache-sur',
|
|
198
|
-
'idempotency.md §Layer 1',
|
|
198
|
+
'spec/v1/idempotency.md §Layer 1',
|
|
199
199
|
'all 5 retries with same key MUST resolve to the same runId',
|
|
200
200
|
),
|
|
201
201
|
).toBe(1);
|
|
@@ -204,7 +204,7 @@ describe.skipIf(SKIP)(
|
|
|
204
204
|
expect(
|
|
205
205
|
results[0]!.replay,
|
|
206
206
|
req('openwop.it.highConcurrency.5-sequential-retries-with-same-key-100ms-apart-all-succeed-idempotency-cache-sur',
|
|
207
|
-
'idempotency.md §Server responsibilities',
|
|
207
|
+
'spec/v1/idempotency.md §Server responsibilities',
|
|
208
208
|
'first request with new key MUST NOT be marked as replay',
|
|
209
209
|
),
|
|
210
210
|
).toBe(false);
|
|
@@ -216,7 +216,7 @@ describe.skipIf(SKIP)(
|
|
|
216
216
|
expect(
|
|
217
217
|
someReplay,
|
|
218
218
|
req('openwop.it.highConcurrency.5-sequential-retries-with-same-key-100ms-apart-all-succeed-idempotency-cache-sur',
|
|
219
|
-
'idempotency.md §Server responsibilities #2',
|
|
219
|
+
'spec/v1/idempotency.md §Server responsibilities #2',
|
|
220
220
|
'replay responses SHOULD set openwop-Idempotent-Replay: true',
|
|
221
221
|
),
|
|
222
222
|
).toBe(true);
|
|
@@ -252,7 +252,7 @@ describe.skipIf(SKIP)(
|
|
|
252
252
|
expect(
|
|
253
253
|
typeof ackTimeout === 'number' && Number.isInteger(ackTimeout) && ackTimeout >= 5,
|
|
254
254
|
req('openwop.it.highConcurrency.concurrent-distinct-key-requests-respect-advertised-idempotency-cache-retention',
|
|
255
|
-
'idempotency.md',
|
|
255
|
+
'spec/v1/idempotency.md',
|
|
256
256
|
'limits.idempotencyAckTimeoutSec MUST be integer ≥5 when advertised',
|
|
257
257
|
),
|
|
258
258
|
).toBe(true);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" — the
|
|
2
|
+
* RFC 0150 §B / `spec/v1/idempotency.md` §"Concurrent duplicates (Layer 2)" — the
|
|
3
3
|
* atomic-claim witness. Gap **G17**.
|
|
4
4
|
*
|
|
5
5
|
* The rule: the persist that guards a side effect MUST be an **atomic claim** —
|
|
@@ -102,7 +102,7 @@ describe('idempotency-concurrent-claim: two executors of one run, one effect (RF
|
|
|
102
102
|
expect(
|
|
103
103
|
unique.size,
|
|
104
104
|
req('openwop.it.idempotency-concurrent-claim.every-executor-mints-the-same-logicalinvocationid-without-this-one-delivery-prov',
|
|
105
|
-
'idempotency.md §"Idempotency key composition" (Across a recovery boundary)',
|
|
105
|
+
'spec/v1/idempotency.md §"Idempotency key composition" (Across a recovery boundary)',
|
|
106
106
|
'all executors MUST mint one identity — differing ids mean they never collided, so a single delivery is a vacuous pass rather than a working claim',
|
|
107
107
|
),
|
|
108
108
|
).toBe(1);
|
|
@@ -119,7 +119,7 @@ describe('idempotency-concurrent-claim: two executors of one run, one effect (RF
|
|
|
119
119
|
expect(
|
|
120
120
|
out.delivered,
|
|
121
121
|
req('openwop.it.idempotency-concurrent-claim.exactly-one-effect-escapes-however-many-executors-attempt-it',
|
|
122
|
-
'idempotency.md §"Concurrent duplicates (Layer 2)"',
|
|
122
|
+
'spec/v1/idempotency.md §"Concurrent duplicates (Layer 2)"',
|
|
123
123
|
'the engine MUST ensure at most one concurrent executor performs the external effect — the guarding persist MUST be an atomic claim, not a read-then-write',
|
|
124
124
|
),
|
|
125
125
|
).toBe(1);
|
|
@@ -156,7 +156,7 @@ describe('idempotency-concurrent-claim: two executors of one run, one effect (RF
|
|
|
156
156
|
expect(
|
|
157
157
|
out.delivered,
|
|
158
158
|
req('openwop.it.idempotency-concurrent-claim.the-race-is-real-at-higher-concurrency-too-a-claim-that-only-holds-at-2-is-not-a',
|
|
159
|
-
'idempotency.md §"Concurrent duplicates (Layer 2)"',
|
|
159
|
+
'spec/v1/idempotency.md §"Concurrent duplicates (Layer 2)"',
|
|
160
160
|
'exactly one effect regardless of how many executors race — at most one wins the compare-and-set, the rest observe the hit',
|
|
161
161
|
),
|
|
162
162
|
).toBe(1);
|
|
@@ -167,7 +167,7 @@ describe('idempotency-concurrent-claim: two executors of one run, one effect (RF
|
|
|
167
167
|
).toBe(out.attempted);
|
|
168
168
|
expect(
|
|
169
169
|
new Set(ids.map((x) => JSON.stringify(x))).size,
|
|
170
|
-
req('openwop.it.idempotency-concurrent-claim.the-race-is-real-at-higher-concurrency-too-a-claim-that-only-holds-at-2-is-not-a', 'idempotency.md §"Idempotency key composition"', 'one identity across all executors at any concurrency'),
|
|
170
|
+
req('openwop.it.idempotency-concurrent-claim.the-race-is-real-at-higher-concurrency-too-a-claim-that-only-holds-at-2-is-not-a', 'spec/v1/idempotency.md §"Idempotency key composition"', 'one identity across all executors at any concurrency'),
|
|
171
171
|
).toBe(1);
|
|
172
172
|
});
|
|
173
173
|
});
|
|
@@ -135,7 +135,7 @@ describe('category: core.openwop.http.idempotency-key — determinism contract',
|
|
|
135
135
|
};
|
|
136
136
|
const a = await idempotencyKey(ctx);
|
|
137
137
|
const b = await idempotencyKey(ctx);
|
|
138
|
-
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.default-mode-composite-identical-runid-nodeid-payload-produces-identical-keys', 'idempotency.md §"Idempotency-Key', 'idempotency.md §Idempotency-Key: same logical request MUST produce same key')).toBe(b.outputs.key);
|
|
138
|
+
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.default-mode-composite-identical-runid-nodeid-payload-produces-identical-keys', 'spec/v1/idempotency.md §"Idempotency-Key', 'spec/v1/idempotency.md §Idempotency-Key: same logical request MUST produce same key')).toBe(b.outputs.key);
|
|
139
139
|
expect(a.outputs.key).toMatch(KEY_PATTERN);
|
|
140
140
|
});
|
|
141
141
|
|
|
@@ -144,7 +144,7 @@ describe('category: core.openwop.http.idempotency-key — determinism contract',
|
|
|
144
144
|
const baseCtx = { runId: 'run-1', nodeId: 'node-1' };
|
|
145
145
|
const a = await idempotencyKey({ ...baseCtx, inputs: { payload: { hello: 'world' } } });
|
|
146
146
|
const b = await idempotencyKey({ ...baseCtx, inputs: { payload: { hello: 'CHANGED' } } });
|
|
147
|
-
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.payload-sensitivity-different-payload-produces-different-key', 'idempotency.md §"Idempotency-Key', 'payload sensitivity — different payload produces different key')).not.toBe(b.outputs.key);
|
|
147
|
+
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.payload-sensitivity-different-payload-produces-different-key', 'spec/v1/idempotency.md §"Idempotency-Key', 'payload sensitivity — different payload produces different key')).not.toBe(b.outputs.key);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
it('run isolation — different runId produces different key', async () => {
|
|
@@ -152,7 +152,7 @@ describe('category: core.openwop.http.idempotency-key — determinism contract',
|
|
|
152
152
|
const payload = { hello: 'world' };
|
|
153
153
|
const a = await idempotencyKey({ runId: 'run-1', nodeId: 'node-1', inputs: { payload } });
|
|
154
154
|
const b = await idempotencyKey({ runId: 'run-2', nodeId: 'node-1', inputs: { payload } });
|
|
155
|
-
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.run-isolation-different-runid-produces-different-key', 'idempotency.md §"Idempotency-Key', 'run isolation — different runId produces different key')).not.toBe(b.outputs.key);
|
|
155
|
+
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.run-isolation-different-runid-produces-different-key', 'spec/v1/idempotency.md §"Idempotency-Key', 'run isolation — different runId produces different key')).not.toBe(b.outputs.key);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
it('node isolation — different nodeId produces different key', async () => {
|
|
@@ -160,7 +160,7 @@ describe('category: core.openwop.http.idempotency-key — determinism contract',
|
|
|
160
160
|
const payload = { hello: 'world' };
|
|
161
161
|
const a = await idempotencyKey({ runId: 'run-1', nodeId: 'node-A', inputs: { payload } });
|
|
162
162
|
const b = await idempotencyKey({ runId: 'run-1', nodeId: 'node-B', inputs: { payload } });
|
|
163
|
-
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.node-isolation-different-nodeid-produces-different-key', 'idempotency.md §"Idempotency-Key', 'node isolation — different nodeId produces different key')).not.toBe(b.outputs.key);
|
|
163
|
+
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.node-isolation-different-nodeid-produces-different-key', 'spec/v1/idempotency.md §"Idempotency-Key', 'node isolation — different nodeId produces different key')).not.toBe(b.outputs.key);
|
|
164
164
|
});
|
|
165
165
|
|
|
166
166
|
it('hash mode is deterministic in the payload alone (run/node ignored)', async () => {
|
|
@@ -168,7 +168,7 @@ describe('category: core.openwop.http.idempotency-key — determinism contract',
|
|
|
168
168
|
const payload = { request: 'payload-here', id: 42 };
|
|
169
169
|
const a = await idempotencyKey({ config: { mode: 'hash' }, runId: 'run-1', nodeId: 'node-1', inputs: { payload } });
|
|
170
170
|
const b = await idempotencyKey({ config: { mode: 'hash' }, runId: 'run-2', nodeId: 'node-2', inputs: { payload } });
|
|
171
|
-
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.hash-mode-is-deterministic-in-the-payload-alone-run-node-ignored', 'idempotency.md §"Idempotency-Key', 'hash mode MUST ignore run/node — useful for global remote-dedup caches')).toBe(b.outputs.key);
|
|
171
|
+
expect(a.outputs.key, req('openwop.it.idempotency-key-determinism.hash-mode-is-deterministic-in-the-payload-alone-run-node-ignored', 'spec/v1/idempotency.md §"Idempotency-Key', 'hash mode MUST ignore run/node — useful for global remote-dedup caches')).toBe(b.outputs.key);
|
|
172
172
|
expect(a.outputs.key).toMatch(KEY_PATTERN);
|
|
173
173
|
});
|
|
174
174
|
|
|
@@ -183,7 +183,7 @@ describe('category: core.openwop.http.idempotency-key — determinism contract',
|
|
|
183
183
|
];
|
|
184
184
|
for (const ctx of samples) {
|
|
185
185
|
const result = await idempotencyKey(ctx);
|
|
186
|
-
expect(result.outputs.key, req('openwop.it.idempotency-key-determinism.output-shape-every-emitted-key-matches-openwop-sha256-prefix-16', 'idempotency.md §"Idempotency-Key', `key shape for ctx=${JSON.stringify(ctx)}`)).toMatch(KEY_PATTERN);
|
|
186
|
+
expect(result.outputs.key, req('openwop.it.idempotency-key-determinism.output-shape-every-emitted-key-matches-openwop-sha256-prefix-16', 'spec/v1/idempotency.md §"Idempotency-Key', `key shape for ctx=${JSON.stringify(ctx)}`)).toMatch(KEY_PATTERN);
|
|
187
187
|
}
|
|
188
188
|
});
|
|
189
189
|
|
|
@@ -197,7 +197,7 @@ describe('category: core.openwop.http.idempotency-key — determinism contract',
|
|
|
197
197
|
} catch (err) {
|
|
198
198
|
caught = err;
|
|
199
199
|
}
|
|
200
|
-
expect(caught, req('openwop.it.idempotency-key-determinism.uuid-mode-rejects-with-config-invalid-safety-fix-per-1-1-2', 'idempotency.md §"Idempotency-Key', 'mode: uuid MUST be rejected — the 1.1.0/1.1.1 non-deterministic default was removed')).toBeInstanceOf(Error);
|
|
200
|
+
expect(caught, req('openwop.it.idempotency-key-determinism.uuid-mode-rejects-with-config-invalid-safety-fix-per-1-1-2', 'spec/v1/idempotency.md §"Idempotency-Key', 'mode: uuid MUST be rejected — the 1.1.0/1.1.1 non-deterministic default was removed')).toBeInstanceOf(Error);
|
|
201
201
|
expect((caught as Error & { code?: string }).code).toBe('CONFIG_INVALID');
|
|
202
202
|
expect((caught as Error).message).toMatch(/uuid.*removed|safety-fix/i);
|
|
203
203
|
});
|
|
@@ -223,7 +223,7 @@ describe('category: core.openwop.http.idempotency-key — determinism contract',
|
|
|
223
223
|
const expected = canonicalCompositeKey(v.runId, v.nodeId, v.payload);
|
|
224
224
|
expect(
|
|
225
225
|
packed.outputs.key,
|
|
226
|
-
req('openwop.it.idempotency-key-determinism.cross-impl-invariant-pack-output-equals-canonical-sha-256-formula', 'idempotency.md §"Idempotency-Key', `vector ${JSON.stringify(v)} — pack output MUST match canonical sha256(runId\\0nodeId\\0JSON(payload))`),
|
|
226
|
+
req('openwop.it.idempotency-key-determinism.cross-impl-invariant-pack-output-equals-canonical-sha-256-formula', 'spec/v1/idempotency.md §"Idempotency-Key', `vector ${JSON.stringify(v)} — pack output MUST match canonical sha256(runId\\0nodeId\\0JSON(payload))`),
|
|
227
227
|
).toBe(expected);
|
|
228
228
|
}
|
|
229
229
|
|
|
@@ -47,14 +47,14 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency: same key + same body replays per
|
|
|
47
47
|
expect(
|
|
48
48
|
[200, 201].includes(replay.status),
|
|
49
49
|
req('openwop.it.idempotency.returns-same-runid-twice-and-sets-openwop-idempotent-replay-on-the-replay',
|
|
50
|
-
'idempotency.md §Layer 1',
|
|
50
|
+
'spec/v1/idempotency.md §Layer 1',
|
|
51
51
|
'replay request with same key + same body MUST return success status (200/201)',
|
|
52
52
|
),
|
|
53
53
|
).toBe(true);
|
|
54
54
|
|
|
55
55
|
const replayRunId = (replay.json as { runId: string }).runId;
|
|
56
56
|
expect(replayRunId, req('openwop.it.idempotency.returns-same-runid-twice-and-sets-openwop-idempotent-replay-on-the-replay',
|
|
57
|
-
'idempotency.md §Layer 1',
|
|
57
|
+
'spec/v1/idempotency.md §Layer 1',
|
|
58
58
|
'replay MUST return the SAME runId (no new run created)',
|
|
59
59
|
)).toBe(firstRunId);
|
|
60
60
|
|
|
@@ -84,7 +84,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency: same key + different body conflic
|
|
|
84
84
|
);
|
|
85
85
|
|
|
86
86
|
expect(conflict.status, req('openwop.it.idempotency.returns-409-when-the-body-changes-under-the-same-key',
|
|
87
|
-
'idempotency.md §Layer 1',
|
|
87
|
+
'spec/v1/idempotency.md §Layer 1',
|
|
88
88
|
'same Idempotency-Key with a different body MUST return 409',
|
|
89
89
|
)).toBe(409);
|
|
90
90
|
|
|
@@ -98,7 +98,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency: same key + different body conflic
|
|
|
98
98
|
// `idempotency_key_replay_mismatch` — a spelling in no corpus artifact at
|
|
99
99
|
// all. This leg asserted the status alone, so every one of them passed.
|
|
100
100
|
//
|
|
101
|
-
// `idempotency_key_mismatch` is canonical (idempotency.md §"Record shape,
|
|
101
|
+
// `idempotency_key_mismatch` is canonical (spec/v1/idempotency.md §"Record shape,
|
|
102
102
|
// digest, and lease"): the only spelling already in more than one shipped
|
|
103
103
|
// artifact. The two legacy spellings are TOLERATED here through the first
|
|
104
104
|
// minor after 2026-11-10 so a converging host is not red on a rename it is
|
|
@@ -109,7 +109,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency: same key + different body conflic
|
|
|
109
109
|
expect(
|
|
110
110
|
code === 'idempotency_key_mismatch' || LEGACY.includes(code ?? ''),
|
|
111
111
|
req('openwop.it.idempotency.returns-409-when-the-body-changes-under-the-same-key',
|
|
112
|
-
'idempotency.md §"Record shape, digest, and lease"',
|
|
112
|
+
'spec/v1/idempotency.md §"Record shape, digest, and lease"',
|
|
113
113
|
`a different request digest under the same scoped key MUST fail with the canonical ` +
|
|
114
114
|
`\`idempotency_key_mismatch\` (legacy \`${LEGACY.join('` / `')}\` tolerated through the ` +
|
|
115
115
|
`first minor after 2026-11-10); got \`${code ?? '<none>'}\``,
|
|
@@ -122,7 +122,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency: same key + different body conflic
|
|
|
122
122
|
expect(
|
|
123
123
|
conflictRunId,
|
|
124
124
|
req('openwop.it.idempotency.returns-409-when-the-body-changes-under-the-same-key',
|
|
125
|
-
'idempotency.md §"Record shape, digest, and lease"',
|
|
125
|
+
'spec/v1/idempotency.md §"Record shape, digest, and lease"',
|
|
126
126
|
'a digest mismatch MUST NOT return the cached body (no runId on the 409)',
|
|
127
127
|
),
|
|
128
128
|
).toBeUndefined();
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* additions documented in idempotency.md:
|
|
7
7
|
*
|
|
8
8
|
* 1. openwop-Idempotent-Replay header is present on every keyed response
|
|
9
|
-
* (idempotency.md §Server responsibilities).
|
|
9
|
+
* (spec/v1/idempotency.md §Server responsibilities).
|
|
10
10
|
* 2. Retry-budget floor — hosts handle ≥5 retries 100ms apart with
|
|
11
11
|
* the cached response (scale-profiles.md §"Retry semantics").
|
|
12
12
|
* 3. Same-key replay returns same runId across the budget.
|
|
@@ -53,7 +53,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency-retry: openwop-Idempotent-Replay h
|
|
|
53
53
|
// Permissive assertion: if present, MUST be "false" or "true".
|
|
54
54
|
if (firstReplay !== null) {
|
|
55
55
|
expect(['false', 'true'].includes(firstReplay), req('openwop.it.idempotencyRetry.first-request-with-new-key-returns-false-or-absent-should-per-current-spec-repla',
|
|
56
|
-
'idempotency.md §Server responsibilities',
|
|
56
|
+
'spec/v1/idempotency.md §Server responsibilities',
|
|
57
57
|
'openwop-Idempotent-Replay value MUST be "true" or "false"',
|
|
58
58
|
)).toBe(true);
|
|
59
59
|
}
|
|
@@ -61,19 +61,19 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency-retry: openwop-Idempotent-Replay h
|
|
|
61
61
|
const replay = await driver.post('/v1/runs', body, { headers: { 'Idempotency-Key': key } });
|
|
62
62
|
expect(
|
|
63
63
|
[200, 201].includes(replay.status),
|
|
64
|
-
req('openwop.it.idempotencyRetry.first-request-with-new-key-returns-false-or-absent-should-per-current-spec-repla', 'idempotency.md §Layer 1', 'replay returns 200 or 201'),
|
|
64
|
+
req('openwop.it.idempotencyRetry.first-request-with-new-key-returns-false-or-absent-should-per-current-spec-repla', 'spec/v1/idempotency.md §Layer 1', 'replay returns 200 or 201'),
|
|
65
65
|
).toBe(true);
|
|
66
66
|
|
|
67
67
|
const replayHeader = replay.headers.get('openwop-idempotent-replay');
|
|
68
|
-
// Per idempotency.md §Server responsibilities #2: SHOULD be set.
|
|
68
|
+
// Per spec/v1/idempotency.md §Server responsibilities #2: SHOULD be set.
|
|
69
69
|
// RFC 0002 §1 promotes to MUST. Today's strictness: present on replay.
|
|
70
70
|
expect(replayHeader, req('openwop.it.idempotencyRetry.first-request-with-new-key-returns-false-or-absent-should-per-current-spec-repla',
|
|
71
|
-
'idempotency.md §Server responsibilities #2',
|
|
71
|
+
'spec/v1/idempotency.md §Server responsibilities #2',
|
|
72
72
|
'openwop-Idempotent-Replay SHOULD be set on idempotent replay responses',
|
|
73
73
|
)).not.toBeNull();
|
|
74
74
|
if (replayHeader !== null) {
|
|
75
75
|
expect(replayHeader, req('openwop.it.idempotencyRetry.first-request-with-new-key-returns-false-or-absent-should-per-current-spec-repla',
|
|
76
|
-
'idempotency.md §Server responsibilities #2',
|
|
76
|
+
'spec/v1/idempotency.md §Server responsibilities #2',
|
|
77
77
|
'openwop-Idempotent-Replay on replay MUST be "true"',
|
|
78
78
|
)).toBe('true');
|
|
79
79
|
}
|
|
@@ -104,7 +104,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('idempotency-retry: 5-retry budget per scale-pr
|
|
|
104
104
|
|
|
105
105
|
const runIds = new Set(responses.map((r) => (r.json as { runId?: string })?.runId));
|
|
106
106
|
expect(runIds.size, req('openwop.it.idempotencyRetry.5-retries-100ms-apart-with-same-key-all-return-the-same-runid',
|
|
107
|
-
'idempotency.md §Layer 1',
|
|
107
|
+
'spec/v1/idempotency.md §Layer 1',
|
|
108
108
|
'5 retries with same key MUST collapse to exactly one runId',
|
|
109
109
|
)).toBe(1);
|
|
110
110
|
});
|
|
@@ -116,19 +116,24 @@ describe('idempotency-retry: limits.idempotencyAckTimeoutSec contract per idempo
|
|
|
116
116
|
expect(res.status).toBe(200);
|
|
117
117
|
|
|
118
118
|
const limits = (res.json as { limits?: Record<string, unknown> })?.limits;
|
|
119
|
-
|
|
119
|
+
// partial-witness-ok: discovery answered 200; `limits` presence is its own requirement,
|
|
120
|
+
// asserted by the capabilities scenarios, and this leg's IF-advertised clause binds nothing
|
|
121
|
+
// without it.
|
|
122
|
+
if (!limits) return softSkip('inapplicable', 'discovery carries no limits object — its presence is asserted elsewhere; nothing for this IF-advertised leg to check');
|
|
120
123
|
const ack = limits.idempotencyAckTimeoutSec;
|
|
121
124
|
if (ack === undefined) {
|
|
122
125
|
// Per idempotency.md, the field is optional; absence implies the
|
|
123
126
|
// 5-second floor. Nothing to assert.
|
|
124
|
-
|
|
127
|
+
// partial-witness-ok: idempotencyAckTimeoutSec is optional and absence means the 5-second
|
|
128
|
+
// floor; the IF-advertised MUSTs below bind nothing.
|
|
129
|
+
return softSkip('inapplicable', 'limits.idempotencyAckTimeoutSec not advertised (optional; the 5-second floor applies) — nothing to check');
|
|
125
130
|
}
|
|
126
131
|
expect(typeof ack === 'number' && Number.isInteger(ack), req('openwop.it.idempotencyRetry.host-advertising-idempotencyacktimeoutsec-sets-integer-5',
|
|
127
|
-
'idempotency.md',
|
|
132
|
+
'spec/v1/idempotency.md',
|
|
128
133
|
'limits.idempotencyAckTimeoutSec MUST be an integer when advertised',
|
|
129
134
|
)).toBe(true);
|
|
130
135
|
expect(ack as number, req('openwop.it.idempotencyRetry.host-advertising-idempotencyacktimeoutsec-sets-integer-5',
|
|
131
|
-
'idempotency.md',
|
|
136
|
+
'spec/v1/idempotency.md',
|
|
132
137
|
'limits.idempotencyAckTimeoutSec MUST be ≥ 5',
|
|
133
138
|
)).toBeGreaterThanOrEqual(5);
|
|
134
139
|
});
|
|
@@ -31,7 +31,7 @@ import { driver } from '../lib/driver.js';
|
|
|
31
31
|
import { pollUntilStatus } from '../lib/polling.js';
|
|
32
32
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
33
33
|
import { req } from '../lib/requirement-ids.js';
|
|
34
|
-
import {
|
|
34
|
+
import { blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
35
35
|
|
|
36
36
|
const FIXTURE = 'conformance-interrupt-external-event';
|
|
37
37
|
const SKIP = !isFixtureAdvertised(FIXTURE);
|
|
@@ -72,26 +72,38 @@ describe.skipIf(SKIP)('interrupt-token-matrix: POST /v1/interrupts/{token} negat
|
|
|
72
72
|
expect(create.status).toBe(201);
|
|
73
73
|
const runId = (create.json as { runId: string }).runId;
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
// softskip ratchet, 2026-09-27: this leg polled for `waiting-external-event`, a
|
|
76
|
+
// status that does not exist, and read `snapshot.interrupts[].token`, a field the
|
|
77
|
+
// snapshot does not carry. It found no token on any host, so it never reached the
|
|
78
|
+
// replay assertion and recorded a partial-witness pass on the 201 alone. It now
|
|
79
|
+
// reads the token the way interrupt-external-event-correlation does
|
|
80
|
+
// (`interrupt.interruptToken`, else the `/v1/interrupts/{token}` segment of
|
|
81
|
+
// `interrupt.callbackUrl`) once the run reaches `waiting-external`.
|
|
82
|
+
await pollUntilStatus(runId, 'waiting-external', { timeoutMs: 10_000 }).catch(() => null);
|
|
76
83
|
|
|
77
84
|
const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const token = interrupts[0]?.token;
|
|
85
|
+
const interrupt = (snap.json as { interrupt?: { interruptToken?: string; callbackUrl?: string } } | undefined)?.interrupt;
|
|
86
|
+
const fromCallback = interrupt?.callbackUrl?.match(/\/v1\/interrupts\/([^/?]+)/)?.[1];
|
|
87
|
+
const token = interrupt?.interruptToken ?? (fromCallback === undefined ? undefined : decodeURIComponent(fromCallback));
|
|
82
88
|
if (typeof token !== 'string') {
|
|
83
89
|
// eslint-disable-next-line no-console
|
|
84
90
|
console.warn('[interrupt-token-matrix] host did not surface an interrupt token; skipping replay subtest');
|
|
85
91
|
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
86
92
|
reason: 'conformance-cleanup',
|
|
87
93
|
});
|
|
88
|
-
|
|
94
|
+
// interrupt-external-event-correlation holds token exposure as a MUST for this
|
|
95
|
+
// fixture, so a missing token leaves the replay rule unobserved: blocked.
|
|
96
|
+
return blockedDespiteAssertions('the suspended conformance-interrupt-external-event run exposed no signed token (interrupt.interruptToken or interrupt.callbackUrl) — replay of a resolved token is unobserved');
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
// The fixture's correlation is {orderId: 'fixture-order-1', status: 'completed'}; a
|
|
100
|
+
// mismatched payload is refused 422 (interrupt-external-event-correlation), so the
|
|
101
|
+
// first resolve must match or the replay rule is never reached.
|
|
102
|
+
const MATCHING = { orderId: 'fixture-order-1', status: 'completed', externalReference: 'conformance-token-matrix' };
|
|
91
103
|
const resolve1 = await driver.post(`/v1/interrupts/${encodeURIComponent(token)}`, {
|
|
92
|
-
|
|
104
|
+
resumeValue: MATCHING,
|
|
93
105
|
});
|
|
94
|
-
if (resolve1.status
|
|
106
|
+
if (resolve1.status < 200 || resolve1.status >= 300) {
|
|
95
107
|
// eslint-disable-next-line no-console
|
|
96
108
|
console.warn(
|
|
97
109
|
`[interrupt-token-matrix] first resolve returned ${resolve1.status}; can't exercise replay path. Skipping.`,
|
|
@@ -99,11 +111,12 @@ describe.skipIf(SKIP)('interrupt-token-matrix: POST /v1/interrupts/{token} negat
|
|
|
99
111
|
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
100
112
|
reason: 'conformance-cleanup',
|
|
101
113
|
});
|
|
102
|
-
|
|
114
|
+
// interrupt-external-event-correlation holds a matching resolve to 2xx as a MUST.
|
|
115
|
+
return blockedDespiteAssertions(`the first resolve of the token answered ${resolve1.status}, not 2xx — replay of a resolved token is unobserved`);
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
const resolve2 = await driver.post(`/v1/interrupts/${encodeURIComponent(token)}`, {
|
|
106
|
-
|
|
119
|
+
resumeValue: MATCHING,
|
|
107
120
|
});
|
|
108
121
|
expect([404, 409, 410], req('openwop.it.interrupt-token-matrix.replay-after-successful-resolve-returns-409-or-404',
|
|
109
122
|
'rest-endpoints.md POST /v1/interrupts/{token}',
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { describe, it, expect } from 'vitest';
|
|
24
24
|
import { driver } from '../lib/driver.js';
|
|
25
|
-
import { seamAbsent,
|
|
25
|
+
import { seamAbsent, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
26
26
|
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
27
27
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
28
28
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
@@ -56,10 +56,10 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-cache-tenan
|
|
|
56
56
|
// Shape only; the cross-caller half is unobservable without a second credential.
|
|
57
57
|
// eslint-disable-next-line no-console
|
|
58
58
|
console.warn('[mcp-cache-tenant-scope] OPENWOP_TEST_SECONDARY_API_KEY not set — cross-caller half not exercised (blocked)');
|
|
59
|
-
return
|
|
59
|
+
return blockedDespiteAssertions('OPENWOP_TEST_SECONDARY_API_KEY is unset — the cross-caller half (a per-caller list MUST be cacheScope private) is unobserved');
|
|
60
60
|
}
|
|
61
61
|
const theirs = await listAs(other);
|
|
62
|
-
if (theirs.status !== 200) return
|
|
62
|
+
if (theirs.status !== 200) return blockedDespiteAssertions('tools/list under the secondary credential did not answer 200 — the cross-caller comparison is unobserved');
|
|
63
63
|
const same = JSON.stringify(mine.body.result?.tools) === JSON.stringify(theirs.body.result?.tools);
|
|
64
64
|
if (!same) {
|
|
65
65
|
expect(mine.body.result?.cacheScope, req('openwop.it.mcp-cache-tenant-scope.a-per-caller-list-is-cachescope-private-a-public-list-is-byte-identical-across-c', 'mcp-integration.md §D', 'a list that differs per caller MUST be cacheScope private — a public list that differs is cross-context cache poisoning (mcp-cache-tenant-scoped)')).toBe('private');
|
|
@@ -50,15 +50,15 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §E — mcp-current-aut
|
|
|
50
50
|
const { mcp, anon } = await disco();
|
|
51
51
|
const claims = mcp?.supported === true && (mcp.profiles ?? []).includes(PROFILE) && mcp.serverMount?.supported === true;
|
|
52
52
|
if (!behaviorGate(PROFILE, claims)) return;
|
|
53
|
+
// Anonymous access is permitted through the RFC 0132 surface when anonymousActor is
|
|
54
|
+
// advertised, so the refusal this leg checks does not apply. Known from discovery, so it
|
|
55
|
+
// is decided before any assertion (it used to follow the authenticated-call assert and
|
|
56
|
+
// record a partial-witness pass).
|
|
57
|
+
if (anon?.supported === true) return softSkip('inapplicable', 'anonymousActor is advertised — an anonymous current-profile request may be served through the RFC 0132 surface, so the refusal rule does not apply');
|
|
53
58
|
const authed = await driver.post(await mcpServerMount(), REQ, { headers: HDR });
|
|
54
59
|
if (authed.status === 404 || authed.status === 403) return seamAbsent(`host advertises an MCP server mount but the mount (capabilities.mcp.serverUrls[0], else /v1/host/sample/mcp) answered ${authed.status} — RFC 0153 §B is unobservable at the path the host itself advertised`);
|
|
55
60
|
expect(authed.status, req('openwop.it.mcp-current-auth-boundary.an-unauthenticated-current-profile-request-is-refused-unless-anonymousactor-is-a', 'mcp-integration.md §E', 'the authenticated call MUST succeed at the same path, so a refusal below is not a wrong path')).toBe(200);
|
|
56
61
|
const anonymous = await driver.post(await mcpServerMount(), REQ, { headers: HDR, authenticated: false });
|
|
57
|
-
if (anon?.supported === true) {
|
|
58
|
-
// Anonymous is permitted only through the RFC 0132 surface; a 200 here is that surface answering.
|
|
59
|
-
expect([200, 401, 403]).toContain(anonymous.status);
|
|
60
|
-
return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `anon?.supported === true` returned early');
|
|
61
|
-
}
|
|
62
62
|
expect(
|
|
63
63
|
[401, 403],
|
|
64
64
|
req('openwop.it.mcp-current-auth-boundary.an-unauthenticated-current-profile-request-is-refused-unless-anonymousactor-is-a', 'mcp-integration.md §E', 'an anonymous MCP principal MUST NOT be the production default for an advertised current profile — refuse (401/403) or advertise anonymousActor'),
|
|
@@ -74,7 +74,10 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §E — mcp-current-aut
|
|
|
74
74
|
...setCookies(anonymous.headers),
|
|
75
75
|
...setCookies((await driver.get('/.well-known/openwop', { authenticated: false })).headers),
|
|
76
76
|
];
|
|
77
|
-
|
|
77
|
+
// partial-witness-ok: the bare anonymous probe, the requirement, was refused and asserted
|
|
78
|
+
// above. The S30 replay binds only a host that mints an anonymous session, and this one
|
|
79
|
+
// minted none.
|
|
80
|
+
if (minted.length === 0) return softSkip('inapplicable', 'the host minted no anonymous session, so the bare-probe refusal asserted above was the whole observation');
|
|
78
81
|
const cookie = minted.map((c) => c.split(';')[0]).join('; ');
|
|
79
82
|
const withSession = await driver.post(await mcpServerMount(), REQ, { headers: { ...HDR, Cookie: cookie }, authenticated: false });
|
|
80
83
|
expect(
|
|
@@ -54,12 +54,13 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-extension-o
|
|
|
54
54
|
// an advertised-missing seam (RFC 0148 §B). A 403 is NOT a pass.
|
|
55
55
|
return seamAbsent(`host advertises mcp-2026-07-28 but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status}`);
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
// `extensionAuthority` is an OPTIONAL seam field (host-sample-test-seams.md §23). Its
|
|
58
|
+
// absence is `inapplicable`, decided before any assertion, never a failure or a
|
|
59
|
+
// `blocked` (the 2.42.6 appendEra2Event precedent). A report that IS present is held
|
|
60
|
+
// to the contract below.
|
|
58
61
|
const rep = (drive.json as { extensionAuthority?: { scopesWidened?: boolean; approvalAdvanced?: boolean } }).extensionAuthority;
|
|
59
|
-
if (rep === undefined)
|
|
60
|
-
|
|
61
|
-
return softSkip('blocked', 'precondition not met — `rep === undefined` returned early (seam, prior step, or fixture unavailable)');
|
|
62
|
-
}
|
|
62
|
+
if (rep === undefined) return softSkip('inapplicable', 'the invoke seam reports no extensionAuthority (an optional seam field, host-sample-test-seams.md §23), so extension opacity is not observable on this host');
|
|
63
|
+
expect(server.invocations().some((i) => i.method === 'tools/call'), req('openwop.it.mcp-extension-opacity.client-half-an-extension-meta-asserting-authority-in-a-tool-result-changes-nothi', 'host-sample-test-seams.md §23', 'the host MUST have called the tool')).toBe(true);
|
|
63
64
|
expect(rep.scopesWidened, req('openwop.it.mcp-extension-opacity.client-half-an-extension-meta-asserting-authority-in-a-tool-result-changes-nothi', 'mcp-integration.md §D', 'an extension MUST NOT gain tool authority or scope by appearing in _meta (mcp-extension-no-authority)')).toBe(false);
|
|
64
65
|
expect(rep.approvalAdvanced, req('openwop.it.mcp-extension-opacity.client-half-an-extension-meta-asserting-authority-in-a-tool-result-changes-nothi', 'mcp-integration.md §D/§E', 'MCP content MUST NOT advance approval gates')).toBe(false);
|
|
65
66
|
});
|