@openwop/openwop-conformance 2.43.0 → 2.43.2
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 +24 -0
- package/README.md +3 -3
- package/dist/cli.js +15 -12
- package/dist/lib/certification-bundle-v3.js +19 -0
- package/dist/lib/scenario-disposition.js +30 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-approval-reject-loopback.json +33 -0
- package/fixtures/conformance-approval-reject-routed.json +32 -0
- package/fixtures/conformance-approval-timeout-approve.json +46 -0
- package/fixtures/conformance-approval-timeout.json +28 -0
- package/fixtures/conformance-replay-ordinal-loop.json +31 -0
- package/fixtures.md +69 -1
- package/package.json +2 -2
- package/requirements.json +243 -30
- package/scenario-majors.json +11 -2
- package/schemas/CORPUS-STAMP.json +47 -47
- package/src/cli.ts +15 -12
- package/src/lib/certification-bundle-v3.ts +25 -0
- package/src/lib/scenario-disposition.ts +40 -0
- package/src/lib/standard-webhooks.ts +53 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +63 -0
- package/src/scenarios/v2-agent-org-chart-served-shape.test.ts +63 -0
- package/src/scenarios/v2-approval-reject-disposition.test.ts +106 -6
- package/src/scenarios/v2-error-registry.test.ts +59 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +21 -9
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +23 -7
- package/src/scenarios/v2-pack-isolation.test.ts +10 -6
- package/src/scenarios/v2-peer-dependency-declared.test.ts +18 -6
- package/src/scenarios/v2-replay-suppression-ordinal.test.ts +108 -0
- package/src/scenarios/v2-run-fork-ancestry.test.ts +83 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +28 -11
- package/src/setup.ts +25 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/runs.md` §Diff and ancestry — a fork has no ancestry parent
|
|
3
|
+
* (suite 2.43.2, target major 2; gated on `replay` and on
|
|
4
|
+
* `multiAgent.executionModel.crossHostCausation.ancestryEndpointSupported`;
|
|
5
|
+
* one run created plus one branch fork).
|
|
6
|
+
*
|
|
7
|
+
* `getRunAncestry`'s `parent` is the run that dispatched this one, and `cause`
|
|
8
|
+
* names the composition mechanism it used (`core.subWorkflow`,
|
|
9
|
+
* `core.dispatch`, `mcp-tool-call`, `a2a-message`). A fork is not dispatched:
|
|
10
|
+
* its lineage is `sourceRunId` on the fork `201` and `parentRunId` on the
|
|
11
|
+
* snapshot, and its ancestry `parent` MUST be `null`. A host that reuses its
|
|
12
|
+
* fork link as an ancestry parent reports `cause: core.subWorkflow` for a run
|
|
13
|
+
* no parent dispatched, and fails here.
|
|
14
|
+
*
|
|
15
|
+
* The source run's own ancestry is the control: it was created by `POST /runs`,
|
|
16
|
+
* so its `parent` is `null` too. Without it a host answering `null` for every
|
|
17
|
+
* run would pass the fork leg without being measured on it, and one answering
|
|
18
|
+
* non-null for every run would fail it for the wrong reason.
|
|
19
|
+
*
|
|
20
|
+
* Its own file, not a leg of `v2-run-fork-refusals`: the ancestry gate is
|
|
21
|
+
* absent on most v2 hosts, and an `inapplicable` leg would turn that file's
|
|
22
|
+
* row into a `partial-witness` pass on every one of them.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v2/core/runs.md §Diff and ancestry
|
|
25
|
+
* @see spec/v2/core/runs.md §Fork
|
|
26
|
+
* @see schemas/v2/run-ancestry-response.schema.json
|
|
27
|
+
* @see RFCS/0040-multi-agent-cross-host-causation.md §C
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
32
|
+
import { v2Discovery, gateFamily, familyAdvertised } from '../lib/v2.js';
|
|
33
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
+
import { req } from '../lib/requirement-ids.js';
|
|
36
|
+
|
|
37
|
+
const ID = 'openwop.requirement.0040.fork-has-no-ancestry-parent';
|
|
38
|
+
const DOC = 'spec/v2/core/runs.md §Diff and ancestry';
|
|
39
|
+
const NOOP = 'conformance-noop';
|
|
40
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
41
|
+
|
|
42
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
43
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
44
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
45
|
+
async function status(runId: string): Promise<string | null> {
|
|
46
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
47
|
+
return res?.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('v2 run-fork-ancestry (runs.md §Diff and ancestry)', () => {
|
|
51
|
+
it('a branch fork is not a composition child: its ancestry parent is null', async () => {
|
|
52
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
53
|
+
if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay) — forkRun is gated on replay (runs.md §Surface)');
|
|
54
|
+
const chc = ((await familyAdvertised('multiAgent'))?.['executionModel'] as { crossHostCausation?: { ancestryEndpointSupported?: unknown } } | undefined)?.crossHostCausation;
|
|
55
|
+
if (chc?.ancestryEndpointSupported !== true) return softSkip('inapplicable', 'multiAgent.executionModel.crossHostCausation.ancestryEndpointSupported not advertised — getRunAncestry is gated on it (runs.md §Surface)');
|
|
56
|
+
|
|
57
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP }));
|
|
58
|
+
if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
59
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
60
|
+
if (created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs answered ${created.status} ${readErrorCode(created.json) ?? ''}`.trim());
|
|
61
|
+
const t0 = Date.now(); let settled: string | null = null;
|
|
62
|
+
while (Date.now() - t0 < 10_000) { settled = await status(runId); if (settled !== null && TERMINAL.has(settled)) break; await new Promise((r) => setTimeout(r, 250)); }
|
|
63
|
+
if (settled === null || !TERMINAL.has(settled)) return softSkip('blocked', 'the noop run did not settle within 10 s');
|
|
64
|
+
|
|
65
|
+
const own = await http(() => driver.get(`/runs/${enc(runId)}/ancestry`));
|
|
66
|
+
if (own === null) return softSkip('blocked', 'GET /runs/{runId}/ancestry unreachable (fetch failed)');
|
|
67
|
+
expect(own.status, req(ID, DOC, `a host advertising ancestryEndpointSupported MUST serve getRunAncestry — got ${own.status} ${readErrorCode(own.json) ?? ''}`.trim())).toBe(200);
|
|
68
|
+
expect((own.json as { parent?: unknown } | null)?.parent, req(ID, DOC, 'the control: a run created by POST /runs was dispatched by no parent, so its ancestry parent MUST be null')).toBeNull();
|
|
69
|
+
|
|
70
|
+
const fork = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'branch', fromSeq: 0 }));
|
|
71
|
+
if (fork === null) return softSkip('blocked', 'POST /runs/{runId}:fork unreachable (fetch failed)');
|
|
72
|
+
if (fork.status === 404) return softSkip('blocked', 'POST /runs/{runId}:fork answered 404 with replay advertised — forkRun is not mounted');
|
|
73
|
+
const forkId = (fork.json as { runId?: unknown } | null)?.runId;
|
|
74
|
+
if (fork.status !== 201 || typeof forkId !== 'string') return softSkip('blocked', `a branch fork at fromSeq 0 answered ${fork.status} ${readErrorCode(fork.json) ?? ''} — no fork to read the ancestry of`.trim());
|
|
75
|
+
expect((fork.json as { sourceRunId?: unknown }).sourceRunId, req(ID, 'spec/v2/core/runs.md §Fork', 'the fork\'s lineage is carried by the 201: sourceRunId MUST name the source')).toBe(runId);
|
|
76
|
+
|
|
77
|
+
const anc = await http(() => driver.get(`/runs/${enc(forkId)}/ancestry`));
|
|
78
|
+
if (anc === null) return softSkip('blocked', 'GET /runs/{fork}/ancestry unreachable (fetch failed)');
|
|
79
|
+
expect(anc.status, req(ID, DOC, `getRunAncestry on a fork MUST answer 200 — got ${anc.status} ${readErrorCode(anc.json) ?? ''}`.trim())).toBe(200);
|
|
80
|
+
const parent = (anc.json as { parent?: unknown } | null)?.parent;
|
|
81
|
+
expect(parent, req(ID, DOC, `a fork is not dispatched, so its ancestry parent MUST be null — got ${JSON.stringify(parent)}. Its lineage is sourceRunId and the snapshot's parentRunId; ancestry's cause names a composition mechanism no fork used`)).toBeNull();
|
|
82
|
+
}, 30_000);
|
|
83
|
+
});
|
|
@@ -10,15 +10,20 @@
|
|
|
10
10
|
* checked before lookup (`403 id_tenant_mismatch`), refuses a subscription that
|
|
11
11
|
* did not opt in (`400 validation_error`), and returns no secret.
|
|
12
12
|
*
|
|
13
|
-
* The post-overlap leg waits for `previousSecretExpiresAt`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* The post-overlap leg waits for `previousSecretExpiresAt` when `overlapSeconds`
|
|
14
|
+
* fits inside the suite's retry-wait cap (`OPENWOP_WEBHOOK_RETRY_WAIT_MS`,
|
|
15
|
+
* operator-raisable). Past the cap (suite 2.43.1), a seams-profile host that
|
|
16
|
+
* serves the OPTIONAL `shortenRotationOverlap` seam (host-sample-test-seams.md
|
|
17
|
+
* §29) has this one subscription's overlap cut to a few seconds, and the leg
|
|
18
|
+
* waits for that; a served seam that breaks its contract records `blocked`.
|
|
19
|
+
* A host without the seam is not observed, and the row says so with a
|
|
20
|
+
* `partial-witness:` detail — which the acceptance predicate refuses to cite as
|
|
21
|
+
* `executed-pass` (RFC 0201 §Falsifiability).
|
|
18
22
|
*
|
|
19
23
|
* How it FAILS: signing only with the new secret (one entry); moving
|
|
20
24
|
* `OpenWOP-Signature` to the new secret at rotation; a foreign-tenant id that
|
|
21
|
-
* reaches the lookup (`404` instead of `403`); a response that echoes the secret
|
|
25
|
+
* reaches the lookup (`404` instead of `403`); a response that echoes the secret;
|
|
26
|
+
* the previous secret still signing after the (shortened) overlap.
|
|
22
27
|
*
|
|
23
28
|
* @see spec/v2/core/webhooks.md §Standard Webhooks
|
|
24
29
|
* @see RFCS/0201-standard-webhooks-signature-scheme.md §E
|
|
@@ -32,6 +37,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
|
|
|
32
37
|
import { softSkip } from '../lib/soft-skip.js';
|
|
33
38
|
import { projectBoundId } from '../lib/bound-id.js';
|
|
34
39
|
import { retryWaitCapMs } from '../lib/webhook-retry-window.js';
|
|
40
|
+
import { seamsProfileAdvertised } from '../lib/seams.js';
|
|
35
41
|
import { hitHeader, mintWhsec, startModalReceiver, verifyStandardWebhooks, type ModalHit } from '../lib/webhook-receiver.js';
|
|
36
42
|
import {
|
|
37
43
|
STANDARD_WEBHOOKS_ALG,
|
|
@@ -39,12 +45,15 @@ import {
|
|
|
39
45
|
driveRun,
|
|
40
46
|
loopbackRefusal,
|
|
41
47
|
registerSw,
|
|
48
|
+
shortenRotationOverlap,
|
|
42
49
|
swGate,
|
|
43
50
|
unregisterAllSw,
|
|
44
51
|
waitFor,
|
|
45
52
|
} from '../lib/standard-webhooks.js';
|
|
46
53
|
|
|
47
54
|
const CAP_MS = retryWaitCapMs();
|
|
55
|
+
/** The overlap the §29 seam sets: long enough that the call returns inside it, short enough to wait out. */
|
|
56
|
+
const SEAM_OVERLAP_SECONDS = 3;
|
|
48
57
|
|
|
49
58
|
let closeReceiver: (() => Promise<void>) | null = null;
|
|
50
59
|
afterEach(async () => {
|
|
@@ -114,17 +123,25 @@ describe('RFC 0201 §E — secret rotation overlaps, then retires (gated on webh
|
|
|
114
123
|
expect(underOld.matched, req(ID, 'RFC 0201 §E.20', 'one entry MUST verify under the previous secret')).toBe(1);
|
|
115
124
|
expect(v1SignedBy(s1, d), req(ID, 'RFC 0201 §E.20', 'OpenWOP-Signature MUST stay on the previous secret during the overlap')).toBe(true);
|
|
116
125
|
|
|
117
|
-
// After the overlap:
|
|
118
|
-
|
|
126
|
+
// After the overlap: when it fits the operator's wait cap, or when the §29 seam shortens it.
|
|
127
|
+
let expiresAt = Date.parse(body.previousSecretExpiresAt ?? '');
|
|
119
128
|
if (expiresAt - Date.now() > CAP_MS) {
|
|
129
|
+
const unobserved = (why: string): string => `the post-overlap leg is not observed: the advertised webhooks.secretRotation.overlapSeconds is ${overlapSeconds}s (expiry ${new Date(expiresAt).toISOString()}) and the suite wait cap is ${CAP_MS}ms, and ${why}; raise OPENWOP_WEBHOOK_RETRY_WAIT_MS past the overlap, or serve the seam, to witness it`;
|
|
120
130
|
// partial-witness-ok: the tenant check, the rotate response and the dual signature during
|
|
121
131
|
// the overlap were asserted above. "The previous secret MUST NOT sign after
|
|
122
132
|
// previousSecretExpiresAt" cannot be observed inside a bounded run when the host's
|
|
123
133
|
// advertised webhooks.secretRotation.overlapSeconds outlasts the suite's wait cap, and
|
|
124
134
|
// blocking every long-overlap host would deny certification to conforming hosts
|
|
125
|
-
// (steward ruling, 2026-09-27). The
|
|
126
|
-
//
|
|
127
|
-
return softSkip('inapplicable',
|
|
135
|
+
// (steward ruling, 2026-09-27). The §29 seam closes it, but only for a host that
|
|
136
|
+
// claims the seams profile; this one did not. The row detail names both numbers.
|
|
137
|
+
if (!seamsProfileAdvertised(g.doc)) return softSkip('inapplicable', unobserved('the seams profile is not advertised, so the optional shortenRotationOverlap seam (host-sample-test-seams.md §29) is not offered'));
|
|
138
|
+
const cut = await shortenRotationOverlap(webhookId, SEAM_OVERLAP_SECONDS, foreign);
|
|
139
|
+
// partial-witness-ok: as above — the dual-signing half was asserted. The §29 seam is
|
|
140
|
+
// OPTIONAL and newer than this leg, so a host that does not serve it keeps the partial
|
|
141
|
+
// witness it held before the seam existed, never `blocked`.
|
|
142
|
+
if (!cut.ok && cut.unserved) return softSkip('inapplicable', unobserved(cut.reason));
|
|
143
|
+
if (!cut.ok) return softSkip('blocked', cut.reason);
|
|
144
|
+
expiresAt = cut.expiresAt;
|
|
128
145
|
}
|
|
129
146
|
await new Promise((r) => setTimeout(r, Math.max(0, expiresAt - Date.now()) + 1_500));
|
|
130
147
|
const after = await oneDelivery(rx, webhookId);
|
package/src/setup.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { basename, join } from 'node:path';
|
|
|
36
36
|
import { existsSync, readFileSync } from 'node:fs';
|
|
37
37
|
import { PKG_ROOT_PATH } from './lib/paths.js';
|
|
38
38
|
import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
|
|
39
|
-
import { requirementIdForFile, resolveFileRecord, resolveItRecord, type FileTestState, type TestFailure } from './lib/scenario-disposition.js';
|
|
39
|
+
import { requirementIdForFile, resolveFileRecord, resolveItRecord, unrecordedTests, type FileTestState, type FinishedTest, type TestFailure } from './lib/scenario-disposition.js';
|
|
40
40
|
import { softSkipDisposition, softSkipDispositionSince, softSkipMark } from './lib/soft-skip.js';
|
|
41
41
|
import { ItIdAllocator, takeExplicitRequirementId } from './lib/requirement-ids.js';
|
|
42
42
|
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './lib/spec-coherence.js';
|
|
@@ -250,6 +250,8 @@ const _fileStates = new Map<string, FileTestState[]>();
|
|
|
250
250
|
const _fileAssertions = new Map<string, number>();
|
|
251
251
|
/** Per file: the failing cases, so the file row can NAME them (2.37.0). */
|
|
252
252
|
const _fileFailures = new Map<string, TestFailure[]>();
|
|
253
|
+
/** Task ids the per-test recorder saw, so `afterAll` can recover the ones a throwing scenario hook hid (`unrecordedTests`). */
|
|
254
|
+
const _fileRecorded = new Map<string, Set<string>>();
|
|
253
255
|
const _ledgerMarks = new Map<string, number>();
|
|
254
256
|
// Per-`it` recording (v2 charter Phase 1, suite 1.153.0 — the durable G8 fix
|
|
255
257
|
// named in scenario-disposition.ts). Each test gets its own ledger row under
|
|
@@ -296,6 +298,20 @@ function registeredExplicitId(file: string, title: string): string | null {
|
|
|
296
298
|
return _registry.get(`${file}\u0000${title}`) ?? null;
|
|
297
299
|
}
|
|
298
300
|
|
|
301
|
+
/** Every `it` under a suite or file task, with the state vitest settled on. */
|
|
302
|
+
function _finishedTests(node: unknown): FinishedTest[] {
|
|
303
|
+
const out: FinishedTest[] = [];
|
|
304
|
+
const walk = (n: unknown): void => {
|
|
305
|
+
const t = n as { id?: string; name?: string; type?: string; tasks?: unknown[]; result?: { state?: string; errors?: Array<{ message?: string }> } };
|
|
306
|
+
if (t.type === 'test' && typeof t.id === 'string') {
|
|
307
|
+
const message = t.result?.errors?.[0]?.message;
|
|
308
|
+
out.push({ id: t.id, name: t.name ?? '', state: t.result?.state, ...(message === undefined ? {} : { message }) });
|
|
309
|
+
}
|
|
310
|
+
for (const c of t.tasks ?? []) walk(c);
|
|
311
|
+
};
|
|
312
|
+
walk(node);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
299
315
|
function _fileOf(task: { file?: { filepath?: string; name?: string } } | undefined): string | null {
|
|
300
316
|
const f = task?.file?.filepath ?? task?.file?.name;
|
|
301
317
|
return typeof f === 'string' && f.length > 0 ? basename(f) : null;
|
|
@@ -414,6 +430,9 @@ afterEach(({ task }) => {
|
|
|
414
430
|
if (file === null) return;
|
|
415
431
|
if (!_ledgerMarks.has(file)) _ledgerMarks.set(file, 0);
|
|
416
432
|
const state = task.result?.state;
|
|
433
|
+
const seen = _fileRecorded.get(file) ?? new Set<string>();
|
|
434
|
+
seen.add(task.id);
|
|
435
|
+
_fileRecorded.set(file, seen);
|
|
417
436
|
const arr = _fileStates.get(file) ?? [];
|
|
418
437
|
arr.push(state === 'pass' ? 'pass' : state === 'fail' ? 'fail' : 'skip');
|
|
419
438
|
_fileStates.set(file, arr);
|
|
@@ -506,7 +525,10 @@ afterAll(({}, suite) => {
|
|
|
506
525
|
const s = suite as unknown as { filepath?: string; name?: string; file?: { filepath?: string; name?: string } };
|
|
507
526
|
const file = _fileOf({ file: s.file ?? s });
|
|
508
527
|
if (file === null || !file.endsWith('.test.ts')) return;
|
|
509
|
-
|
|
528
|
+
// A scenario hook that threw before the recorder ran hid its test; recover it from vitest's own results.
|
|
529
|
+
const hidden = unrecordedTests(_finishedTests(s.file ?? s), _fileRecorded.get(file) ?? new Set());
|
|
530
|
+
const states = [...(_fileStates.get(file) ?? []), ...hidden.states];
|
|
531
|
+
if (hidden.failures.length > 0) _fileFailures.set(file, [...(_fileFailures.get(file) ?? []), ...hidden.failures]);
|
|
510
532
|
// Did a behaviorGate in this file record inapplicable/skipped for its profile?
|
|
511
533
|
const mark = _ledgerMarks.get(file) ?? 0;
|
|
512
534
|
const since = journalSince(mark);
|
|
@@ -550,6 +572,7 @@ afterAll(({}, suite) => {
|
|
|
550
572
|
_fileStates.delete(file);
|
|
551
573
|
_fileAssertions.delete(file);
|
|
552
574
|
_fileFailures.delete(file);
|
|
575
|
+
_fileRecorded.delete(file);
|
|
553
576
|
_ledgerMarks.delete(file);
|
|
554
577
|
_itAllocators.delete(file);
|
|
555
578
|
_itMarks.delete(file);
|