@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
@@ -14,14 +14,32 @@
14
14
  * `retryable: false`, and `run.failed.failedNodeId` names the gate;
15
15
  * 4. `conformance-interrupt-quorum` (requiredApprovals 3, `majority`): one
16
16
  * reject leaves the run `waiting-approval`; a second decides the gate and
17
- * the run terminates `failed` with `approval_rejected`.
17
+ * the run terminates `failed` with `approval_rejected`;
18
+ * 5. `conformance-approval-reject-routed` (gap G1, suite 2.43.1): rejected, the
19
+ * gate fails with `approval_rejected` / `retryable: false`, its
20
+ * `any_failed` target runs, its `all_success` target never runs, and the
21
+ * run completes;
22
+ * 6. `conformance-approval-timeout` (gap G2, suite 2.43.1): left unresolved
23
+ * past its 1500 ms `timeoutMs` with no `onTimeout`, the host resolves the
24
+ * gate itself (`action: timeout`, `decision: rejected`, `reason: timeout`),
25
+ * the gate fails with `approval_rejected` and `run.failed` names it;
26
+ * 7. `conformance-approval-timeout-approve` (openwop#1696, suite 2.43.1): the
27
+ * same gate with `onTimeout: "approve"` still resolves rejected — a
28
+ * timeout never grants — and the run fails, never completes;
29
+ * 8. `conformance-approval-reject-loopback` (openwop#1697, suite 2.43.1): a
30
+ * rejected gate routed back to itself through `revise` is asked again —
31
+ * a second `interrupt.requested` with a different `key`, the run back in
32
+ * `waiting-approval`, and no second `interrupt.resolved` (the first
33
+ * rejection is not replayed). `inapplicable` on a host that does not
34
+ * advertise the fixture, i.e. one that does not run cycles.
18
35
  *
19
36
  * The legs are separate rows so a host that misses one field fails that row
20
- * only. The timeout disposition is not tested: no registered fixture carries a
21
- * gate with a short `timeoutMs`, and the suite cannot make a host's timer fire.
22
- * The routed case (an `all_complete` / `any_failed` edge) is not tested either:
23
- * `conformance-approval` has no edges, and widening a fixture every host serves
24
- * would force a re-registration (fixtures.md §conformance-approval-refine).
37
+ * only. Legs 5 and 6 each need their own fixture, because `conformance-approval`
38
+ * has no edges and no timer and widening a fixture every host serves would force
39
+ * a re-registration (fixtures.md §conformance-approval-refine); a host that
40
+ * does not advertise one records `inapplicable`. Leg 5 does not require
41
+ * `node.skipped`: interrupt.md says the `all_success` edge is not satisfied, not
42
+ * how a host records that, so the leg asserts only that the target never ran.
25
43
  *
26
44
  * The quorum leg presents distinct votes on one bearer through the resume
27
45
  * value's `voter` member, as `interrupt-quorum-resolution` does at major 1. A
@@ -40,11 +58,16 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
40
58
  import { readErrorCode } from '../lib/error-envelope.js';
41
59
  import { softSkip } from '../lib/soft-skip.js';
42
60
  import { req } from '../lib/requirement-ids.js';
61
+ import { scaledTimeoutMs } from '../lib/polling.js';
43
62
 
44
63
  const DOC = 'spec/v2/core/interrupt.md §Rejection (RFC 0223)';
45
64
  const R = (slug: string): string => `openwop.requirement.0223.${slug}`;
46
65
  const FIXTURE = 'conformance-approval';
47
66
  const QUORUM = 'conformance-interrupt-quorum';
67
+ const ROUTED = 'conformance-approval-reject-routed';
68
+ const TIMEOUT = 'conformance-approval-timeout';
69
+ const TIMEOUT_APPROVE = 'conformance-approval-timeout-approve';
70
+ const LOOPBACK = 'conformance-approval-reject-loopback';
48
71
  const NODE_ID = 'gate';
49
72
  const CODE = 'approval_rejected';
50
73
  const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
@@ -159,4 +182,81 @@ describe('RFC 0223 — v2-approval-reject-disposition (gated on interrupt + conf
159
182
  expect(terminal?.['status'], req(id, DOC, `two rejects of three under majority MUST resolve the gate rejected and fail the run (got ${String(terminal?.['status'])})`)).toBe('failed');
160
183
  expect(errorCode(terminal?.['error']), req(id, DOC, 'the terminal snapshot error.code MUST be approval_rejected')).toBe(CODE);
161
184
  }, 60_000);
185
+
186
+ it('a rejected gate routed over an any_failed edge fails as a node, its all_success sibling never runs, and the run completes', async () => {
187
+ if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
188
+ if (!isFixtureAdvertised(ROUTED)) return softSkip('inapplicable', `fixture ${ROUTED} is not advertised — the host does not claim the routed-reject fixture`);
189
+ const s = await suspended(ROUTED);
190
+ if ('reason' in s) return softSkip('blocked', s.reason);
191
+ const res = await resolve(s.runId, { action: 'reject' });
192
+ if (res === null || res.status < 200 || res.status >= 300) return softSkip('blocked', `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract`);
193
+ const id = R('reject-routed');
194
+ const terminal = await waitStatus(s.runId, TERMINAL, 10_000);
195
+ const evs = await events(s.runId);
196
+ const ran = (nodeId: string, type: string): boolean => evs.some((e) => e.type === type && (e.payload?.['nodeId'] ?? e.nodeId) === nodeId);
197
+ const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
198
+ const err = gateFailed?.payload?.['error'] as { code?: unknown; retryable?: unknown } | undefined;
199
+ expect({ code: err?.code, retryable: err?.retryable }, req(id, DOC, 'the routed gate still fails as a node: node.failed.error MUST carry approval_rejected and retryable false')).toEqual({ code: CODE, retryable: false });
200
+ expect(ran('on-reject', 'node.completed'), req(id, DOC, 'the any_failed edge admits the failed gate: on-reject MUST run to node.completed')).toBe(true);
201
+ expect(ran('on-accept', 'node.started') || ran('on-accept', 'node.completed'), req(id, DOC, 'a rejected gate MUST NOT satisfy an all_success edge: on-accept MUST NOT run')).toBe(false);
202
+ expect(terminal?.['status'], req(id, DOC, `a routed rejection does not fail the run: it MUST complete (got ${String(terminal?.['status'])})`)).toBe('completed');
203
+ }, 45_000);
204
+
205
+ it('an unresolved gate past its timeoutMs with no onTimeout is resolved rejected by the host and fails the run naming the gate', async () => {
206
+ if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
207
+ if (!isFixtureAdvertised(TIMEOUT)) return softSkip('inapplicable', `fixture ${TIMEOUT} is not advertised — the host does not claim the timeout fixture`);
208
+ const s = await suspended(TIMEOUT);
209
+ if ('reason' in s) return softSkip('blocked', s.reason);
210
+ const id = R('timeout-rejects');
211
+ const terminal = await waitStatus(s.runId, TERMINAL, scaledTimeoutMs(20_000));
212
+ const evs = await events(s.runId);
213
+ const resolved = evs.find((e) => e.type === 'interrupt.resolved');
214
+ expect(
215
+ { action: resolved?.payload?.['action'], decision: resolved?.payload?.['decision'], reason: resolved?.payload?.['reason'] },
216
+ req(id, DOC, 'the host MUST resolve the timed-out gate itself, recording action "timeout", decision "rejected" and reason "timeout"'),
217
+ ).toEqual({ action: 'timeout', decision: 'rejected', reason: 'timeout' });
218
+ const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
219
+ expect(errorCode(gateFailed?.payload?.['error']), req(id, DOC, 'the timed-out gate MUST fail with approval_rejected')).toBe(CODE);
220
+ const runFailed = evs.find((e) => e.type === 'run.failed');
221
+ expect({ status: terminal?.['status'], code: errorCode(runFailed?.payload?.['error']), failedNodeId: runFailed?.payload?.['failedNodeId'] }, req(id, DOC, 'with no routing edge the run MUST terminate failed, run.failed carrying approval_rejected and failedNodeId naming the gate')).toEqual({ status: 'failed', code: CODE, failedNodeId: NODE_ID });
222
+ }, 60_000);
223
+
224
+ it('a gate whose onTimeout is approve still resolves rejected on timeout and fails the run: a timeout never grants', async () => {
225
+ if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
226
+ if (!isFixtureAdvertised(TIMEOUT_APPROVE)) return softSkip('inapplicable', `fixture ${TIMEOUT_APPROVE} is not advertised — the host does not claim the timeout-approve fixture`);
227
+ const s = await suspended(TIMEOUT_APPROVE);
228
+ if ('reason' in s) return softSkip('blocked', s.reason);
229
+ const id = R('timeout-never-grants');
230
+ const terminal = await waitStatus(s.runId, TERMINAL, scaledTimeoutMs(20_000));
231
+ const evs = await events(s.runId);
232
+ const resolved = evs.find((e) => e.type === 'interrupt.resolved');
233
+ expect(
234
+ { action: resolved?.payload?.['action'], decision: resolved?.payload?.['decision'] },
235
+ req(id, DOC, 'whatever onTimeout holds, the host MUST resolve the timed-out gate rejected (action "timeout", decision "rejected") — onTimeout "approve" is treated as reject'),
236
+ ).toEqual({ action: 'timeout', decision: 'rejected' });
237
+ const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
238
+ expect(errorCode(gateFailed?.payload?.['error']), req(id, DOC, 'the timed-out gate MUST fail with approval_rejected')).toBe(CODE);
239
+ expect(terminal?.['status'], req(id, DOC, `a timeout MUST NOT grant a gate: with no routing edge the run MUST terminate failed, never completed (got ${String(terminal?.['status'])})`)).toBe('failed');
240
+ }, 60_000);
241
+
242
+ it('a rejected gate looped back to itself is asked again under a new key, and the first rejection is not replayed', async () => {
243
+ if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
244
+ if (!isFixtureAdvertised(LOOPBACK)) return softSkip('inapplicable', `fixture ${LOOPBACK} is not advertised — the host does not run cycles`);
245
+ const s = await suspended(LOOPBACK);
246
+ if ('reason' in s) return softSkip('blocked', s.reason);
247
+ const res = await resolve(s.runId, { action: 'reject' });
248
+ if (res === null || res.status < 200 || res.status >= 300) return softSkip('blocked', `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract`);
249
+ const id = R('reject-loopback-reasks');
250
+ const gateRequests = (evs: Ev[]): Ev[] => evs.filter((e) => e.type === 'interrupt.requested' && (e.nodeId ?? e.payload?.['nodeId']) === NODE_ID);
251
+ const deadline = Date.now() + scaledTimeoutMs(10_000);
252
+ let evs = await events(s.runId);
253
+ while (gateRequests(evs).length < 2 && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 250)); evs = await events(s.runId); }
254
+ const snap = await snapshot(s.runId);
255
+ const [first, second] = gateRequests(evs);
256
+ expect(second, req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'the second visit of the gate MUST raise a new interrupt.requested')).toBeDefined();
257
+ expect(second?.payload?.['key'] !== undefined && second?.payload?.['key'] !== first?.payload?.['key'], req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', `a later execution of the node MUST derive a different key (got ${String(first?.payload?.['key'])} then ${String(second?.payload?.['key'])})`)).toBe(true);
258
+ expect(snap?.['status'], req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'the second visit MUST wait for a new decision: the run is waiting-approval again')).toBe('waiting-approval');
259
+ expect(evs.filter((e) => e.type === 'interrupt.resolved').length, req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'an earlier visit\'s resumeValue MUST NOT be returned: only the one caller resolve is recorded')).toBe(1);
260
+ await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
261
+ }, 45_000);
162
262
  });
