@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
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { describe, it, expect } from 'vitest';
|
|
34
|
-
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
34
|
+
import { softSkip, seamAbsent, seamAbsentDespiteAssertions } from '../lib/soft-skip.js';
|
|
35
35
|
import { driver } from '../lib/driver.js';
|
|
36
36
|
import { mcpServerMount } from '../lib/mcp-mount.js';
|
|
37
37
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
@@ -72,19 +72,20 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundt
|
|
|
72
72
|
const calls = server.invocations().filter((i) => i.method === 'tools/call');
|
|
73
73
|
expect(calls.length, req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'RFCS/0153 §C', 'the host MUST have called the tool at least once for this leg to mean anything')).toBeGreaterThan(0);
|
|
74
74
|
const mrtr = (drive.json as { mrtr?: { inputRequiredSeen?: boolean; retried?: boolean; requestStateEchoed?: boolean; result?: unknown } }).mrtr;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// `mrtr` is an OPTIONAL seam field (host-sample-test-seams.md §23). A host that omits
|
|
76
|
+
// it is not violating anything: the retry is still witnessed on the wire below, and
|
|
77
|
+
// only the seam's own report is skipped. A report that IS present is held to it.
|
|
78
|
+
if (mrtr !== undefined) {
|
|
79
|
+
expect(mrtr.inputRequiredSeen, req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §C.1', 'the host MUST recognise resultType input_required')).toBe(true);
|
|
80
|
+
expect(mrtr.retried, req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §C.1', 'the host MUST retry the ORIGINAL request with inputResponses (a live callback is not the current profile)')).toBe(true);
|
|
78
81
|
}
|
|
79
|
-
expect(mrtr.inputRequiredSeen, req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §C.1', 'the host MUST recognise resultType input_required')).toBe(true);
|
|
80
|
-
expect(mrtr.retried, req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §C.1', 'the host MUST retry the ORIGINAL request with inputResponses (a live callback is not the current profile)')).toBe(true);
|
|
81
82
|
// Cross-check from the wire: two tools/call, second carries requestState identical to what the server issued.
|
|
82
83
|
expect(calls.length, req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §C.1', 'initial + retry = two independent JSON-RPC requests on the wire')).toBeGreaterThanOrEqual(2);
|
|
83
84
|
const retry = calls[calls.length - 1]!.params as { requestState?: string; inputResponses?: Record<string, unknown> };
|
|
84
85
|
expect(typeof retry.requestState, req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §C.1', 'requestState MUST be echoed exactly on the retry')).toBe('string');
|
|
85
86
|
expect(retry.requestState?.startsWith('mrtr:needs_input:'), req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §C.1', 'the echoed requestState is the value the server issued, byte-exact')).toBe(true);
|
|
86
87
|
expect(retry.inputResponses?.['who'], req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §C.1', 'inputResponses MUST answer every requested key')).toBeDefined();
|
|
87
|
-
expect(mrtr.requestStateEchoed).toBe(true);
|
|
88
|
+
if (mrtr !== undefined) expect(mrtr.requestStateEchoed).toBe(true);
|
|
88
89
|
for (const c of calls) {
|
|
89
90
|
expect(c.revision, req('openwop.it.mcp-mrtr-roundtrip.input-required-gather-retry-with-inputresponses-echoed-requeststate-as-one-logic', 'mcp-integration.md §B', 'both calls MUST carry the current revision')).toBe('2026-07-28');
|
|
90
91
|
}
|
|
@@ -123,7 +124,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundt
|
|
|
123
124
|
expect(reg.status, req('openwop.it.mcp-mrtr-roundtrip.a-suspending-tool-answers-input-required-with-elicitation-requeststate-the-retry', 'host-sample-test-seams.md §"sample workflows"', `the registration fixture MUST be accepted; a 4xx means the fixture this leg posts is malformed (suite defect): ${JSON.stringify(reg.json).slice(0, 200)}`)).toBeLessThan(400);
|
|
124
125
|
const hdr = { 'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call', 'Mcp-Name': TOOL };
|
|
125
126
|
const first = await driver.post(await mcpServerMount(), { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: TOOL, arguments: {}, _meta: { [META_V]: '2026-07-28', [META_C]: { elicitation: {} } } } }, { headers: hdr });
|
|
126
|
-
if (first.status === 404) return
|
|
127
|
+
if (first.status === 404) return seamAbsentDespiteAssertions('host advertises an MCP server mount but /v1/host/sample/mcp answered 404 after the tool registered');
|
|
127
128
|
const r1 = first.json as { result?: { resultType?: string; inputRequests?: Record<string, { method?: string }>; requestState?: string }; error?: { code: number } };
|
|
128
129
|
expect(r1.error, req('openwop.it.mcp-mrtr-roundtrip.a-suspending-tool-answers-input-required-with-elicitation-requeststate-the-retry', 'mcp-integration.md §C.2', `tools/call MUST NOT error: ${JSON.stringify(r1.error)}`)).toBeUndefined();
|
|
129
130
|
expect(r1.result?.resultType, req('openwop.it.mcp-mrtr-roundtrip.a-suspending-tool-answers-input-required-with-elicitation-requeststate-the-retry', 'mcp-integration.md §C.2', 'a run reaching waiting-input MUST answer the in-flight tools/call with resultType input_required (not a live callback)')).toBe('input_required');
|
|
@@ -88,16 +88,11 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
|
|
|
88
88
|
server.reset();
|
|
89
89
|
const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.hostFacingEndpoint() });
|
|
90
90
|
if (drive.status === 404 || drive.status === 403) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'is observable. Without it the requirement resolves to `blocked` per RFC 0148 §A — not ' +
|
|
97
|
-
'to a pass.',
|
|
98
|
-
),
|
|
99
|
-
).not.toBe(404);
|
|
100
|
-
return softSkip('blocked', 'precondition not met — `drive.status === 404 || drive.status === 403` returned early (seam, prior step, or fixture unavailable)');
|
|
91
|
+
// A host advertising MCP revisions MUST expose the invoke seam so the negotiated
|
|
92
|
+
// version is observable. Default mode records `blocked` (RFC 0148 §A); strict mode
|
|
93
|
+
// fails (RFC 0148 §B). This was a not-404 assert followed by a blocked note, so a 403
|
|
94
|
+
// recorded a partial-witness pass at major 1.
|
|
95
|
+
return seamAbsent(`host advertises MCP revisions but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status}`);
|
|
101
96
|
}
|
|
102
97
|
const calls = server.invocations();
|
|
103
98
|
expect(calls.length, req('openwop.it.mcp-version-negotiation.outbound-calls-carry-mcp-protocol-version-in-date-form', 'RFCS/0153 §B', 'the host MUST have called the server')).toBeGreaterThan(0);
|
|
@@ -148,7 +148,7 @@ describe.skipIf(HTTP_SKIP)('media-url-inline-cap: advertisement shape (RFC 0055
|
|
|
148
148
|
const runIds = ((runs.json as { runs?: { runId?: string }[] }).runs ?? [])
|
|
149
149
|
.map((r) => r.runId)
|
|
150
150
|
.filter((id): id is string => typeof id === 'string');
|
|
151
|
-
for (const runId of runIds) {
|
|
151
|
+
runs: for (const runId of runIds) {
|
|
152
152
|
const bundle = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/debug-bundle`);
|
|
153
153
|
if (bundle.status !== 200) continue;
|
|
154
154
|
const events = (bundle.json as { events?: { type?: string; payload?: { url?: unknown; base64?: unknown } }[] }).events ?? [];
|
|
@@ -159,7 +159,8 @@ describe.skipIf(HTTP_SKIP)('media-url-inline-cap: advertisement shape (RFC 0055
|
|
|
159
159
|
typeof ev.payload?.url === 'string' && ev.payload?.base64 === undefined,
|
|
160
160
|
req('openwop.it.media-url-inline-cap.a-media-payload-in-a-run-debug-bundle-is-referenced-by-url-not-inlined-rfc-0055', 'ai-envelope.md §"Media reference payloads"', 'a media.* payload in a debug bundle MUST be a URL reference, never inlined binary'),
|
|
161
161
|
).toBe(true);
|
|
162
|
-
|
|
162
|
+
// One media.* payload asserted by URL: the §C rule-3 requirement was observed.
|
|
163
|
+
break runs;
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
}
|
|
@@ -117,6 +117,8 @@ describe('memory-compaction-provenance-tag: compacted-from:<id> tag follows §C
|
|
|
117
117
|
if (provenance === undefined) {
|
|
118
118
|
// eslint-disable-next-line no-console
|
|
119
119
|
console.warn('[rfc0012-tag] output entry has no compacted-from:<id> tag — RFC 0012 §C is SHOULD, not MUST; pass with warning');
|
|
120
|
+
// partial-witness-ok: RFC 0012 §C tagging is a SHOULD. An untagged entry is this leg's
|
|
121
|
+
// "omits it cleanly" branch, and the entry was located and inspected above.
|
|
120
122
|
return softSkip('inapplicable', '[rfc0012-tag] output entry has no compacted-from:<id> tag — RFC 0012 §C is SHOULD, not MUST; pass with warning');
|
|
121
123
|
}
|
|
122
124
|
expect(provenance, req('openwop.it.memory-compaction-provenance-tag.compacted-entry-carries-a-well-formed-compacted-from-tag-or-omits-it-cleanly-no',
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { describe, it, expect } from 'vitest';
|
|
30
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
30
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
31
31
|
import { driver } from '../lib/driver.js';
|
|
32
32
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
33
33
|
import { req } from '../lib/requirement-ids.js';
|
|
@@ -101,7 +101,7 @@ describe('memory-compaction-sr1-carry-forward: derived content passes the BYOK r
|
|
|
101
101
|
if (typeof event.outputContent !== 'string') {
|
|
102
102
|
// eslint-disable-next-line no-console
|
|
103
103
|
console.warn('[rfc0012-sr1] test seam did not return outputContent; the wire-level memory.compacted shape does not surface content so without a host-side seam we cannot verify §D end-to-end. Skipping.');
|
|
104
|
-
return
|
|
104
|
+
return blockedDespiteAssertions('[rfc0012-sr1] the compact seam returned no outputContent, so the persisted entry\'s SR-1 carry-forward is unobserved');
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
// The load-bearing assertion: the PERSISTED entry content (what
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { describe, it, expect } from 'vitest';
|
|
25
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
26
26
|
import { driver } from '../lib/driver.js';
|
|
27
27
|
import { req } from '../lib/requirement-ids.js';
|
|
28
28
|
|
|
@@ -61,7 +61,7 @@ describe('memory-consolidation-idempotent: pass contract (RFC 0068 §D, capabili
|
|
|
61
61
|
const second = await driver.post('/v1/host/sample/memory/consolidate', {
|
|
62
62
|
memoryRef: 'mem://conformance/consolidation',
|
|
63
63
|
});
|
|
64
|
-
if (second.status === 404 || second.status === 501) return
|
|
64
|
+
if (second.status === 404 || second.status === 501) return blockedDespiteAssertions('the consolidate seam answered the first pass but not the second — §D.2 idempotence is unobserved');
|
|
65
65
|
const r2 = second.json as ConsolidateResult;
|
|
66
66
|
expect(
|
|
67
67
|
r2.event?.inputCount,
|
|
@@ -116,7 +116,7 @@ describe('memory-injection-budget (RFC 0113)', () => {
|
|
|
116
116
|
|
|
117
117
|
const v = await driveFixtureVariables();
|
|
118
118
|
expect(v, req('openwop.it.memory-injection-budget.token-bounds-the-injection-read-omits-the-over-budget-entry-and-preserves-sr-1-c', 'RFC 0113', 'fixture MUST surface run variables')).toBeDefined();
|
|
119
|
-
if (v === undefined)
|
|
119
|
+
if (v === undefined) throw new Error('unreachable: the assertion above has already failed this leg');
|
|
120
120
|
|
|
121
121
|
// ── tokenBudget bound (the new lever) ──────────────────────────────
|
|
122
122
|
const tokenBudget = numberOf(v['tokenBudget']);
|
|
@@ -177,7 +177,7 @@ describe('memory-injection-budget (RFC 0113)', () => {
|
|
|
177
177
|
|
|
178
178
|
const v = await driveFixtureVariables();
|
|
179
179
|
expect(v, req('openwop.it.memory-injection-budget.rank-relevance-reorders-vs-recency-only-when-memory-search-semantic-is-also-adve', 'RFC 0113', 'fixture MUST surface run variables')).toBeDefined();
|
|
180
|
-
if (v === undefined)
|
|
180
|
+
if (v === undefined) throw new Error('unreachable: the assertion above has already failed this leg');
|
|
181
181
|
|
|
182
182
|
const recencyOrder = stringArrayOf(v['recencyOrder']);
|
|
183
183
|
const relevanceOrder = stringArrayOf(v['relevanceOrder']);
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
40
|
import { describe, it, expect } from 'vitest';
|
|
41
|
-
import { seamAbsent } from '../lib/soft-skip.js';
|
|
41
|
+
import { seamAbsent, seamAbsentDespiteAssertions } from '../lib/soft-skip.js';
|
|
42
42
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
43
43
|
import { isMultiPartyConversationSupported } from '../lib/multi-agent-capabilities.js';
|
|
44
44
|
import {
|
|
@@ -89,7 +89,7 @@ describe('multi-party-conversation-behavioral (RFC 0101 §Conformance)', () => {
|
|
|
89
89
|
|
|
90
90
|
// ---- MUST 1: POSITIVE — a roster-valid attributed turn is accepted ----
|
|
91
91
|
const ok = await exchangeMultiPartyTurn({ conversationId: convId, turn: agentTurn('host:advisor-cfo', 1) });
|
|
92
|
-
if (ok.unwired) return
|
|
92
|
+
if (ok.unwired) return seamAbsentDespiteAssertions('host advertises multiPartyConversation and the council opened, but the turn-exchange seam is not wired');
|
|
93
93
|
expect(
|
|
94
94
|
ok.status === 200,
|
|
95
95
|
req('openwop.it.multi-party-conversation-behavioral.opens-a-council-accepts-an-attributed-turn-and-rejects-missing-non-participant-o', 'RFC 0101 §Spec', "a role:'agent' turn with an in-roster speakerId MUST be accepted"),
|
|
@@ -87,14 +87,14 @@ describe.skipIf(HTTP_SKIP)('multi-region-idempotency-behavior: convergence rule
|
|
|
87
87
|
expect(
|
|
88
88
|
probe.status,
|
|
89
89
|
req('openwop.it.multi-region-idempotency-behavior.two-region-conflict-resolves-to-the-lex-min-runid-per-annex-convergence-rule',
|
|
90
|
-
'idempotency.md §"Multi-region idempotency annex"',
|
|
90
|
+
'spec/v1/idempotency.md §"Multi-region idempotency annex"',
|
|
91
91
|
'simulate-partition seam MUST return 200 when ≥2 conflicting claims are submitted',
|
|
92
92
|
),
|
|
93
93
|
).toBe(200);
|
|
94
94
|
expect(
|
|
95
95
|
probe.body.winner?.runId,
|
|
96
96
|
req('openwop.it.multi-region-idempotency-behavior.two-region-conflict-resolves-to-the-lex-min-runid-per-annex-convergence-rule',
|
|
97
|
-
'idempotency.md §"Convergence rule"',
|
|
97
|
+
'spec/v1/idempotency.md §"Convergence rule"',
|
|
98
98
|
'winner MUST be the lex-min runId (run-a-west < run-b-east)',
|
|
99
99
|
),
|
|
100
100
|
).toBe('run-a-west');
|
|
@@ -115,14 +115,14 @@ describe.skipIf(HTTP_SKIP)('multi-region-idempotency-behavior: convergence rule
|
|
|
115
115
|
expect(
|
|
116
116
|
probe.body.winner?.runId,
|
|
117
117
|
req('openwop.it.multi-region-idempotency-behavior.three-region-partition-resolves-to-a-single-winner',
|
|
118
|
-
'idempotency.md §"Convergence rule"',
|
|
118
|
+
'spec/v1/idempotency.md §"Convergence rule"',
|
|
119
119
|
'winner MUST be the lex-min runId across all conflicting claims',
|
|
120
120
|
),
|
|
121
121
|
).toBe('aaa-1');
|
|
122
122
|
expect(
|
|
123
123
|
probe.body.losers?.length,
|
|
124
124
|
req('openwop.it.multi-region-idempotency-behavior.three-region-partition-resolves-to-a-single-winner',
|
|
125
|
-
'idempotency.md §"Convergence rule"',
|
|
125
|
+
'spec/v1/idempotency.md §"Convergence rule"',
|
|
126
126
|
'losers array MUST contain N-1 entries when N claims conflict',
|
|
127
127
|
),
|
|
128
128
|
).toBe(2);
|
|
@@ -143,7 +143,7 @@ describe.skipIf(HTTP_SKIP)('multi-region-idempotency-behavior: convergence rule
|
|
|
143
143
|
expect(
|
|
144
144
|
redirects.length,
|
|
145
145
|
req('openwop.it.multi-region-idempotency-behavior.every-region-gets-a-cache-redirect-entry-pointing-at-the-winner',
|
|
146
|
-
'idempotency.md §"Convergence rule"',
|
|
146
|
+
'spec/v1/idempotency.md §"Convergence rule"',
|
|
147
147
|
'cacheRedirects MUST contain one entry per claim (including the winner)',
|
|
148
148
|
),
|
|
149
149
|
).toBe(2);
|
|
@@ -151,7 +151,7 @@ describe.skipIf(HTTP_SKIP)('multi-region-idempotency-behavior: convergence rule
|
|
|
151
151
|
expect(
|
|
152
152
|
redirect.redirectToRunId,
|
|
153
153
|
req('openwop.it.multi-region-idempotency-behavior.every-region-gets-a-cache-redirect-entry-pointing-at-the-winner',
|
|
154
|
-
'idempotency.md §"Convergence rule"',
|
|
154
|
+
'spec/v1/idempotency.md §"Convergence rule"',
|
|
155
155
|
'every cache redirect MUST point at the winner runId',
|
|
156
156
|
),
|
|
157
157
|
).toBe('run-a');
|
|
@@ -172,7 +172,7 @@ describe.skipIf(HTTP_SKIP)('multi-region-idempotency-behavior: convergence rule
|
|
|
172
172
|
expect(
|
|
173
173
|
probe.body.loserCancelReason,
|
|
174
174
|
req('openwop.it.multi-region-idempotency-behavior.loser-cancel-reason-must-be-the-canonical-cross-region-dedup-loss-string',
|
|
175
|
-
'idempotency.md §"Convergence rule"',
|
|
175
|
+
'spec/v1/idempotency.md §"Convergence rule"',
|
|
176
176
|
'loserCancelReason MUST be the canonical `cross_region_dedup_loss` string',
|
|
177
177
|
),
|
|
178
178
|
).toBe('cross_region_dedup_loss');
|
|
@@ -198,7 +198,7 @@ describe.skipIf(HTTP_SKIP)('multi-region-idempotency-behavior: convergence rule
|
|
|
198
198
|
expect(
|
|
199
199
|
p1.body.winner?.runId,
|
|
200
200
|
req('openwop.it.multi-region-idempotency-behavior.resolver-is-order-invariant-shuffled-inputs-produce-the-same-winner',
|
|
201
|
-
'idempotency.md §"Convergence rule" — determinism',
|
|
201
|
+
'spec/v1/idempotency.md §"Convergence rule" — determinism',
|
|
202
202
|
'resolver MUST be order-invariant; all permutations MUST produce the same lex-min winner',
|
|
203
203
|
),
|
|
204
204
|
).toBe('a');
|
|
@@ -219,7 +219,7 @@ describe.skipIf(HTTP_SKIP)('multi-region-idempotency-behavior: convergence rule
|
|
|
219
219
|
expect(
|
|
220
220
|
probe.status,
|
|
221
221
|
req('openwop.it.multi-region-idempotency-behavior.mismatched-tuple-rejects-with-400-validation-error',
|
|
222
|
-
'idempotency.md §"Convergence rule"',
|
|
222
|
+
'spec/v1/idempotency.md §"Convergence rule"',
|
|
223
223
|
'claims with non-matching (tenantId, endpoint, key) MUST be rejected — it would be a programming error in the caller',
|
|
224
224
|
),
|
|
225
225
|
).toBe(400);
|
|
@@ -64,7 +64,7 @@ describe('multi-region-idempotency: capability shape', () => {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
expect(ALLOWED.has(idem.crossRegion), req('openwop.it.multi-region-idempotency.idempotency-crossregion-when-advertised-must-be-one-of-the-closed-enum',
|
|
67
|
-
'idempotency.md §"Multi-region idempotency" §"Capability advertisement"',
|
|
67
|
+
'spec/v1/idempotency.md §"Multi-region idempotency" §"Capability advertisement"',
|
|
68
68
|
'crossRegion MUST be one of {"single-region","reconciled-records","fenced-effects"}',
|
|
69
69
|
)).toBe(true);
|
|
70
70
|
|
|
@@ -90,7 +90,7 @@ describe('multi-region-idempotency: capability shape', () => {
|
|
|
90
90
|
const advertised = new Set(observability?.metrics?.names ?? []);
|
|
91
91
|
for (const name of REQUIRED_METRICS_WHEN_MULTI_REGION) {
|
|
92
92
|
expect(advertised.has(name), req('openwop.it.multi-region-idempotency.multi-region-hosts-should-expose-the-cross-region-conflict-counter-per-operator',
|
|
93
|
-
'idempotency.md §"Operator surface"',
|
|
93
|
+
'spec/v1/idempotency.md §"Operator surface"',
|
|
94
94
|
`multi-region hosts SHOULD advertise metric "${name}" so operators can monitor conflict frequency`,
|
|
95
95
|
)).toBe(true);
|
|
96
96
|
}
|
|
@@ -94,18 +94,19 @@ describe('pack-registry-isolation: test catalog MUST NOT bleed into production (
|
|
|
94
94
|
`pack name '${name}' was written via /v1/packs-test/${name}@${version} but appeared in /v1/packs/${name} response body — test-catalog isolation MUST hold`,
|
|
95
95
|
),
|
|
96
96
|
).toBe(false);
|
|
97
|
-
|
|
97
|
+
// A 200 that does not name the pack IS isolation holding: the requirement was observed.
|
|
98
|
+
// (This branch used to end in a blocked note, a partial-witness pass at major 1.)
|
|
99
|
+
} else {
|
|
100
|
+
// Acceptable: 4xx range (404 pack_not_found is the spec-canonical
|
|
101
|
+
// shape; 410/422 also fine — any "not present in production catalog"
|
|
102
|
+
// signal satisfies the invariant).
|
|
103
|
+
expect(
|
|
104
|
+
prodRes.status >= 400 && prodRes.status < 500,
|
|
105
|
+
req('openwop.it.pack-registry-isolation.a-pack-put-to-v1-packs-test-name-must-not-appear-in-get-v1-packs-name',
|
|
106
|
+
'RFCS/0025-test-mode-registry-namespace.md §C point 1',
|
|
107
|
+
`expected production-namespace GET to return 4xx for a test-namespace-only pack '${name}', got ${prodRes.status}`,
|
|
108
|
+
),
|
|
109
|
+
).toBe(true);
|
|
98
110
|
}
|
|
99
|
-
|
|
100
|
-
// Acceptable: 4xx range (404 pack_not_found is the spec-canonical
|
|
101
|
-
// shape; 410/422 also fine — any "not present in production catalog"
|
|
102
|
-
// signal satisfies the invariant).
|
|
103
|
-
expect(
|
|
104
|
-
prodRes.status >= 400 && prodRes.status < 500,
|
|
105
|
-
req('openwop.it.pack-registry-isolation.a-pack-put-to-v1-packs-test-name-must-not-appear-in-get-v1-packs-name',
|
|
106
|
-
'RFCS/0025-test-mode-registry-namespace.md §C point 1',
|
|
107
|
-
`expected production-namespace GET to return 4xx for a test-namespace-only pack '${name}', got ${prodRes.status}`,
|
|
108
|
-
),
|
|
109
|
-
).toBe(true);
|
|
110
111
|
});
|
|
111
112
|
});
|
|
@@ -330,9 +330,10 @@ describe('pack-registry: keychain shape (when present)', () => {
|
|
|
330
330
|
authenticated: false,
|
|
331
331
|
});
|
|
332
332
|
// 200 with `{keys: []}` and 404 are both spec-allowed (keychain is
|
|
333
|
-
// optional; not every namespace publishes one).
|
|
333
|
+
// optional; not every namespace publishes one). A 404 is decided before
|
|
334
|
+
// any assertion, so the row is `inapplicable`, not a partial-witness pass.
|
|
335
|
+
if (res.status === 404) return softSkip('inapplicable', `${first} publishes no keychain (optional) — there are no key entries to check`);
|
|
334
336
|
expect([200, 404]).toContain(res.status);
|
|
335
|
-
if (res.status === 404) return softSkip('blocked', 'precondition not met — `res.status === 404` returned early (seam, prior step, or fixture unavailable)');
|
|
336
337
|
|
|
337
338
|
const body = res.json as { keys?: unknown; namespace?: unknown } | undefined;
|
|
338
339
|
expect(Array.isArray(body?.keys), req('openwop.it.pack-registry.get-v1-packs-name-keychain-returns-well-formed-key-entries-when-present',
|
|
@@ -52,6 +52,25 @@ async function cancel(runId: string): Promise<void> {
|
|
|
52
52
|
await driver.post(`/v1/runs/${encodeURIComponent(runId)}/cancel`, { reason: 'conformance-cleanup' });
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A 404 on :pause / :resume. The routes are capability-gated
|
|
57
|
+
* (capabilities.md §`runs.pauseResume`), so a host that does not advertise the
|
|
58
|
+
* family is out of scope (`inapplicable`), and one that advertises it and 404s
|
|
59
|
+
* has made a claim the suite cannot check (`blocked`).
|
|
60
|
+
*
|
|
61
|
+
* Every leg reaches this before its first assertion — the run create is an
|
|
62
|
+
* early return, not an `expect` — so neither case records the partial-witness
|
|
63
|
+
* PASS that a note after the create assertion recorded until 2026-09-27. A
|
|
64
|
+
* host that serves the routes without advertising them still runs every leg.
|
|
65
|
+
*/
|
|
66
|
+
async function routeAbsent(route: ':pause' | ':resume'): Promise<undefined> {
|
|
67
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
68
|
+
const advertised = capabilityFamily<{ pauseResume?: { supported?: unknown } }>(disco.json, 'runs')?.pauseResume?.supported === true;
|
|
69
|
+
return advertised
|
|
70
|
+
? softSkip('blocked', `${route} answered 404 on a host advertising runs.pauseResume.supported — the advertised route is not served`)
|
|
71
|
+
: softSkip('inapplicable', `${route} answered 404 and the host does not advertise runs.pauseResume (capabilities.md §runs.pauseResume) — pause/resume is out of scope for it`);
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
async function eventTypes(runId: string): Promise<string[]> {
|
|
56
75
|
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
57
76
|
const events = (res.json as { events?: unknown } | null)?.events;
|
|
@@ -70,7 +89,7 @@ describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal
|
|
|
70
89
|
// `running`, so 3 s is ample for the pause to land first.
|
|
71
90
|
inputs: { delayMs: 3_000 },
|
|
72
91
|
});
|
|
73
|
-
|
|
92
|
+
if (create.status !== 201) return softSkip('blocked', `POST /v1/runs answered ${create.status} — no run to pause`);
|
|
74
93
|
const runId = (create.json as { runId: string }).runId;
|
|
75
94
|
|
|
76
95
|
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
@@ -84,7 +103,7 @@ describe.skipIf(SKIP)('pause/resume: running → paused → running → terminal
|
|
|
84
103
|
});
|
|
85
104
|
if (pause.status === 404) {
|
|
86
105
|
await cancel(runId);
|
|
87
|
-
return
|
|
106
|
+
return routeAbsent(':pause');
|
|
88
107
|
}
|
|
89
108
|
expect(pause.status, req('openwop.it.pause-resume.pause-transitions-to-paused-resume-returns-the-run-to-running',
|
|
90
109
|
DOC_PAUSE,
|
|
@@ -112,7 +131,7 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
|
|
|
112
131
|
workflowId: FIXTURE!,
|
|
113
132
|
inputs: { delayMs: 30_000 },
|
|
114
133
|
});
|
|
115
|
-
|
|
134
|
+
if (create.status !== 201) return softSkip('blocked', `POST /v1/runs answered ${create.status} — no run to pause`);
|
|
116
135
|
const runId = (create.json as { runId: string }).runId;
|
|
117
136
|
|
|
118
137
|
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
@@ -120,7 +139,7 @@ describe.skipIf(SKIP)('pause/resume: :resume on a non-paused run returns 409', (
|
|
|
120
139
|
const resume = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:resume`, {});
|
|
121
140
|
if (resume.status === 404) {
|
|
122
141
|
await cancel(runId);
|
|
123
|
-
return
|
|
142
|
+
return routeAbsent(':resume');
|
|
124
143
|
}
|
|
125
144
|
expect(resume.status, req('openwop.it.pause-resume.resuming-a-running-not-paused-run-returns-409-with-details-runstatus',
|
|
126
145
|
DOC_RESUME,
|
|
@@ -146,14 +165,14 @@ describe.skipIf(SKIP)('pause/resume: a second :pause is 409 without a matching I
|
|
|
146
165
|
workflowId: FIXTURE!,
|
|
147
166
|
inputs: { delayMs: 30_000 },
|
|
148
167
|
});
|
|
149
|
-
|
|
168
|
+
if (create.status !== 201) return softSkip('blocked', `POST /v1/runs answered ${create.status} — no run to pause`);
|
|
150
169
|
const runId = (create.json as { runId: string }).runId;
|
|
151
170
|
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
152
171
|
|
|
153
172
|
const first = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, { drainPolicy: 'immediate' });
|
|
154
173
|
if (first.status === 404) {
|
|
155
174
|
await cancel(runId);
|
|
156
|
-
return
|
|
175
|
+
return routeAbsent(':pause');
|
|
157
176
|
}
|
|
158
177
|
expect(first.status, req('openwop.it.pause-resume.pause-on-an-already-paused-run-returns-409-with-details-runstatus-paused-unless',
|
|
159
178
|
DOC_PAUSE,
|
|
@@ -211,7 +230,7 @@ describe.skipIf(SKIP)('pause/resume: :pause on a terminal run returns 409', () =
|
|
|
211
230
|
await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
212
231
|
|
|
213
232
|
const pause = await driver.post(`/v1/runs/${encodeURIComponent(runId)}:pause`, {});
|
|
214
|
-
if (pause.status === 404) return
|
|
233
|
+
if (pause.status === 404) return routeAbsent(':pause');
|
|
215
234
|
expect(pause.status, req('openwop.it.pause-resume.pause-on-a-completed-cancelled-failed-run-must-return-409',
|
|
216
235
|
DOC_PAUSE,
|
|
217
236
|
':pause on a terminal run MUST return 409',
|
|
@@ -235,7 +254,7 @@ describe.skipIf(SKIP)('pause/resume: drain-current-node lets the executing node
|
|
|
235
254
|
workflowId: FIXTURE!,
|
|
236
255
|
inputs: { delayMs: 3_000 },
|
|
237
256
|
});
|
|
238
|
-
|
|
257
|
+
if (create.status !== 201) return softSkip('blocked', `POST /v1/runs answered ${create.status} — no run to pause`);
|
|
239
258
|
const runId = (create.json as { runId: string }).runId;
|
|
240
259
|
await pollUntilStatus(runId, 'running', { timeoutMs: 10_000 });
|
|
241
260
|
|
|
@@ -245,7 +264,7 @@ describe.skipIf(SKIP)('pause/resume: drain-current-node lets the executing node
|
|
|
245
264
|
});
|
|
246
265
|
if (pause.status === 404) {
|
|
247
266
|
await cancel(runId);
|
|
248
|
-
return
|
|
267
|
+
return routeAbsent(':pause');
|
|
249
268
|
}
|
|
250
269
|
expect(pause.status, req('openwop.it.pause-resume.under-drain-current-node-node-completed-precedes-run-paused-in-the-log',
|
|
251
270
|
DOC_PAUSE,
|
|
@@ -281,7 +300,7 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
|
|
|
281
300
|
return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!isFixtureAdvertised(\'conformance-approval\')` returned early ([pause-resume] conformance-approval not advertised; skipping :pause-during-suspend race subtest)');
|
|
282
301
|
}
|
|
283
302
|
const create = await driver.post('/v1/runs', { workflowId: 'conformance-approval' });
|
|
284
|
-
|
|
303
|
+
if (create.status !== 201) return softSkip('blocked', `POST /v1/runs answered ${create.status} — no run to pause`);
|
|
285
304
|
const runId = (create.json as { runId: string }).runId;
|
|
286
305
|
await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
|
|
287
306
|
|
|
@@ -290,7 +309,7 @@ describe.skipIf(SKIP)('pause/resume: :pause-during-suspend race', () => {
|
|
|
290
309
|
});
|
|
291
310
|
if (pause.status === 404) {
|
|
292
311
|
await cancel(runId);
|
|
293
|
-
return
|
|
312
|
+
return routeAbsent(':pause');
|
|
294
313
|
}
|
|
295
314
|
|
|
296
315
|
// Either rejection (preferred) or stacked-pause is OK; silent override is not.
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
* diagnostic log. Indicates the host doesn't recognize `?workspaceId=`
|
|
47
47
|
* on this endpoint (e.g., host-only template library with no
|
|
48
48
|
* workspace dimension).
|
|
49
|
-
* - 5xx —
|
|
49
|
+
* - 5xx — `blocked`: a server error is not a readable refusal (a crash
|
|
50
|
+
* also answers 5xx), so enforcement was not observed.
|
|
50
51
|
*
|
|
51
52
|
* Why a random workspaceId is sufficient: the assertion is negative-space.
|
|
52
53
|
* A host that correctly enforces membership MUST refuse for ANY workspace
|
|
@@ -118,27 +119,17 @@ describe.skipIf(HTTP_SKIP)(
|
|
|
118
119
|
`/v1/prompts?workspaceId=${encodeURIComponent(nonMemberWorkspaceId)}`,
|
|
119
120
|
);
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (res.status === 403) {
|
|
127
|
-
const body = res.json as { error?: unknown } | null;
|
|
128
|
-
expect(
|
|
129
|
-
body?.error,
|
|
130
|
-
req('openwop.it.prompt-read-workspace-membership-enforced.get-v1-prompts-workspaceid-non-member-must-refuse-or-return-empty-templates-neve',
|
|
131
|
-
'spec/v1/rest-endpoints.md §Common error codes — workspace_membership_required',
|
|
132
|
-
`403 refusal of a workspace-scoped read MUST carry error: "workspace_membership_required"; got error: ${JSON.stringify(body?.error)}`,
|
|
133
|
-
),
|
|
134
|
-
).toBe('workspace_membership_required');
|
|
135
|
-
}
|
|
136
|
-
return softSkip('blocked', 'precondition not met — `res.status >= 400 && res.status < 500` returned early (4xx — refused. Acceptable shape for the membership-required failure (and any other refusal mode the host chooses: 401, 404 for existence-d…');
|
|
137
|
-
}
|
|
122
|
+
const ID = 'openwop.it.prompt-read-workspace-membership-enforced.get-v1-prompts-workspaceid-non-member-must-refuse-or-return-empty-templates-neve';
|
|
123
|
+
|
|
124
|
+
// 5xx — not a refusal the suite can read as enforcement (a crash also
|
|
125
|
+
// answers 5xx). Checked first so it returns before any assertion.
|
|
126
|
+
if (res.status >= 500) return softSkip('blocked', `GET /v1/prompts?workspaceId=<non-member> answered ${res.status} — a server error is neither a refusal nor an empty result, so membership enforcement was not observed`);
|
|
138
127
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
128
|
+
// Other status codes (1xx, 3xx) — not a clear signal either way.
|
|
129
|
+
if (res.status < 200 || (res.status >= 300 && res.status < 400)) {
|
|
130
|
+
softSkip('skipped', `GET /v1/prompts?workspaceId=… answered ${res.status} — neither a refusal nor a list`);
|
|
131
|
+
return ctx.skip();
|
|
132
|
+
}
|
|
142
133
|
|
|
143
134
|
// 2xx — must inspect the response body. The failure mode this
|
|
144
135
|
// invariant guards against is a 200 response that LEAKS templates
|
|
@@ -152,35 +143,53 @@ describe.skipIf(HTTP_SKIP)(
|
|
|
152
143
|
) {
|
|
153
144
|
// Host doesn't recognize `?workspaceId=` on this endpoint
|
|
154
145
|
// (response shape doesn't include the documented `templates[]`
|
|
155
|
-
// field).
|
|
146
|
+
// field). Skip: this scenario probes hosts that expose
|
|
156
147
|
// workspace-scoped reads, and a host without that surface is
|
|
157
|
-
// simply out of scope.
|
|
158
|
-
|
|
159
|
-
|
|
148
|
+
// simply out of scope. The note is written BEFORE ctx.skip(),
|
|
149
|
+
// which throws.
|
|
150
|
+
softSkip('skipped', 'GET /v1/prompts?workspaceId=… answered 2xx without a templates field — the host does not expose workspace-scoped prompt reads');
|
|
151
|
+
return ctx.skip();
|
|
160
152
|
}
|
|
161
153
|
const templates = body.templates;
|
|
162
154
|
if (!Array.isArray(templates)) {
|
|
163
155
|
// Same: unrecognized shape, skip.
|
|
164
|
-
|
|
165
|
-
return
|
|
156
|
+
softSkip('skipped', 'GET /v1/prompts?workspaceId=… answered 2xx with a non-array templates field — the host does not expose workspace-scoped prompt reads');
|
|
157
|
+
return ctx.skip();
|
|
166
158
|
}
|
|
167
159
|
|
|
168
160
|
// A random non-member workspaceId can never legitimately contain
|
|
169
161
|
// templates the caller is authorized to see. Any non-empty result
|
|
170
|
-
// is a cross-tenant data leak.
|
|
162
|
+
// is a cross-tenant data leak. An empty result IS the requirement
|
|
163
|
+
// observed, so the leg ends as a plain pass.
|
|
171
164
|
expect(
|
|
172
165
|
templates.length,
|
|
173
|
-
req(
|
|
166
|
+
req(ID,
|
|
174
167
|
'spec/v1/prompts.md §Workspace membership on workspace-scoped reads and writes',
|
|
175
168
|
`GET /v1/prompts?workspaceId=<random-non-member> MUST NOT return any templates; got ${templates.length} templates which is a cross-tenant data leak (the random workspaceId is freshly generated per probe and cannot legitimately contain authorized content)`,
|
|
176
169
|
),
|
|
177
170
|
).toBe(0);
|
|
178
|
-
|
|
171
|
+
} else {
|
|
172
|
+
// 4xx — refused. Acceptable shape for the membership-required failure
|
|
173
|
+
// (and any other refusal mode the host chooses: 401, 404 for
|
|
174
|
+
// existence-disclosure avoidance, etc). The refusal IS the requirement
|
|
175
|
+
// observed; until 2026-09-27 this branch ended in a blocked soft-skip,
|
|
176
|
+
// recording a refused read as blocked (401/404) or a partial witness (403).
|
|
177
|
+
expect(
|
|
178
|
+
res.status,
|
|
179
|
+
req(ID, 'spec/v1/prompts.md §Workspace membership on workspace-scoped reads and writes', 'a workspace-scoped read for a non-member workspace MUST be refused (4xx) or return no templates'),
|
|
180
|
+
).toBeGreaterThanOrEqual(400);
|
|
181
|
+
// Canonical envelope on 403 per rest-endpoints.md §"Common error codes".
|
|
182
|
+
if (res.status === 403) {
|
|
183
|
+
const body = res.json as { error?: unknown } | null;
|
|
184
|
+
expect(
|
|
185
|
+
body?.error,
|
|
186
|
+
req(ID,
|
|
187
|
+
'spec/v1/rest-endpoints.md §Common error codes — workspace_membership_required',
|
|
188
|
+
`403 refusal of a workspace-scoped read MUST carry error: "workspace_membership_required"; got error: ${JSON.stringify(body?.error)}`,
|
|
189
|
+
),
|
|
190
|
+
).toBe('workspace_membership_required');
|
|
191
|
+
}
|
|
179
192
|
}
|
|
180
|
-
|
|
181
|
-
// Other status codes (1xx, 3xx) — soft-skip with note. Not a clear
|
|
182
|
-
// signal either way.
|
|
183
|
-
ctx.skip();
|
|
184
193
|
});
|
|
185
194
|
},
|
|
186
195
|
);
|
|
@@ -29,7 +29,7 @@ import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
|
29
29
|
import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
30
30
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
31
31
|
import { req } from '../lib/requirement-ids.js';
|
|
32
|
-
import { softSkip } from '../lib/soft-skip.js';
|
|
32
|
+
import { softSkip, blockedDespiteAssertions } from '../lib/soft-skip.js';
|
|
33
33
|
|
|
34
34
|
interface DiscoveryDoc {
|
|
35
35
|
capabilities?: {
|
|
@@ -145,7 +145,7 @@ describe('provider-usage: event presence via emit-seam + event-log query (RFC 00
|
|
|
145
145
|
expect(emit.status).toBe(200);
|
|
146
146
|
|
|
147
147
|
const events = await queryTestEvents(runId, { type: 'provider.usage' });
|
|
148
|
-
if (!events.ok) return
|
|
148
|
+
if (!events.ok) return blockedDespiteAssertions('the event-log seam is advertised but the provider.usage query failed — the projection is unobserved');
|
|
149
149
|
expect(
|
|
150
150
|
events.events.length,
|
|
151
151
|
req('openwop.it.provider-usage.emit-seam-projects-exactly-one-provider-usage-event-with-required-fields-populat', 'RFC 0026 §B', 'emit-seam MUST project exactly one provider.usage event'),
|
|
@@ -66,15 +66,17 @@ const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
|
|
|
66
66
|
describe('redaction: /.well-known/openwop secrets+aiProviders shape contract', () => {
|
|
67
67
|
it('secrets is well-formed regardless of supported value', async () => {
|
|
68
68
|
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
69
|
-
expect(res.status).toBe(200);
|
|
70
|
-
|
|
71
69
|
const body = res.json as { secrets?: unknown } | undefined;
|
|
72
70
|
const secrets = body?.secrets;
|
|
73
71
|
|
|
74
|
-
if (secrets === undefined) {
|
|
75
|
-
// Optional v1 field — hosts MAY omit. Spec-allowed; nothing to assert
|
|
76
|
-
|
|
72
|
+
if (res.status === 200 && secrets === undefined) {
|
|
73
|
+
// Optional v1 field — hosts MAY omit. Spec-allowed; nothing to assert,
|
|
74
|
+
// so the leg returns before any assertion and records `inapplicable`
|
|
75
|
+
// (it was a `blocked` note after the status assertion until 2026-09-27,
|
|
76
|
+
// i.e. a partial-witness pass).
|
|
77
|
+
return softSkip('inapplicable', '`secrets` is omitted from discovery — an OPTIONAL v1 field, so there is no shape to check');
|
|
77
78
|
}
|
|
79
|
+
expect(res.status).toBe(200);
|
|
78
80
|
|
|
79
81
|
// Per capabilities.schema.json: `secrets.supported` is REQUIRED
|
|
80
82
|
// when secrets is present.
|