@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +3 -3
  3. package/dist/cli.js +15 -12
  4. package/dist/lib/certification-bundle-v3.js +19 -0
  5. package/dist/lib/scenario-disposition.js +30 -0
  6. package/dist/spec-artifacts.lock.json +2 -2
  7. package/fixtures/conformance-approval-reject-loopback.json +33 -0
  8. package/fixtures/conformance-approval-reject-routed.json +32 -0
  9. package/fixtures/conformance-approval-timeout-approve.json +46 -0
  10. package/fixtures/conformance-approval-timeout.json +28 -0
  11. package/fixtures/conformance-replay-ordinal-loop.json +31 -0
  12. package/fixtures.md +69 -1
  13. package/package.json +2 -2
  14. package/requirements.json +243 -30
  15. package/scenario-majors.json +11 -2
  16. package/schemas/CORPUS-STAMP.json +47 -47
  17. package/src/cli.ts +15 -12
  18. package/src/lib/certification-bundle-v3.ts +25 -0
  19. package/src/lib/scenario-disposition.ts +40 -0
  20. package/src/lib/standard-webhooks.ts +53 -0
  21. package/src/scenarios/agent-org-chart-scoping.test.ts +63 -0
  22. package/src/scenarios/v2-agent-org-chart-served-shape.test.ts +63 -0
  23. package/src/scenarios/v2-approval-reject-disposition.test.ts +106 -6
  24. package/src/scenarios/v2-error-registry.test.ts +59 -0
  25. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +21 -9
  26. package/src/scenarios/v2-manifest-hatch-carried.test.ts +23 -7
  27. package/src/scenarios/v2-pack-isolation.test.ts +10 -6
  28. package/src/scenarios/v2-peer-dependency-declared.test.ts +18 -6
  29. package/src/scenarios/v2-replay-suppression-ordinal.test.ts +108 -0
  30. package/src/scenarios/v2-run-fork-ancestry.test.ts +83 -0
  31. package/src/scenarios/v2-webhook-secret-rotation.test.ts +28 -11
  32. 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` only when
14
- * `overlapSeconds` fits inside the suite's retry-wait cap
15
- * (`OPENWOP_WEBHOOK_RETRY_WAIT_MS`, operator-raisable). Past the cap it is not
16
- * observed, and the row says so with a `partial-witness:` detail — which the
17
- * acceptance predicate refuses to cite as `executed-pass` (RFC 0201 §Falsifiability).
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: only when it fits the operator's wait cap.
118
- const expiresAt = Date.parse(body.previousSecretExpiresAt ?? '');
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 row detail names both numbers. Raise
126
- // OPENWOP_WEBHOOK_RETRY_WAIT_MS past the overlap to witness it.
127
- return softSkip('inapplicable', `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; raise OPENWOP_WEBHOOK_RETRY_WAIT_MS past the overlap to witness it`);
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
- const states = _fileStates.get(file) ?? [];
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);