@@ -10,6 +10,14 @@
10
10
  * lives in `Retry-After` only, which a `429 rate_limited` MUST set. The
11
11
  * deliberately bad request is `GET /runs/does-not-exist` (outside the runId
12
12
  * grammar, so a host may answer `400 validation_error` or `404 not_found`).
13
+ *
14
+ * `event-code-registered` (openwop#1698, suite 2.43.1): errors.md §The
15
+ * registry binds `error.code` on `run.failed`, `node.failed` and the snapshot's
16
+ * `error` exactly as it binds an error response. The leg runs
17
+ * `conformance-failure` and checks every such code against the registry and the
18
+ * vendor pattern (first segment an org registered in `spec/v2/declaration.json`).
19
+ * ADVISORY for suite 2.43.1: a violation is recorded as a partial witness naming
20
+ * the codes, not a failure, while hosts remap (per-host rows in gaps.json).
13
21
  */
14
22
 
15
23
  import { describe, it, expect } from 'vitest';
@@ -25,6 +33,8 @@ import { req } from '../lib/requirement-ids.js';
25
33
  const DOC = 'spec/v2/core/errors.md §The envelope';
26
34
  const ERRORS_PATH = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'errors.json');
27
35
  const RETRY_SPELLINGS = ['retryAfter', 'retryAfterMs', 'retryAfterSeconds'];
