@openwop/openwop-conformance 2.46.42 → 2.46.44

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.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * RFC 0258 — judges for `v2-bounded-loopback.test.ts`. Pure functions over a
3
+ * run's event log, so each can be shown to fail on a sabotaged log
4
+ * (`bounded-loopback-witness.test.ts`). Rules: `$defs/EdgeLoopback` in
5
+ * `schemas/v2/workflow-definition.schema.json`, `$defs/nodeCompleted.loopback`
6
+ * in `run-event-payloads.schema.json`, `$defs/ApprovalData.loopback` in
7
+ * `suspend-request.schema.json`.
8
+ */
9
+
10
+ export interface Ev { type?: string; sequence?: number; nodeId?: string; payload?: Record<string, unknown> }
11
+ export interface LoopFinding { ok: boolean; message: string }
12
+
13
+ const ok = (cond: boolean, message: string): LoopFinding => ({ ok: cond, message });
14
+ const nodeOf = (e: Ev): string | undefined => (typeof e.nodeId === 'string' ? e.nodeId : (e.payload?.['nodeId'] as string | undefined));
15
+ const of = (log: readonly Ev[], type: string, nodeId?: string): Ev[] => log.filter((e) => e.type === type && (nodeId === undefined || nodeOf(e) === nodeId));
16
+
17
+ /** Every `interrupt.requested` raised by `gate`, in log order. */
18
+ export function requestsOf(log: readonly Ev[], gate: string): Ev[] { return of(log, 'interrupt.requested', gate); }
19
+
20
+ /** The `ApprovalData.loopback` of one request, or undefined. */
21
+ export function approvalLoopback(e: Ev | undefined): Record<string, unknown> | undefined {
22
+ const data = e?.payload?.['data'] as Record<string, unknown> | undefined;
23
+ const lb = data?.['loopback'];
24
+ return lb && typeof lb === 'object' ? (lb as Record<string, unknown>) : undefined;
25
+ }
26
+
27
+ /** The gate's `node.completed` events that carry the §C.1 marker. */
28
+ export function markersOf(log: readonly Ev[], gate: string): Ev[] {
29
+ return of(log, 'node.completed', gate).filter((e) => e.payload?.['loopback'] !== undefined);
30
+ }
31
+
32
+ /**
33
+ * `0258.refine-rearms` — after one refine: the gate's completion carries the
34
+ * marker for round 1 naming every region node, the target starts again, the
35
+ * gate is asked again under a different key, and the gate's forward target
36
+ * never started.
37
+ */
38
+ export function judgeRearm(log: readonly Ev[], o: { gate: string; target: string; edgeId: string; region: readonly string[]; forward: string }): LoopFinding[] {
39
+ const marks = markersOf(log, o.gate);
40
+ const m = marks[0]?.payload?.['loopback'] as Record<string, unknown> | undefined;
41
+ const reqs = requestsOf(log, o.gate);
42
+ const keys = reqs.map((e) => e.payload?.['key']);
43
+ return [
44
+ ok(marks.length === 1, `the gate's node.completed MUST carry loopback after one refine (found ${marks.length} marked completion(s))`),
45
+ ok(m?.['edgeId'] === o.edgeId && m?.['targetNodeId'] === o.target && m?.['round'] === 1, `the marker MUST read {edgeId: ${o.edgeId}, targetNodeId: ${o.target}, round: 1} (got ${JSON.stringify(m)})`),
46
+ ok(JSON.stringify(m?.['rearmed']) === JSON.stringify(o.region), `rearmed MUST list the region in nodes[] order, ${JSON.stringify(o.region)} (got ${JSON.stringify(m?.['rearmed'])})`),
47
+ ok(of(log, 'node.started', o.target).length === 2, `the target MUST start again as a new visit (node.started ×${of(log, 'node.started', o.target).length}, want 2)`),
48
+ ok(reqs.length === 2 && typeof keys[0] === 'string' && typeof keys[1] === 'string' && keys[0] !== keys[1], `the gate MUST be asked again under a different key (interrupt.requested ×${reqs.length}, keys ${JSON.stringify(keys)})`),
49
+ ok(of(log, 'node.started', o.forward).length === 0, `nothing leaves the region on a refine: ${o.forward} MUST NOT start before accept`),
50
+ ];
51
+ }
52
+
53
+ /**
54
+ * `0258.history-from-log` — the target (an input-echoing node) shows the round
55
+ * history it received: `[]` on its first visit, then one entry per round
56
+ * holding exactly `round`, the target's own outputs from that round, and the
57
+ * refine feedback. No other key, so no timestamp, principal, run id or key.
58
+ */
59
+ export function judgeHistory(log: readonly Ev[], o: { target: string; port: string; feedback: readonly unknown[] }): LoopFinding[] {
60
+ const done = of(log, 'node.completed', o.target).map((e) => (e.payload?.['outputs'] ?? {}) as Record<string, unknown>);
61
+ const out: LoopFinding[] = [ok(done.length === o.feedback.length + 1, `the target MUST complete once per visit (${done.length} completion(s), want ${o.feedback.length + 1})`)];
62
+ done.forEach((outputs, visit) => {
63
+ const h = outputs[o.port];
64
+ if (!Array.isArray(h)) { out.push(ok(false, `visit ${visit}: the ${o.port} input MUST be an array (got ${JSON.stringify(h)})`)); return; }
65
+ out.push(ok(h.length === visit, `visit ${visit}: history MUST hold ${visit} entr${visit === 1 ? 'y' : 'ies'} (got ${h.length})`));
66
+ h.forEach((entry: unknown, i: number) => {
67
+ const en = (entry ?? {}) as Record<string, unknown>;
68
+ const extra = Object.keys(en).filter((k) => !['round', 'outputs', 'refineFeedback'].includes(k));
69
+ out.push(ok(en['round'] === i + 1, `visit ${visit} entry ${i}: round MUST be ${i + 1}, oldest first (got ${JSON.stringify(en['round'])})`));
70
+ out.push(ok(JSON.stringify(en['outputs']) === JSON.stringify(done[i]), `visit ${visit} entry ${i}: outputs MUST be the target's own round-${i + 1} outputs`));
71
+ out.push(ok(JSON.stringify(en['refineFeedback']) === JSON.stringify(o.feedback[i]), `visit ${visit} entry ${i}: refineFeedback MUST be round ${i + 1}'s (got ${JSON.stringify(en['refineFeedback'])})`));
72
+ out.push(ok(extra.length === 0, `visit ${visit} entry ${i}: an entry MUST carry no other key (found ${JSON.stringify(extra)})`));
73
+ });
74
+ });
75
+ return out;
76
+ }
77
+
78
+ /** `0258.approval-data-loopback` — each visit's `ApprovalData.loopback` reads the expected triple. */
79
+ export function judgeApprovalData(log: readonly Ev[], gate: string, want: ReadonlyArray<{ round: number; maxIterations: number; exhausted: boolean }>): LoopFinding[] {
80
+ const got = requestsOf(log, gate).map(approvalLoopback);
81
+ const out: LoopFinding[] = [ok(got.length === want.length, `the gate MUST be asked ${want.length} time(s) (got ${got.length})`)];
82
+ want.forEach((w, i) => out.push(ok(JSON.stringify(got[i]) === JSON.stringify(w), `visit ${i}: ApprovalData.loopback MUST be ${JSON.stringify(w)} (got ${JSON.stringify(got[i])})`)));
83
+ return out;
84
+ }
85
+
86
+ /** `0258.withdraw-refine` — the exhausted visit omits `refine` from `actions`. */
87
+ export function judgeWithdrawn(log: readonly Ev[], gate: string): LoopFinding[] {
88
+ const last = requestsOf(log, gate).at(-1);
89
+ const actions = (last?.payload?.['data'] as Record<string, unknown> | undefined)?.['actions'];
90
+ return [
91
+ ok(approvalLoopback(last)?.['exhausted'] === true, 'the last visit MUST read exhausted: true'),
92
+ ok(Array.isArray(actions) && !actions.includes('refine') && actions.length > 0, `the exhausted visit MUST omit refine from actions and keep the others (got ${JSON.stringify(actions)})`),
93
+ ];
94
+ }
95
+
96
+ /** `0258.fail-on-exhausted` — a refine on the exhausted visit, under `onExhausted: fail`. */
97
+ export function judgeFailExhausted(log: readonly Ev[], gate: string, maxIterations: number): LoopFinding[] {
98
+ const limit = of(log, 'workflow.loopback-limit')[0];
99
+ const cap = of(log, 'cap.breached').find((e) => e.payload?.['kind'] === 'loopback-iterations');
100
+ const failed = of(log, 'run.failed')[0];
101
+ const err = (failed?.payload?.['error'] ?? {}) as Record<string, unknown>;
102
+ const seq = (e: Ev | undefined): number => (typeof e?.sequence === 'number' ? e.sequence : Number.NaN);
103
+ return [
104
+ ok(limit?.payload?.['nodeId'] === gate && limit?.payload?.['iterations'] === maxIterations && limit?.payload?.['limit'] === maxIterations, `workflow.loopback-limit MUST read {nodeId: ${gate}, iterations: ${maxIterations}, limit: ${maxIterations}} (got ${JSON.stringify(limit?.payload)})`),
105
+ ok(cap !== undefined && cap.payload?.['limit'] === maxIterations && cap.payload?.['observed'] === maxIterations + 1, `cap.breached {kind: loopback-iterations, limit: ${maxIterations}, observed: ${maxIterations + 1}} MUST be emitted (got ${JSON.stringify(cap?.payload)})`),
106
+ ok(seq(limit) < seq(cap) && seq(cap) < seq(failed), 'the order MUST be workflow.loopback-limit, then cap.breached, then run.failed'),
107
+ ok(err['code'] === 'loop_limit_exceeded', `run.failed MUST carry loop_limit_exceeded (got ${JSON.stringify(err['code'])})`),
108
+ ok(failed?.payload?.['failedNodeId'] === gate, `run.failed.failedNodeId MUST name the gate (got ${JSON.stringify(failed?.payload?.['failedNodeId'])})`),
109
+ ];
110
+ }
111
+
112
+ /**
113
+ * `0258.replay-rearms` — a replay fork whose prefix ends at the marked
114
+ * completion reaches the gate's second visit: a new request for the gate after
115
+ * the fork point, and no resolution of the gate after it (round 1 is not
116
+ * decided again).
117
+ */
118
+ export function judgeReplayRearm(fork: readonly Ev[], gate: string, fromSeq: number): LoopFinding[] {
119
+ const after = fork.filter((e) => typeof e.sequence === 'number' && e.sequence >= fromSeq);
120
+ return [
121
+ ok(of(after, 'interrupt.requested', gate).length === 1, `the fork MUST re-arm and ask the gate again after the marker (requests after fromSeq: ${of(after, 'interrupt.requested', gate).length})`),
122
+ ok(of(after, 'interrupt.resolved', gate).length === 0, 'the fork MUST NOT decide round 1 again (no interrupt.resolved for the gate after fromSeq)'),
123
+ ok(!after.some((e) => e.type === 'run.completed' || e.type === 'run.failed'), 'the fork MUST wait at the gate, not settle the run'),
124
+ ];
125
+ }
@@ -41,6 +41,13 @@ describe('fixtures: workflow-definition schema validity', () => {
41
41
  }
42
42
  // The root schema is already registered under its $id above; compile via getSchema.
43
43
  const validate = ajv.getSchema('workflow-definition.schema.json') ?? ajv.compile(JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')));
44
+ // The v2 tree, for fixtures tagged `v2-only` (below). Loaded here rather than
45
+ // through lib/v2.ts, which imports the driver and would make this file host-bound.
46
+ const ajv2 = new Ajv2020({ allErrors: true, strict: false });
47
+ addFormats(ajv2);
48
+ const V2_DIR = join(SCHEMAS_DIR, 'v2');
49
+ for (const file of readdirSync(V2_DIR).filter((f) => f.endsWith('.schema.json'))) ajv2.addSchema(JSON.parse(readFileSync(join(V2_DIR, file), 'utf8')) as Record<string, unknown>);
50
+ const validateV2 = ajv2.getSchema('https://openwop.dev/spec/v2/workflow-definition.schema.json');
44
51
 
45
52
  const files = readdirSync(FIXTURES_DIR)
46
53
  .filter((f) => f.endsWith('.json')) // top-level only — sub-dirs hold non-workflow fixtures
@@ -53,8 +60,13 @@ describe('fixtures: workflow-definition schema validity', () => {
53
60
  for (const file of files) {
54
61
  it(`${file} validates against workflow-definition.schema.json`, () => {
55
62
  const data = JSON.parse(readFileSync(join(FIXTURES_DIR, file), 'utf8'));
56
- const ok = validate(data);
57
- const errors = (validate.errors ?? [])
63
+ // A fixture tagged `v2-only` uses a construct the frozen v1 wire cannot
64
+ // carry (RFC 0258's `WorkflowEdge.loopback`), so it is judged by the v2
65
+ // schema instead. v1 is end-of-support (RFC 0234) and is never edited.
66
+ const v2Only = Array.isArray(data?.metadata?.tags) && data.metadata.tags.includes('v2-only');
67
+ const judge = v2Only && validateV2 ? validateV2 : validate;
68
+ const ok = v2Only && !validateV2 ? false : judge(data);
69
+ const errors = (judge.errors ?? [])
58
70
  .map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
59
71
  .join('\n');
60
72
  expect(ok, req('openwop.it.fixtures-valid.validates-against-workflow-definition-schema-json', 'fixtures-valid.test.ts (no spec citation in file)', `Fixture ${file} fails workflow-definition schema:\n${errors}`)).toBe(true);
@@ -0,0 +1,231 @@
1
+ /**
2
+ * v2 — a declared, bounded loopback from an approval gate back to the node it
3
+ * reviews (RFC 0258; the rules are `$defs/EdgeLoopback` in
4
+ * `schemas/v2/workflow-definition.schema.json`, `$defs/nodeCompleted.loopback`
5
+ * and `$defs/workflowLoopbackLimit` in `run-event-payloads.schema.json`,
6
+ * `$defs/ApprovalData.loopback` in `suspend-request.schema.json`, and the
7
+ * `loopback` family in `spec/v2/facets/loopback.schema.json`).
8
+ *
9
+ * Server-free (always on):
10
+ * 1. `0258.shape` — `EdgeLoopback` requires `on` and `maxIterations >= 1`,
11
+ * admits only `on: "refine"`, and a loopback edge carries no condition,
12
+ * port or trigger rule; the marker, `ApprovalData.loopback`, the new
13
+ * `cap.breached` kind and the facet floor (`maxHistoryEntryBytes >= 4096`)
14
+ * validate as declared.
15
+ *
16
+ * Host legs — gated on the `loopback` family and on each fixture in discovery
17
+ * `fixtures[]` (unadvertised → `inapplicable`); judges in
18
+ * `lib/bounded-loopback-witness.ts`. One `conformance-loopback-refine` run is
19
+ * driven through two refines, a refused third refine and an accept, and legs
20
+ * 2–5 read it at the points they name.
21
+ * 2. `0258.refine-rearms` — after one refine: the marker, a second visit of
22
+ * `draft`, a second request under a new key, `apply` not started.
23
+ * 3. `0258.history-from-log` — `draft` (core.identity, echoes its inputs)
24
+ * shows `rounds`: `[]`, then one exact entry per round, oldest first.
25
+ * 4. `0258.approval-data-loopback` — the three requests read `{0,2,false}`,
26
+ * `{1,2,false}`, `{2,2,true}`.
27
+ * 5. `0258.withdraw-refine` — the exhausted visit omits `refine`, a refine
28
+ * resume answers `400 validation_error` with the run still waiting, and
29
+ * accept completes the run with `apply` run once.
30
+ * 6. `0258.fail-on-exhausted` — `conformance-loopback-refine-fail`: a refine
31
+ * on the exhausted visit emits `workflow.loopback-limit`, `cap.breached`
32
+ * and `run.failed` (`loop_limit_exceeded`, `failedNodeId: gate`).
33
+ * 7. `0258.replay-rearms` — gated also on `replay.modes ∋ replay`: a replay
34
+ * fork whose prefix ends at round 1's marked completion asks the gate
35
+ * again without deciding round 1 again.
36
+ *
37
+ * §B's admission refusals are not legs: v2 has no operation through which a
38
+ * client submits a definition (no workflow registration, no inline definition
39
+ * on createRun), so a host's refusal is not observable on the wire (RFC 0258
40
+ * §Falsifiability).
41
+ */
42
+
43
+ import { describe, it, expect } from 'vitest';
44
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
45
+ import { gateFamily, v2RefValidator } from '../lib/v2.js';
46
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
47
+ import { readErrorCode } from '../lib/error-envelope.js';
48
+ import { softSkip } from '../lib/soft-skip.js';
49
+ import { req } from '../lib/requirement-ids.js';
50
+ import { scaledTimeoutMs } from '../lib/polling.js';
51
+ import { judgeApprovalData, judgeFailExhausted, judgeHistory, judgeRearm, judgeReplayRearm, judgeWithdrawn, markersOf, requestsOf, type Ev, type LoopFinding } from '../lib/bounded-loopback-witness.js';
52
+
53
+ const DOC = 'workflow-definition.schema.json $defs/EdgeLoopback; run-event-payloads.schema.json $defs/nodeCompleted.loopback (RFC 0258)';
54
+ const R = (slug: string): string => `openwop.requirement.0258.${slug}`;
55
+ const REFINE = 'conformance-loopback-refine';
56
+ const FAIL = 'conformance-loopback-refine-fail';
57
+ const GATE = 'gate';
58
+ const REARM = { gate: GATE, target: 'draft', edgeId: 'again', region: ['draft', 'gate'], forward: 'apply' };
59
+ const FB = [{ scope: 'whole', text: 'conformance round one: shorter' }, { scope: 'whole', text: 'conformance round two: darker' }];
60
+
61
+ const enc = (id: string): string => encodeURIComponent(id);
62
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
63
+
64
+ async function logOf(runId: string): Promise<Ev[]> {
65
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
66
+ const events = (res?.json as { events?: unknown } | null)?.events;
67
+ return Array.isArray(events) ? (events as Ev[]).filter((e) => typeof e.sequence === 'number').sort((a, b) => (a.sequence as number) - (b.sequence as number)) : [];
68
+ }
69
+ const TERMINAL = /^run\.(completed|failed|cancelled)$/;
70
+
71
+ /** Poll until `done(log)` holds, or the bound passes; the log either way. */
72
+ async function until(runId: string, done: (log: Ev[]) => boolean): Promise<{ log: Ev[]; ok: boolean }> {
73
+ const deadline = Date.now() + scaledTimeoutMs(20_000);
74
+ for (;;) {
75
+ const log = await logOf(runId);
76
+ if (done(log)) return { log, ok: true };
77
+ if (Date.now() > deadline || log.some((e) => typeof e.type === 'string' && TERMINAL.test(e.type))) return { log, ok: done(log) };
78
+ await new Promise((r) => setTimeout(r, 250));
79
+ }
80
+ }
81
+ const asked = (n: number) => (log: Ev[]): boolean => requestsOf(log, GATE).length >= n;
82
+ const resolve = (runId: string, resumeValue: unknown): Promise<OpenWOPResponse | null> => http(() => driver.post(`/runs/${enc(runId)}/interrupts/${enc(GATE)}`, { resumeValue }));
83
+ const refine = (runId: string, i: number): Promise<OpenWOPResponse | null> => resolve(runId, { action: 'refine', refineFeedback: FB[i] });
84
+
85
+ type Gate = { skip: ['inapplicable', string] } | { maxIterations: number };
86
+ async function gateLoopback(fixture: string, needIterations: number): Promise<Gate> {
87
+ if (!isFixtureAdvertised(fixture)) return { skip: ['inapplicable', `fixture ${fixture} is not advertised`] };
88
+ const fam = await gateFamily('loopback');
89
+ if (!fam) return { skip: ['inapplicable', 'loopback family not advertised (gate recorded under openwop.family.loopback)'] };
90
+ const max = fam['maxIterations'];
91
+ if (typeof max !== 'number' || max < needIterations) return { skip: ['inapplicable', `loopback.maxIterations ${String(max)} is below the fixture's ${needIterations}`] };
92
+ return { maxIterations: max };
93
+ }
94
+
95
+ interface Drive { runId: string; afterOne: Ev[]; atExhausted: Ev[]; refusedStatus: number | undefined; refusedCode: string | undefined; statusAfterRefusal: unknown; final: Ev[] }
96
+ let shared: Promise<Drive | { reason: string }> | undefined;
97
+ /** The one `conformance-loopback-refine` run legs 2–5 read. */
98
+ function drive(): Promise<Drive | { reason: string }> {
99
+ shared ??= (async () => {
100
+ const created = await http(() => driver.post('/runs', { workflowId: REFINE }));
101
+ const runId = (created?.json as { runId?: unknown } | null)?.runId;
102
+ if (created === null || created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${REFINE}) answered ${created?.status ?? 'nothing'} ${readErrorCode(created?.json) ?? ''}`.trim() };
103
+ if (!(await until(runId, asked(1))).ok) return { reason: `the ${REFINE} run never asked its gate` };
104
+ const r1 = await refine(runId, 0);
105
+ if (r1?.status !== 200) return { reason: `the first refine answered ${r1?.status ?? 'nothing'} ${readErrorCode(r1?.json) ?? ''}`.trim() };
106
+ const one = await until(runId, asked(2));
107
+ if (!one.ok) return { reason: 'the gate was not asked a second time after one refine (no re-arm)' };
108
+ const r2 = await refine(runId, 1);
109
+ if (r2?.status !== 200) return { reason: `the second refine answered ${r2?.status ?? 'nothing'} ${readErrorCode(r2?.json) ?? ''}`.trim() };
110
+ const two = await until(runId, asked(3));
111
+ if (!two.ok) return { reason: 'the gate was not asked a third time after two refines' };
112
+ const r3 = await resolve(runId, { action: 'refine', refineFeedback: { scope: 'whole', text: 'conformance: past the cap' } });
113
+ const snap = await http(() => driver.get(`/runs/${enc(runId)}`));
114
+ const status = (snap?.json as { status?: unknown } | null)?.status;
115
+ const acc = await resolve(runId, { action: 'accept' });
116
+ if (acc?.status !== 200) return { reason: `accept answered ${acc?.status ?? 'nothing'} ${readErrorCode(acc?.json) ?? ''}`.trim() };
117
+ const end = await until(runId, (log) => log.some((e) => typeof e.type === 'string' && TERMINAL.test(e.type)));
118
+ return { runId, afterOne: one.log, atExhausted: two.log, refusedStatus: r3?.status, refusedCode: readErrorCode(r3?.json), statusAfterRefusal: status, final: end.log };
119
+ })();
120
+ return shared;
121
+ }
122
+
123
+ function expectAll(id: string, findings: readonly LoopFinding[]): void {
124
+ for (const x of findings) expect(x.ok, req(id, DOC, x.message)).toBe(true);
125
+ }
126
+
127
+ describe('v2 RFC 0258 — bounded loopback wire shape (server-free)', () => {
128
+ it('EdgeLoopback, the marker, ApprovalData.loopback, the cap kind and the facet validate as declared', () => {
129
+ const edge = v2RefValidator('workflow-definition.schema.json#/$defs/WorkflowEdge');
130
+ const base = { id: 'again', sourceNodeId: 'gate', targetNodeId: 'draft' };
131
+ expect(edge({ ...base, loopback: { on: 'refine', maxIterations: 3, historyInput: 'rounds' } }).ok, req(R('shape'), DOC, 'a loopback edge MUST validate')).toBe(true);
132
+ expect(edge({ ...base, loopback: { on: 'refine' } }).ok, req(R('shape'), DOC, 'maxIterations is required')).toBe(false);
133
+ expect(edge({ ...base, loopback: { on: 'refine', maxIterations: 0 } }).ok, req(R('shape'), DOC, 'maxIterations has minimum 1')).toBe(false);
134
+ expect(edge({ ...base, loopback: { on: 'reject', maxIterations: 2 } }).ok, req(R('shape'), DOC, 'on admits only refine')).toBe(false);
135
+ expect(edge({ ...base, loopback: { on: 'refine', maxIterations: 2, onExhausted: 'retry' } }).ok, req(R('shape'), DOC, 'onExhausted is withdraw or fail')).toBe(false);
136
+ for (const extra of [{ condition: { type: 'truthy', left: 'approved' } }, { sourceOutput: 'output' }, { targetInput: 'input' }, { triggerRule: 'all_success' }]) {
137
+ expect(edge({ ...base, ...extra, loopback: { on: 'refine', maxIterations: 2 } }).ok, req(R('shape'), DOC, `a loopback edge MUST NOT carry ${Object.keys(extra)[0]}`)).toBe(false);
138
+ }
139
+ expect(edge({ ...base, condition: { type: 'truthy', left: 'approved' } }).ok, req(R('shape'), DOC, 'a plain edge with a condition still validates')).toBe(true);
140
+ const done = v2RefValidator('run-event-payloads.schema.json#/$defs/nodeCompleted');
141
+ expect(done({ nodeId: 'gate', loopback: { edgeId: 'again', targetNodeId: 'draft', round: 1, rearmed: ['draft', 'gate'] } }).ok, req(R('shape'), DOC, 'the marker MUST validate')).toBe(true);
142
+ expect(done({ nodeId: 'gate', loopback: { edgeId: 'again', targetNodeId: 'draft', round: 0, rearmed: ['draft', 'gate'] } }).ok, req(R('shape'), DOC, 'round has minimum 1')).toBe(false);
143
+ expect(done({ nodeId: 'gate', loopback: { edgeId: 'again', targetNodeId: 'draft', round: 1, rearmed: ['gate'] } }).ok, req(R('shape'), DOC, 'rearmed holds at least the target and the gate')).toBe(false);
144
+ const approval = v2RefValidator('suspend-request.schema.json#/$defs/ApprovalData');
145
+ const data = { artifactId: 'a', artifactType: 'text', title: 't', actions: ['accept', 'refine'] };
146
+ expect(approval({ ...data, loopback: { round: 0, maxIterations: 2, exhausted: false } }).ok, req(R('shape'), DOC, 'ApprovalData.loopback MUST validate')).toBe(true);
147
+ expect(approval({ ...data, loopback: { round: 0, maxIterations: 2 } }).ok, req(R('shape'), DOC, 'exhausted is required')).toBe(false);
148
+ const cap = v2RefValidator('run-event-payloads.schema.json#/$defs/capBreached');
149
+ expect(cap({ kind: 'loopback-iterations', limit: 2, observed: 3 }).ok, req(R('shape'), DOC, 'cap.breached admits kind loopback-iterations')).toBe(true);
150
+ const facet = v2RefValidator('capabilities.schema.json#/properties/loopback');
151
+ const rec = { status: 'stable', since: '2.46', witness: 'witnessable-gated', maxIterations: 3 };
152
+ expect(facet({ ...rec, maxHistoryEntryBytes: 65536 }).ok, req(R('shape'), DOC, 'the loopback record MUST validate')).toBe(true);
153
+ expect(facet({ ...rec, maxHistoryEntryBytes: 1 }).ok, req(R('shape'), DOC, 'maxHistoryEntryBytes has minimum 4096')).toBe(false);
154
+ expect(facet(rec).ok, req(R('shape'), DOC, 'maxHistoryEntryBytes is required')).toBe(false);
155
+ });
156
+ });
157
+
158
+ describe('v2 RFC 0258 — bounded loopback (host)', () => {
159
+ it('a refine re-arms the region: the marker, a new visit of the target, a new key, nothing released forward', async () => {
160
+ const g = await gateLoopback(REFINE, 2);
161
+ if ('skip' in g) return softSkip(...g.skip);
162
+ const d = await drive();
163
+ if ('reason' in d) return softSkip('blocked', d.reason);
164
+ expectAll(R('refine-rearms'), judgeRearm(d.afterOne, REARM));
165
+ }, 120_000);
166
+
167
+ it('the target receives the round history rebuilt from the log, with no run-scoped bytes', async () => {
168
+ const g = await gateLoopback(REFINE, 2);
169
+ if ('skip' in g) return softSkip(...g.skip);
170
+ const d = await drive();
171
+ if ('reason' in d) return softSkip('blocked', d.reason);
172
+ expectAll(R('history-from-log'), judgeHistory(d.atExhausted, { target: 'draft', port: 'rounds', feedback: FB }));
173
+ }, 120_000);
174
+
175
+ it('each visit of the gate carries ApprovalData.loopback {round, maxIterations, exhausted}', async () => {
176
+ const g = await gateLoopback(REFINE, 2);
177
+ if ('skip' in g) return softSkip(...g.skip);
178
+ const d = await drive();
179
+ if ('reason' in d) return softSkip('blocked', d.reason);
180
+ expectAll(R('approval-data-loopback'), judgeApprovalData(d.atExhausted, GATE, [
181
+ { round: 0, maxIterations: 2, exhausted: false }, { round: 1, maxIterations: 2, exhausted: false }, { round: 2, maxIterations: 2, exhausted: true },
182
+ ]));
183
+ }, 120_000);
184
+
185
+ it('the exhausted visit withdraws refine, refuses a refine resume, and accept completes the run', async () => {
186
+ const g = await gateLoopback(REFINE, 2);
187
+ if ('skip' in g) return softSkip(...g.skip);
188
+ const d = await drive();
189
+ if ('reason' in d) return softSkip('blocked', d.reason);
190
+ expectAll(R('withdraw-refine'), judgeWithdrawn(d.atExhausted, GATE));
191
+ expect(d.refusedStatus, req(R('withdraw-refine'), DOC, 'a refine resume on the exhausted visit MUST answer 400')).toBe(400);
192
+ expect(d.refusedCode, req(R('withdraw-refine'), DOC, 'the refusal MUST carry validation_error')).toBe('validation_error');
193
+ expect(d.statusAfterRefusal, req(R('withdraw-refine'), DOC, 'the refused refine MUST leave the run waiting-approval')).toBe('waiting-approval');
194
+ expect(d.final.some((e) => e.type === 'run.completed'), req(R('withdraw-refine'), DOC, 'accept MUST complete the run')).toBe(true);
195
+ expect(d.final.filter((e) => e.type === 'node.started' && (e.nodeId ?? e.payload?.['nodeId']) === 'apply').length, req(R('withdraw-refine'), DOC, 'apply MUST run exactly once, after accept')).toBe(1);
196
+ }, 120_000);
197
+
198
+ it('under onExhausted: fail, a refine on the exhausted visit fails the run with loop_limit_exceeded', async () => {
199
+ const g = await gateLoopback(FAIL, 1);
200
+ if ('skip' in g) return softSkip(...g.skip);
201
+ const created = await http(() => driver.post('/runs', { workflowId: FAIL }));
202
+ const runId = (created?.json as { runId?: unknown } | null)?.runId;
203
+ if (created === null || created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs (${FAIL}) answered ${created?.status ?? 'nothing'} ${readErrorCode(created?.json) ?? ''}`.trim());
204
+ if (!(await until(runId, asked(1))).ok) return softSkip('blocked', `the ${FAIL} run never asked its gate`);
205
+ const r1 = await refine(runId, 0);
206
+ if (r1?.status !== 200) return softSkip('blocked', `the first refine answered ${r1?.status ?? 'nothing'}`);
207
+ if (!(await until(runId, asked(2))).ok) return softSkip('blocked', 'the gate was not asked again after one refine (no re-arm)');
208
+ await refine(runId, 1);
209
+ const end = await until(runId, (log) => log.some((e) => typeof e.type === 'string' && TERMINAL.test(e.type)));
210
+ if (!end.ok) return softSkip('blocked', `the ${FAIL} run logged no terminal event within the bound`);
211
+ expectAll(R('fail-on-exhausted'), judgeFailExhausted(end.log, GATE, 1));
212
+ }, 120_000);
213
+
214
+ it('a replay fork at round 1\'s marked completion re-arms and asks the gate again', async () => {
215
+ const g = await gateLoopback(REFINE, 2);
216
+ if ('skip' in g) return softSkip(...g.skip);
217
+ const replay = await gateFamily('replay');
218
+ if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
219
+ if (!(Array.isArray(replay['modes']) && replay['modes'].includes('replay'))) return softSkip('inapplicable', 'replay.modes does not list replay');
220
+ const d = await drive();
221
+ if ('reason' in d) return softSkip('blocked', d.reason);
222
+ const mark = markersOf(d.final, GATE)[0];
223
+ if (typeof mark?.sequence !== 'number') return softSkip('blocked', 'the source run logged no marked gate completion to fork from');
224
+ const fromSeq = mark.sequence + 1;
225
+ const fork = await http(() => driver.post(`/runs/${enc(d.runId)}:fork`, { mode: 'replay', fromSeq }));
226
+ const forkId = (fork?.json as { runId?: unknown } | null)?.runId;
227
+ 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());
228
+ const k = await until(forkId, (log) => requestsOf(log, GATE).some((e) => typeof e.sequence === 'number' && e.sequence >= fromSeq));
229
+ expectAll(R('replay-rearms'), judgeReplayRearm(k.log, GATE, fromSeq));
230
+ }, 120_000);
231
+ });