36
+ const DECLARATION_PATH = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'declaration.json');
37
+ const FAILURE_FIXTURE = 'conformance-failure';
28
38
 
29
39
  async function discovery(): Promise<Record<string, unknown> | null> {
30
40
  try { return await v2Discovery(); } catch { return null; }
@@ -40,6 +50,16 @@ function registryStatus(): Map<string, number> | null {
40
50
  return new Map(rows.map((r) => [r.code, r.httpStatus]));
41
51
  }
42
52
 
53
+ /** code → registered, or a vendor code whose org is registered; null when the registry or declaration is absent. */
54
+ function codeAccepted(): ((code: string) => boolean) | null {
55
+ const registry = registryStatus();
56
+ if (registry === null || !existsSync(DECLARATION_PATH)) return null;
57
+ const reg = JSON.parse(readFileSync(ERRORS_PATH, 'utf8')) as { vendorCodePattern?: string };
58
+ const pattern = new RegExp(reg.vendorCodePattern ?? '^(?!openwop\\.)[a-z][a-z0-9]*(-[a-z0-9]+)*\\.[a-z][a-z0-9_]*$');
59
+ const orgs = new Set(Object.keys((JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')) as { extensions?: Record<string, unknown> }).extensions ?? {}));
60
+ return (code: string) => registry.has(code) || (pattern.test(code) && orgs.has(code.split('.')[0] ?? ''));
61
+ }
62
+
43
63
  async function badRequest(): Promise<OpenWOPResponse | { reason: string }> {
44
64
  if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
45
65
  const res = await http(() => driver.get('/runs/does-not-exist'));
@@ -86,4 +106,43 @@ describe('v2 error-registry (RFC 0171 §B.1–§B.2)', () => {
86
106
  expect(r.headers.get('retry-after'), req('openwop.requirement.0171.error-registry.retry-after', 'spec/v2/core/errors.md §Retry timing', 'a 429 rate_limited response MUST set Retry-After')).not.toBeNull();
87
107
  }
88
108
  });
109
+
110
+ it('an error code on run.failed, node.failed and the snapshot is registered or a vendor code (advisory in 2.43.1)', async () => {
111
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
112
+ const accepted = codeAccepted();
113
+ if (accepted === null) return softSkip('inapplicable', 'spec/v2/errors.json or spec/v2/declaration.json is not present in this layout');
114
+ const fixtures = ((await discovery())?.['fixtures'] ?? []) as unknown[];
115
+ if (!fixtures.includes(FAILURE_FIXTURE)) return softSkip('blocked', `fixture ${FAILURE_FIXTURE} is not advertised — no failing run to read codes from`);
116
+ const create = await http(() => driver.post('/runs', { workflowId: FAILURE_FIXTURE }));
117
+ const runId = (create?.json as { runId?: unknown } | null)?.runId;
118
+ if (create === null || create.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs (${FAILURE_FIXTURE}) answered ${create?.status ?? 'nothing'}`);
119
+ const deadline = Date.now() + 10_000;
120
+ let snap: Record<string, unknown> | null = null;
121
+ for (;;) {
122
+ const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
123
+ snap = r?.status === 200 ? (r.json as Record<string, unknown>) : null;
124
+ if (snap !== null && ['completed', 'failed', 'cancelled'].includes(String(snap['status']))) break;
125
+ if (Date.now() > deadline) return softSkip('blocked', `the ${FAILURE_FIXTURE} run did not reach a terminal state within 10 s`);
126
+ await new Promise((ok) => setTimeout(ok, 250));
127
+ }
128
+ const polled = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
129
+ const events = ((polled?.json as { events?: Array<{ type?: string; payload?: { error?: { code?: unknown } } }> } | null)?.events ?? []);
130
+ const seen: Array<{ where: string; code: string }> = [];
131
+ for (const e of events) {
132
+ const code = e.payload?.error?.code;
133
+ if ((e.type === 'run.failed' || e.type === 'node.failed') && typeof code === 'string') seen.push({ where: e.type, code });
134
+ }
135
+ const snapCode = (snap?.['error'] as { code?: unknown } | undefined)?.code;
136
+ if (typeof snapCode === 'string') seen.push({ where: 'snapshot.error', code: snapCode });
137
+ const ID = 'openwop.requirement.errors.event-code-registered';
138
+ expect(seen.length, req(ID, 'spec/v2/core/errors.md §The registry', `a failed ${FAILURE_FIXTURE} run MUST carry an error code on run.failed, node.failed or the snapshot (status ${String(snap?.['status'])})`)).toBeGreaterThan(0);
139
+ const bad = seen.filter((s) => !accepted(s.code));
140
+ if (bad.length > 0) {
141
+ // partial-witness-ok: ADVISORY for suite 2.43.1 (openwop#1698). The codes were
142
+ // observed and checked above; a violation is recorded, not failed, for one
143
+ // suite patch while hosts remap. Per-host gap rows track them.
144
+ return softSkip('inapplicable', `ADVISORY (suite 2.43.1, openwop#1698): unregistered, non-vendor error codes: ${bad.map((b) => `${b.where}=${b.code}`).join(', ')}`);
145
+ }
146
+ expect(bad, req(ID, 'spec/v2/core/errors.md §The registry', 'every error code on run.failed, node.failed and the snapshot MUST be registered or a vendor code')).toEqual([]);
147
+ }, 45_000);
89
148
  });
@@ -14,10 +14,12 @@
14
14
  * 2. `>=1.0.0 <2.0.0` (v1 ceiling) → refused, pack_engine_unsupported
15
15
  * 3. `>=2.0.0 <3.0.0` (admits major 2) → installs (200/201)
16
16
  *
17
- * Gated on the `packs` family (RFC 0169 §A.2: presence is the claim) and on
18
- * the seams profile (`conformance.seamsProfile: openwop-conformance-seams-v2`);
19
- * a host without the profile records `blocked` — the write API is the only
20
- * install path the suite can drive.
17
+ * Gated on the seams profile (`conformance.seamsProfile:
18
+ * openwop-conformance-seams-v2`): its publish seam is the only install path the
19
+ * suite can drive. Not gated on `packs` since suite 2.43.1 (#1706): §A.1 binds
20
+ * at install on every path, and `packs` advertises registry resolution, which a
21
+ * host installing only local packs correctly omits. A seam 404 records `blocked`
22
+ * when `packs` is advertised and `inapplicable` when it is not.
21
23
  *
22
24
  * @see RFCS/0177-v2-registry-packs-and-extension-tail.md §A.1
23
25
  * @see spec/v2/core/packs.md §"The engine range"
@@ -31,7 +33,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
31
33
  import { req } from '../lib/requirement-ids.js';
32
34
  import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
33
35
  import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
34
- import { v2Discovery, gateFamily } from '../lib/v2.js';
36
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
35
37
 
36
38
  const SECTION = 'packs.md §"The engine range" (RFC 0177 §A.1)';
37
39
 
@@ -87,6 +89,17 @@ async function publish(manifest: Record<string, unknown>) {
87
89
  }
88
90
 
89
91
  /** The reason this leg cannot run, or null when the seam is reachable. */
92
+ /**
93
+ * The publish seam answered 404. Whether that is a defect depends on `packs`
94
+ * (#1706): a host advertising `packs` claims an install path and must mount it
95
+ * (`blocked`); a host that omits `packs` may have no install path the suite can
96
+ * drive (`inapplicable`). §A.1 binds at install on every path, not on `packs`.
97
+ */
98
+ async function seamMissing(): Promise<undefined> {
99
+ if (await familyAdvertised('packs')) return softSkip('blocked', 'packs-test publish seam answered 404 — packs and the seams profile are advertised but the seam is not mounted');
100
+ return softSkip('inapplicable', 'packs-test publish seam answered 404 and packs is not advertised — no install path the suite can drive');
101
+ }
102
+
90
103
  async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
91
104
  if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
92
105
  let doc: Record<string, unknown> | null;
@@ -96,7 +109,6 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
96
109
  doc = null;
97
110
  }
98
111
  if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
99
- if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
100
112
  if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
101
113
  return null;
102
114
  }
@@ -106,7 +118,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
106
118
  const skip = await preflight();
107
119
  if (skip) return softSkip(skip.kind, skip.reason);
108
120
  const res = await publish(nodePack(freshName('unbounded'), '>=1.0.0'));
109
- if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
121
+ if (res.status === 404) return seamMissing();
110
122
  expect(res.status, req('openwop.requirement.0177.manifest-ceiling-refused.unbounded', SECTION, 'an unbounded range does not admit protocol major 2 and MUST be refused with 400 pack_engine_unsupported')).toBe(400);
111
123
  expect(readErrorCode(res.json), req('openwop.requirement.0177.manifest-ceiling-refused.unbounded', SECTION, 'the refusal code MUST be pack_engine_unsupported (not pack_runtime_requirement_unmet)')).toBe('pack_engine_unsupported');
112
124
  });
@@ -115,7 +127,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
115
127
  const skip = await preflight();
116
128
  if (skip) return softSkip(skip.kind, skip.reason);
117
129
  const res = await publish(nodePack(freshName('v1-ceiling'), '>=1.0.0 <2.0.0'));
118
- if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
130
+ if (res.status === 404) return seamMissing();
119
131
  expect(res.status, req('openwop.requirement.0177.manifest-ceiling-refused.v1-ceiling', SECTION, 'a <2.0.0 ceiling does not admit protocol major 2 and MUST be refused with 400 pack_engine_unsupported')).toBe(400);
120
132
  expect(readErrorCode(res.json), req('openwop.requirement.0177.manifest-ceiling-refused.v1-ceiling', SECTION, 'the refusal code MUST be pack_engine_unsupported')).toBe('pack_engine_unsupported');
121
133
  });
@@ -124,7 +136,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
124
136
  const skip = await preflight();
125
137
  if (skip) return softSkip(skip.kind, skip.reason);
126
138
  const res = await publish(nodePack(freshName('v2-installs'), '>=2.0.0 <3.0.0'));
127
- if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
139
+ if (res.status === 404) return seamMissing();
128
140
  expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-ceiling-refused.v2-installs', SECTION, `a manifest whose range admits major 2 MUST install (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBe(true);
129
141
  });
130
142
  });
@@ -15,8 +15,10 @@
15
15
  * packs at all (`pack_kind_invalid`) records `skipped` for that leg.
16
16
  *
17
17
  * Installs through the seams-profile publish seam
18
- * (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on `packs` + the
19
- * seams profile.
18
+ * (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on the seams
19
+ * profile; not on `packs` since suite 2.43.1 (#1706) — the hatch binds at install
20
+ * on every path. A seam 404 records `blocked` when `packs` is advertised and
21
+ * `inapplicable` when it is not.
20
22
  *
21
23
  * @see RFCS/0177-v2-registry-packs-and-extension-tail.md §C.2
22
24
  * @see spec/v2/core/packs.md §"The manifest schema family"
@@ -30,7 +32,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
30
32
  import { req } from '../lib/requirement-ids.js';
31
33
  import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
32
34
  import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
33
- import { v2Discovery, gateFamily } from '../lib/v2.js';
35
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
34
36
 
35
37
  const SECTION = 'packs.md §"The manifest schema family" (RFC 0177 §C.2)';
36
38
 
@@ -67,6 +69,17 @@ async function publish(manifest: Record<string, unknown>, files: Record<string,
67
69
  headers: { 'Content-Type': 'application/octet-stream' },
68
70
  });
69
71
  }
72
+ /**
73
+ * The publish seam answered 404. Whether that is a defect depends on `packs`
74
+ * (#1706): a host advertising `packs` claims an install path and must mount it
75
+ * (`blocked`); a host that omits `packs` may have no install path the suite can
76
+ * drive (`inapplicable`). §A.1 binds at install on every path, not on `packs`.
77
+ */
78
+ async function seamMissing(): Promise<undefined> {
79
+ if (await familyAdvertised('packs')) return softSkip('blocked', 'packs-test publish seam answered 404 — packs and the seams profile are advertised but the seam is not mounted');
80
+ return softSkip('inapplicable', 'packs-test publish seam answered 404 and packs is not advertised — no install path the suite can drive');
81
+ }
82
+
70
83
  async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
71
84
  if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
72
85
  let doc: Record<string, unknown> | null;
@@ -76,7 +89,6 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
76
89
  doc = null;
77
90
  }
78
91
  if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
79
- if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
80
92
  if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
81
93
  return null;
82
94
  }
@@ -93,10 +105,14 @@ describe('v2-manifest-hatch-carried (RFC 0177 §C.2)', () => {
93
105
  engines: { openwop: '>=2.0.0 <3.0.0' },
94
106
  runtime: { language: 'javascript', entry: 'index.mjs', format: 'esm' },
95
107
  nodes: [{ typeId: `${name}.echo`, version: '1.0.0', category: 'data', role: 'pure' }],
96
- agents: [{ agentId: `${name}.helper`, persona: 'Hatch Helper', modelClass: 'general', 'x-vendor-note': 'a hatch property the host MUST ignore' }],
108
+ // systemPrompt: agent-manifest requires exactly one of systemPrompt /
109
+ // systemPromptRef (oneOf). Without it the fixture was schema-invalid for a
110
+ // reason unrelated to the hatch, and a conforming host had to refuse it
111
+ // (2.43.1; reported by MyndHyve).
112
+ agents: [{ agentId: `${name}.helper`, persona: 'Hatch Helper', modelClass: 'general', systemPrompt: 'You are a conformance fixture.', 'x-vendor-note': 'a hatch property the host MUST ignore' }],
97
113
  };
98
114
  const res = await publish(manifest, { 'index.mjs': ENTRY });
99
- if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
115
+ if (res.status === 404) return seamMissing();
100
116
  expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-hatch-carried.agents-x-field', SECTION, `agent-manifest admits ^(openwop-|x-|vendor\\.) — a pack whose agents[0] carries x-vendor-note MUST install (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBe(true);
101
117
  });
102
118
 
@@ -112,7 +128,7 @@ describe('v2-manifest-hatch-carried (RFC 0177 §C.2)', () => {
112
128
  prompts: [{ templateId: `${name}.system`, version: '1.0.0', kind: 'system', text: 'You are a conformance fixture.', 'x-vendor-note': 'a hatch property the host MUST ignore' }],
113
129
  };
114
130
  const res = await publish(manifest, {});
115
- if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
131
+ if (res.status === 404) return seamMissing();
116
132
  const code = readErrorCode(res.json);
117
133
  if (res.status >= 400 && code === 'pack_kind_invalid') return softSkip('skipped', 'host does not install kind: prompt packs (pack_kind_invalid) — the prompt-template hatch cannot be observed on this host');
118
134
  expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-hatch-carried.prompt-template-x-field', SECTION, `prompt-template admits ^(openwop-|x-|vendor\\.) — a prompt pack whose prompts[0] carries x-vendor-note MUST install (got ${res.status} ${code ?? ''})`)).toBe(true);
@@ -1,5 +1,10 @@
1
1
  /**
2
- * RFC 0173 §B — `pack-isolation` (suite 2.0.0, target major 2; gated on `packs` + `sandbox`).
2
+ * RFC 0173 §B — `pack-isolation` (suite 2.0.0, target major 2; gated on `sandbox`).
3
+ *
4
+ * Gated on `sandbox` alone since suite 2.43.1 (#1706). It was gated on `packs`
5
+ * + `sandbox`, but `packs` advertises registry resolution (packs.md §The
6
+ * `packs` capability), which a host executing only local packs correctly omits.
7
+ * `sandbox` is the advertisement §B requires of any host executing packs.
3
8
  *
4
9
  * Isolation binds with pack EXECUTION: a host that executes third-party packs
5
10
  * MUST enforce the eight `node-pack-sandbox-*` invariants and MUST advertise
@@ -41,11 +46,10 @@ async function discovery(): Promise<Record<string, unknown> | null> {
41
46
  try { return await v2Discovery(); } catch { return null; }
42
47
  }
43
48
 
44
- /** Gate for every leg: packs + sandbox advertised. Returns the sandbox facet or null (reason recorded). */
49
+ /** Gate for every leg: sandbox advertised. Returns the sandbox facet or null (reason recorded). */
45
50
  async function gated(): Promise<Record<string, unknown> | null> {
46
51
  const doc = await discovery();
47
52
  if (!doc) { softSkip('blocked', 'discovery unreachable'); return null; }
48
- if (!(await gateFamily('packs'))) { softSkip('inapplicable', 'packs family not advertised — no pack execution, no isolation obligation (gate recorded under openwop.family.packs)'); return null; }
49
53
  const sandbox = await gateFamily('sandbox');
50
54
  if (!sandbox) { softSkip('inapplicable', 'sandbox family not advertised (gate recorded under openwop.family.sandbox)'); return null; }
51
55
  return sandbox;
@@ -56,7 +60,7 @@ async function invoke(typeId: string, extra: Record<string, unknown> = {}): Prom
56
60
  const doc = await discovery();
57
61
  if (!doc || !seamsProfileAdvertised(doc)) { softSkip('inapplicable', `the ${typeId} leg is seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised`); return null; }
58
62
  const res = await driver.post(INVOKE, { typeId, ...extra });
59
- if (res.status === 404 || res.status === 403 || res.status === 405) { seamAbsent(`host advertises packs + sandbox but ${INVOKE} answered ${res.status} — the ${typeId} leg is unobservable (host-sample-test-seams.md §8)`); return null; }
63
+ if (res.status === 404 || res.status === 403 || res.status === 405) { seamAbsent(`host advertises sandbox but ${INVOKE} answered ${res.status} — the ${typeId} leg is unobservable (host-sample-test-seams.md §8)`); return null; }
60
64
  // The same id is ALSO asserted by its own `it` above, and that is the copy the
61
65
  // registry can see: `generate-requirement-registry.mjs` harvests `req(…)` only
62
66
  // within an `it`, so this helper-level call alone left the id out of
@@ -74,7 +78,7 @@ function expectEscape(id: string, r: InvokeResult, escapeKind: string, invariant
74
78
  expect(r.error?.details?.escapeKind, req(id, 'host-sample-test-seams.md §8 SandboxError', `escapeKind MUST be ${escapeKind}`)).toBe(escapeKind);
75
79
  }
76
80
 
77
- describe('RFC 0173 §B — pack-isolation (gated on packs + sandbox)', () => {
81
+ describe('RFC 0173 §B — pack-isolation (gated on sandbox)', () => {
78
82
  it('sandbox.isolationModel names a real mechanism, never node:vm', async () => {
79
83
  const sandbox = await gated();
80
84
  if (!sandbox) return softSkip('inapplicable', 'gate not met (reason recorded above)');
@@ -94,7 +98,7 @@ describe('RFC 0173 §B — pack-isolation (gated on packs + sandbox)', () => {
94
98
  const doc = await discovery();
95
99
  if (!doc || !seamsProfileAdvertised(doc)) return softSkip('inapplicable', 'seam-driven — seams profile (conformance.seamsProfile = openwop-conformance-seams-v2) not advertised');
96
100
  const res = await driver.post(INVOKE, { typeId: 'misbehave.fs-escape-read' });
97
- if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises packs + sandbox but ${INVOKE} answered ${res.status}`);
101
+ if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises sandbox but ${INVOKE} answered ${res.status}`);
98
102
  expect(
99
103
  res.status,
100
104
  req('openwop.requirement.0173.pack-isolation.seam', 'host-sample-test-seams.md §8', `${INVOKE} MUST answer 200 { result } | 200 { error } — the eight behavioural legs are unobservable otherwise`),
@@ -17,8 +17,10 @@
17
17
  * failure; any other refusal fails.
18
18
  *
19
19
  * Both legs install through the seams-profile publish seam
20
- * (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on `packs` + the
21
- * seams profile.
20
+ * (`/conformance/seams/packs-test/…`, RFC 0168 §C.2). Gated on the seams
21
+ * profile; not on `packs` since suite 2.43.1 (#1706) — the rule binds at install
22
+ * on every path. A seam 404 records `blocked` when `packs` is advertised and
23
+ * `inapplicable` when it is not.
22
24
  *
23
25
  * @see RFCS/0177-v2-registry-packs-and-extension-tail.md §B.1, §B.2
24
26
  * @see spec/v2/core/packs.md §"Peer-dependency identifiers", §"The alias table"
@@ -35,7 +37,7 @@ import { SCHEMAS_DIR } from '../lib/paths.js';
35
37
  import { req } from '../lib/requirement-ids.js';
36
38
  import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
37
39
  import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
38
- import { v2Discovery, gateFamily, familyAdvertised } from '../lib/v2.js';
40
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
39
41
 
40
42
  const SECTION = 'packs.md §"Peer-dependency identifiers" (RFC 0177 §B.1)';
41
43
  const ALIASES = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'peer-dependency-aliases.json');
@@ -86,6 +88,17 @@ async function publish(manifest: Record<string, unknown>) {
86
88
  headers: { 'Content-Type': 'application/octet-stream' },
87
89
  });
88
90
  }
91
+ /**
92
+ * The publish seam answered 404. Whether that is a defect depends on `packs`
93
+ * (#1706): a host advertising `packs` claims an install path and must mount it
94
+ * (`blocked`); a host that omits `packs` may have no install path the suite can
95
+ * drive (`inapplicable`). §A.1 binds at install on every path, not on `packs`.
96
+ */
97
+ async function seamMissing(): Promise<undefined> {
98
+ if (await familyAdvertised('packs')) return softSkip('blocked', 'packs-test publish seam answered 404 — packs and the seams profile are advertised but the seam is not mounted');
99
+ return softSkip('inapplicable', 'packs-test publish seam answered 404 and packs is not advertised — no install path the suite can drive');
100
+ }
101
+
89
102
  async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
90
103
  if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
91
104
  let doc: Record<string, unknown> | null;
@@ -95,7 +108,6 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
95
108
  doc = null;
96
109
  }
97
110
  if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
98
- if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
99
111
  if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
100
112
  return null;
101
113
  }
@@ -105,7 +117,7 @@ describe('v2-peer-dependency-declared (RFC 0177 §B.1/§B.2)', () => {
105
117
  const skip = await preflight();
106
118
  if (skip) return softSkip(skip.kind, skip.reason);
107
119
  const res = await publish(nodePack(freshName('peer-undefined'), { 'host.nonexistent': 'required' }));
108
- if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
120
+ if (res.status === 404) return seamMissing();
109
121
  expect(res.status, req('openwop.requirement.0177.peer-dependency-declared.undefined-key', SECTION, 'a key absent from spec/v2/declaration.json MUST be refused with 400 pack_peer_dependency_undefined')).toBe(400);
110
122
  expect(readErrorCode(res.json), req('openwop.requirement.0177.peer-dependency-declared.undefined-key', SECTION, 'the refusal code MUST be pack_peer_dependency_undefined')).toBe('pack_peer_dependency_undefined');
111
123
  });
@@ -124,7 +136,7 @@ describe('v2-peer-dependency-declared (RFC 0177 §B.1/§B.2)', () => {
124
136
  }
125
137
  if (!chosen) return softSkip('inapplicable', 'no facet-less alias row names a family this host advertises — the overlap alias cannot be exercised');
126
138
  const res = await publish(nodePack(freshName('peer-alias'), { [chosen.alias]: 'required' }));
127
- if (res.status === 404) return softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
139
+ if (res.status === 404) return seamMissing();
128
140
  const code = readErrorCode(res.json);
129
141
  if (res.status === 400 && code === 'pack_peer_dependency_undefined') return softSkip('skipped', `host does not resolve the overlap alias ${chosen.alias} (RFC 0177 §B.2 is a MAY during the overlap; MUST NOT after v1 end-of-support)`);
130
142
  expect([200, 201].includes(res.status), req('openwop.requirement.0177.peer-dependency-declared.alias-overlap', 'packs.md §"The alias table" (RFC 0177 §B.2)', `alias ${chosen.alias} → ${chosen.family} (advertised) MUST install through the overlap or be refused only with pack_peer_dependency_undefined (got ${res.status} ${code ?? ''})`)).toBe(true);
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `spec/v2/core/replay.md` §Suppression rule 2 — a replay fork resolves a
3
+ * side-effecting node's outcome keyed on `(sourceRunId, nodeId, n)`, where `n`
4
+ * counts the node's `node.started` events through this execution, including
5
+ * retries, later visits and the fork's inherited prefix (openwop#1718, suite
6
+ * 2.43.2, target major 2).
7
+ *
8
+ * The rule said `(nodeId, attempt)`. In a run where a node executes more than
9
+ * once (a loop back over an edge), that key names no single execution: a host
10
+ * counting attempts per execution resolves every visit from the first visit's
11
+ * outcome, and one selecting the latest recorded outcome does the same when
12
+ * only one exists.
13
+ *
14
+ * Construction (`conformance-replay-ordinal-loop`): start → effect → wait →
15
+ * effect, so `effect` executes twice. The suite cancels the source inside
16
+ * `wait`, after `effect`'s first execution completed and before its second, and
17
+ * forks `mode: "replay"` at `effect`'s first `node.started`. In the fork:
18
+ * - `effect` execution 1 (n = 1) has a recorded outcome and resolves from it;
19
+ * - `effect` execution 2 (n = 2) has none, so it MUST fail closed with
20
+ * `replay_source_missing` (rule 3) and MUST NOT complete.
21
+ * Only the fail-closed half is asserted as the discriminator: a host keyed on
22
+ * the wrong ordinal completes execution 2 from execution 1's outcome.
23
+ *
24
+ * Gated on `replay` and on the fixture, which only a host that runs cycles and
25
+ * suppresses side effects advertises. No such host is known today (the v2
26
+ * reference host does not loop), so the row has a negative control only — see
27
+ * RFC 0140's gap register.
28
+ *
29
+ * @see spec/v2/core/replay.md §Suppression
30
+ * @see RFCS/0140-replay-side-effect-suppression.md
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest';
34
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
35
+ import { gateFamily } from '../lib/v2.js';
36
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
37
+ import { readErrorCode } from '../lib/error-envelope.js';
38
+ import { softSkip } from '../lib/soft-skip.js';
39
+ import { req } from '../lib/requirement-ids.js';
40
+ import { scaledTimeoutMs } from '../lib/polling.js';
41
+
42
+ const ID = 'openwop.requirement.replay.suppression-execution-ordinal';
43
+ const DOC = 'spec/v2/core/replay.md §Suppression';
44
+ const FIXTURE = 'conformance-replay-ordinal-loop';
45
+ const EFFECT = 'effect';
46
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
47
+
48
+ interface Ev { readonly sequence?: unknown; readonly type?: unknown; readonly nodeId?: unknown; readonly payload?: { readonly nodeId?: unknown; readonly error?: { readonly code?: unknown } } }
49
+
50
+ const enc = (id: string): string => encodeURIComponent(id);
51
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
52
+ const nodeOf = (e: Ev): unknown => e.nodeId ?? e.payload?.nodeId;
53
+
54
+ async function logOf(runId: string): Promise<Ev[]> {
55
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1&streamMode=debug`));
56
+ const events = (res?.json as { events?: unknown } | null)?.events;
57
+ return Array.isArray(events) ? (events as Ev[]).filter((e) => typeof e.sequence === 'number').sort((a, b) => (a.sequence as number) - (b.sequence as number)) : [];
58
+ }
59
+ async function statusOf(runId: string): Promise<string | null> {
60
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
61
+ return res?.status === 200 ? String((res.json as { status?: unknown } | null)?.status ?? '') : null;
62
+ }
63
+ async function until(check: () => Promise<boolean>, ms: number): Promise<boolean> {
64
+ const deadline = Date.now() + ms;
65
+ for (;;) {
66
+ if (await check()) return true;
67
+ if (Date.now() > deadline) return false;
68
+ await new Promise((r) => setTimeout(r, 200));
69
+ }
70
+ }
71
+
72
+ describe('v2 replay-suppression-ordinal (replay.md §Suppression rule 2, openwop#1718)', () => {
73
+ it('a replay fork resolves each execution of a looped side-effecting node by its own ordinal, failing closed where none was recorded', async () => {
74
+ if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
75
+ if (!isFixtureAdvertised(FIXTURE)) return softSkip('inapplicable', `fixture ${FIXTURE} is not advertised — the host does not run cycles with a side-effecting node`);
76
+
77
+ const created = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs: { delayMs: 3000 } }));
78
+ const runId = (created?.json as { runId?: unknown } | null)?.runId;
79
+ if (created === null || created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs (${FIXTURE}) answered ${created?.status ?? 'nothing'} ${readErrorCode(created?.json) ?? ''}`.trim());
80
+
81
+ // Cancel inside `wait`: effect's first execution completed, its second not yet started.
82
+ const firstDone = await until(async () => (await logOf(runId)).some((e) => e.type === 'node.completed' && nodeOf(e) === EFFECT), scaledTimeoutMs(10_000));
83
+ if (!firstDone) return softSkip('blocked', `the source run never completed ${EFFECT}'s first execution`);
84
+ await http(() => driver.post(`/runs/${enc(runId)}/cancel`, {}));
85
+ if (!(await until(async () => TERMINAL.has((await statusOf(runId)) ?? ''), scaledTimeoutMs(10_000)))) return softSkip('blocked', 'the cancelled source run did not settle');
86
+ const source = await logOf(runId);
87
+ const effectStarts = source.filter((e) => e.type === 'node.started' && nodeOf(e) === EFFECT);
88
+ const effectDone = source.filter((e) => e.type === 'node.completed' && nodeOf(e) === EFFECT);
89
+ if (effectStarts.length !== 1 || effectDone.length !== 1) return softSkip('blocked', `the source was cancelled with ${effectStarts.length} starts and ${effectDone.length} completions of ${EFFECT}, not exactly one of each — the second execution cannot be isolated`);
90
+
91
+ const fromSeq = effectStarts[0]!.sequence as number;
92
+ const fork = await http(() => driver.post(`/runs/${enc(runId)}:fork`, { mode: 'replay', fromSeq }));
93
+ const forkId = (fork?.json as { runId?: unknown } | null)?.runId;
94
+ if (fork === null || fork.status !== 201 || typeof forkId !== 'string') return softSkip('blocked', `POST /runs/{runId}:fork answered ${fork?.status ?? 'nothing'} ${readErrorCode(fork?.json) ?? ''} — forkRun owns that contract`.trim());
95
+ await until(async () => TERMINAL.has((await statusOf(forkId)) ?? ''), scaledTimeoutMs(20_000));
96
+ const forked = await logOf(forkId);
97
+ const forkEffect = forked.filter((e) => nodeOf(e) === EFFECT);
98
+
99
+ expect(
100
+ forkEffect.filter((e) => e.type === 'node.completed').length,
101
+ req(ID, DOC, 'only the execution with a recorded outcome (n = 1) may complete in the fork; execution 2 has none and MUST NOT complete'),
102
+ ).toBeLessThanOrEqual(1);
103
+ expect(
104
+ forkEffect.some((e) => e.type === 'node.failed' && e.payload?.error?.code === 'replay_source_missing'),
105
+ req(ID, `${DOC} rules 2–3`, `the second execution of ${EFFECT} (n = 2) has no recorded source outcome and MUST fail closed with replay_source_missing — a host keyed on (nodeId, attempt), or on the latest outcome, resolves it from the first execution instead`),
106
+ ).toBe(true);
107
+ }, 90_000);
108
+ });