@openwop/openwop-conformance 2.46.43 → 2.47.0

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,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
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * v2 — `interrupt.resolved` records the instrument of a token resolution
3
+ * (RFC 0259; the rules are `$defs/interruptResolved.resolvedBy` and
4
+ * `.resolvedVia` in `schemas/v2/run-event-payloads.schema.json`, and the
5
+ * `interrupt.resolvedVia` facet in `spec/v2/facets/interrupt.schema.json`).
6
+ *
7
+ * Server-free (always on):
8
+ * 1. `0259.shape` — `resolvedVia` is a closed object with a REQUIRED
9
+ * `scheme` from a closed vocabulary; `tokenRef` is opaque; `boundTo` is a
10
+ * Subject of kind `user` or `agent` with a non-legacy issuer.
11
+ *
12
+ * Host legs — each needs the resume token, which v2 gives no unaided way to
13
+ * read (interrupt.md: the signed-token surface is a SHOULD and the suite cannot
14
+ * mint a token). They are gated on the seams profile and the
15
+ * `readInterruptToken` seam (RFC 0259 §C.1): unadvertised → `inapplicable`; an
16
+ * advertised profile with the seam unwired → `blocked`.
17
+ * 2. `0259.token-resolve-names-no-subject` — UNCONDITIONAL (§A.1): an
18
+ * unauthenticated `POST /interrupts/{token}` records no `resolvedBy`.
19
+ * 3. `0259.resolved-via-recorded` — gated on `interrupt.resolvedVia: true`:
20
+ * the same resolution records `resolvedVia.scheme: "interrupt-token"`
21
+ * and, with no binding asked for, no `boundTo`.
22
+ * 4. `0259.token-ref-opaque` — gated as 3, and on `tokenRef` being present:
23
+ * it is not the token, contains no segment of it, and is not a prefix of
24
+ * a digest of the token or a segment in hex, base64 or base64url.
25
+ * 5. `0259.run-resolve-no-via` — gated as 3: a run-scoped resolution
26
+ * records no `resolvedVia`.
27
+ * 6. `0259.bound-to-caller` — gated as 3, and on the seam returning
28
+ * `resumeValueMembers` for `bind: true`: `boundTo` deep-equals the
29
+ * `run.started.owner.subject` of the run the caller created.
30
+ * 7. `0259.forged-binding-binds-no-one` — gated as 6: the same binding with
31
+ * every returned member altered by one character (a token bound to the
32
+ * caller, presented as bound to someone else) records no `boundTo`; the
33
+ * host may refuse the resolution instead.
34
+ *
35
+ * Fork copying `resolvedVia` verbatim (§A.5) is not a leg here: it is the
36
+ * general rule that a fork copies the recorded resolution, witnessed for
37
+ * `decision` / `action` / `reason` by `v2-approval-reject-disposition` leg 9.
38
+ *
39
+ * @see RFCS/0259-interrupt-resolved-via.md
40
+ * @see spec/v2/core/interrupt.md §Resolve surfaces
41
+ */
42
+
43
+ import { createHash } from 'node:crypto';
44
+ import { describe, it, expect } from 'vitest';
45
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
46
+ import { v2Discovery, v2RefValidator } from '../lib/v2.js';
47
+ import { SEAMS_PREFIX, seamsProfileAdvertised } from '../lib/seams.js';
48
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
49
+ import { readErrorCode } from '../lib/error-envelope.js';
50
+ import { seamAbsent, softSkip } from '../lib/soft-skip.js';
51
+ import { req } from '../lib/requirement-ids.js';
52
+ import { scaledTimeoutMs } from '../lib/polling.js';
53
+
54
+ const DOC = 'run-event-payloads.schema.json $defs/interruptResolved.resolvedBy + .resolvedVia (RFC 0259)';
55
+ const R = (slug: string): string => `openwop.requirement.0259.${slug}`;
56
+ const FIXTURE = 'conformance-approval';
57
+ const NODE_ID = 'gate';
58
+ const SEAM = `${SEAMS_PREFIX}/sample/interrupts/token`;
59
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
60
+
61
+ interface Ev { type?: string; nodeId?: string; sequence?: number; payload?: Record<string, unknown> }
62
+
63
+ const enc = (id: string): string => encodeURIComponent(id);
64
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
65
+ const obj = (x: unknown): Record<string, unknown> | undefined => (x !== null && typeof x === 'object' && !Array.isArray(x) ? (x as Record<string, unknown>) : undefined);
66
+
67
+ async function snapshot(runId: string): Promise<Record<string, unknown> | null> {
68
+ const res = await http(() => driver.get(`/runs/${enc(runId)}`));
69
+ return res?.status === 200 ? (obj(res.json) ?? null) : null;
70
+ }
71
+ async function waitStatus(runId: string, wanted: ReadonlySet<string>): Promise<Record<string, unknown> | null> {
72
+ const deadline = Date.now() + scaledTimeoutMs(10_000);
73
+ for (;;) {
74
+ const s = await snapshot(runId);
75
+ if (s !== null && wanted.has(String(s['status']))) return s;
76
+ if (Date.now() > deadline) return s;
77
+ await new Promise((r) => setTimeout(r, 250));
78
+ }
79
+ }
80
+ async function logOf(runId: string): Promise<Ev[]> {
81
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
82
+ const events = obj(res?.json)?.['events'];
83
+ return Array.isArray(events) ? (events as Ev[]) : [];
84
+ }
85
+ async function resolvedPayload(runId: string): Promise<Record<string, unknown> | undefined> {
86
+ const deadline = Date.now() + scaledTimeoutMs(10_000);
87
+ for (;;) {
88
+ const e = (await logOf(runId)).find((x) => x.type === 'interrupt.resolved' && x.nodeId === NODE_ID);
89
+ if (e) return obj(e.payload);
90
+ if (Date.now() > deadline) return undefined;
91
+ await new Promise((r) => setTimeout(r, 250));
92
+ }
93
+ }
94
+
95
+ /** A suspended `conformance-approval` run, or why there is none. */
96
+ async function suspended(): Promise<{ runId: string } | { reason: string }> {
97
+ const res = await http(() => driver.post('/runs', { workflowId: FIXTURE }));
98
+ const runId = obj(res?.json)?.['runId'];
99
+ if (res === null || res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${FIXTURE}) answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''}`.trim() };
100
+ const s = await waitStatus(runId, new Set(['waiting-approval', ...TERMINAL]));
101
+ if (s?.['status'] !== 'waiting-approval') return { reason: `the ${FIXTURE} run did not suspend on its approval gate (status ${String(s?.['status'])})` };
102
+ return { runId };
103
+ }
104
+
105
+ type Gate = { inapplicable: string } | { blocked: string } | { ok: true; doc: Record<string, unknown> | null };
106
+ async function seamGate(): Promise<Gate> {
107
+ if (!isFixtureAdvertised(FIXTURE)) return { inapplicable: `fixture ${FIXTURE} is not advertised` };
108
+ const d = await v2Discovery().catch(() => null);
109
+ if (!seamsProfileAdvertised(d)) return { inapplicable: 'seams profile not advertised — v2 gives the suite no unaided way to read a resume token (interrupt.md §Resolve surfaces), so a token resolution is seam-gated' };
110
+ return { ok: true, doc: d };
111
+ }
112
+ const facetOn = (doc: Record<string, unknown> | null): boolean => obj(doc?.['interrupt'])?.['resolvedVia'] === true;
113
+
114
+ /** Suspend, read the token (and a binding when asked), resolve accept through the token surface unauthenticated. */
115
+ const alter = (v: unknown): unknown => (typeof v === 'string' && v.length > 0 ? `${v.slice(0, -1)}${v.endsWith('x') ? 'y' : 'x'}` : v);
116
+
117
+ async function tokenResolution(bind: boolean, forge = false): Promise<{ runId: string; token: string; bound: boolean; refused: boolean; payload: Record<string, unknown> | undefined } | { blocked: string } | { inapplicable: string }> {
118
+ const s = await suspended();
119
+ if ('reason' in s) return { blocked: s.reason };
120
+ const seam = await http(() => driver.post(SEAM, { runId: s.runId, nodeId: NODE_ID, ...(bind ? { bind: true } : {}) }));
121
+ const token = obj(seam?.json)?.['token'];
122
+ if (seam === null || seam.status !== 200 || typeof token !== 'string') return { blocked: `the readInterruptToken seam answered ${seam?.status ?? 'nothing'} (RFC 0259 §C.1) — the token resolution is unobserved` };
123
+ const members = obj(obj(seam.json)?.['resumeValueMembers']);
124
+ if (bind && members === undefined) return { inapplicable: 'the host returned no resumeValueMembers for bind: true — it binds no presentation to this caller' };
125
+ const presented = forge && members !== undefined ? Object.fromEntries(Object.entries(members).map(([k, v]) => [k, alter(v)])) : members;
126
+ const res = await http(() => driver.post(`/interrupts/${enc(token)}`, { resumeValue: { action: 'accept', ...(presented ?? {}) } }, { authenticated: false }));
127
+ if (forge && res !== null && (res.status === 400 || res.status === 403)) return { runId: s.runId, token, bound: false, refused: true, payload: undefined };
128
+ if (res === null || res.status < 200 || res.status >= 300) return { blocked: `POST /interrupts/{token} answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract` };
129
+ return { runId: s.runId, token, bound: members !== undefined, refused: false, payload: await resolvedPayload(s.runId) };
130
+ }
131
+
132
+ let unbound: ReturnType<typeof tokenResolution> | undefined;
133
+ const unboundResolution = (): ReturnType<typeof tokenResolution> => (unbound ??= tokenResolution(false));
134
+
135
+ /** sha256 of `s` in hex, base64 and base64url — the bare digests a token-derived tokenRef would carry. */
136
+ function digests(s: string): string[] {
137
+ const d = createHash('sha256').update(s).digest();
138
+ return [d.toString('hex'), d.toString('base64').replace(/=+$/, ''), d.toString('base64url')];
139
+ }
140
+ /**
141
+ * Why `tokenRef` is not opaque, if it is not. A tokenRef carrying a bare digest
142
+ * of the token, or any prefix of one, shares an 8-character run with that
143
+ * digest; for an independent value the chance of that is negligible (an
144
+ * 8-character run over a 64-symbol alphabet), so the test has no practical
145
+ * false positive and catches the truncated-hash convention.
146
+ */
147
+ function tokenRefFindings(tokenRef: string, token: string): string[] {
148
+ const out: string[] = [];
149
+ const segments = [token, ...token.split('.').filter((x) => x.length >= 8)];
150
+ const windows = Array.from({ length: Math.max(0, tokenRef.length - 7) }, (_, i) => tokenRef.slice(i, i + 8));
151
+ for (const seg of segments) {
152
+ if (tokenRef.includes(seg)) out.push('tokenRef MUST NOT equal or contain the token or any of its segments');
153
+ if (digests(seg).some((d) => windows.some((w) => d.includes(w)))) out.push('tokenRef MUST NOT be computable from the token without a host-held secret: it shares a run with a bare sha256 of the token or a segment');
154
+ }
155
+ return out;
156
+ }
157
+
158
+ describe('v2 RFC 0259 — resolvedVia wire shape (server-free)', () => {
159
+ it('resolvedVia is a closed object with a registered scheme; boundTo is a non-legacy user or agent Subject', () => {
160
+ const v = v2RefValidator('run-event-payloads.schema.json#/$defs/interruptResolved');
161
+ const base = { nodeId: 'gate', interruptId: 'acme/0123456789abcdef', kind: 'approval', decision: 'granted', action: 'accept' };
162
+ const person = { issuer: 'urn:example:session', subjectId: 'u_7f3a9c1e', tenant: 'acme', lane: 'session', kind: 'user' };
163
+ expect(v(base).ok, req(R('shape'), DOC, 'interrupt.resolved without resolvedVia MUST validate')).toBe(true);
164
+ expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token' } }).ok, req(R('shape'), DOC, 'a bare token resolution (no resolvedBy) with resolvedVia MUST validate')).toBe(true);
165
+ expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', tokenRef: 'tr_0000synthetic0000', boundTo: person } }).ok, req(R('shape'), DOC, 'tokenRef and a user boundTo MUST validate')).toBe(true);
166
+ expect(v({ ...base, resolvedVia: {} }).ok, req(R('shape'), DOC, 'scheme is REQUIRED')).toBe(false);
167
+ expect(v({ ...base, resolvedVia: { scheme: 'share-link' } }).ok, req(R('shape'), DOC, 'an unregistered scheme MUST fail')).toBe(false);
168
+ expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', token: 'ow2.hs256.k.p.m' } }).ok, req(R('shape'), DOC, 'resolvedVia is closed: a token member MUST fail')).toBe(false);
169
+ expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', tokenRef: 'ow2.hs256.k.p.m' } }).ok, req(R('shape'), DOC, 'tokenRef is opaque: a dotted token-shaped value MUST fail the grammar')).toBe(false);
170
+ expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', boundTo: { ...person, kind: 'anonymous', lane: 'anonymous' } } }).ok, req(R('shape'), DOC, 'boundTo of kind anonymous MUST fail')).toBe(false);
171
+ expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', boundTo: { ...person, kind: 'workload', lane: 'workload' } } }).ok, req(R('shape'), DOC, 'boundTo of kind workload MUST fail')).toBe(false);
172
+ expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', boundTo: { ...person, issuer: 'urn:openwop:legacy' } } }).ok, req(R('shape'), DOC, 'boundTo with the legacy issuer MUST fail')).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe('v2 RFC 0259 — token resolutions (host, seam-gated)', () => {
177
+ it('an unauthenticated token resolution records no resolvedBy', async () => {
178
+ const g = await seamGate();
179
+ if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
180
+ if ('blocked' in g) return seamAbsent(g.blocked);
181
+ const r = await unboundResolution();
182
+ if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
183
+ if ('blocked' in r) return seamAbsent(r.blocked);
184
+ expect(r.payload, req(R('token-resolve-names-no-subject'), DOC, 'the token resolution logged no interrupt.resolved for the gate')).toBeDefined();
185
+ expect(r.payload?.['resolvedBy'], req(R('token-resolve-names-no-subject'), DOC, 'a resolution whose request authenticated nothing MUST NOT record resolvedBy (§A.1)')).toBeUndefined();
186
+ });
187
+
188
+ it('a host advertising interrupt.resolvedVia records scheme interrupt-token and no boundTo on an unbound token resolution', async () => {
189
+ const g = await seamGate();
190
+ if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
191
+ if ('blocked' in g) return seamAbsent(g.blocked);
192
+ if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
193
+ const r = await unboundResolution();
194
+ if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
195
+ if ('blocked' in r) return seamAbsent(r.blocked);
196
+ const via = obj(r.payload?.['resolvedVia']);
197
+ expect(via?.['scheme'], req(R('resolved-via-recorded'), DOC, 'a host advertising interrupt.resolvedVia MUST record resolvedVia.scheme "interrupt-token" on a token resolution')).toBe('interrupt-token');
198
+ expect(via?.['boundTo'], req(R('resolved-via-recorded'), DOC, 'boundTo MUST be absent when the host bound the presentation to no one')).toBeUndefined();
199
+ });
200
+
201
+ it('tokenRef, when present, is not the token and not computable from it', async () => {
202
+ const g = await seamGate();
203
+ if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
204
+ if ('blocked' in g) return seamAbsent(g.blocked);
205
+ if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
206
+ const r = await unboundResolution();
207
+ if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
208
+ if ('blocked' in r) return seamAbsent(r.blocked);
209
+ const tokenRef = obj(r.payload?.['resolvedVia'])?.['tokenRef'];
210
+ if (typeof tokenRef !== 'string') return softSkip('inapplicable', 'the host records no tokenRef (OPTIONAL)');
211
+ expect(tokenRefFindings(tokenRef, r.token), req(R('token-ref-opaque'), DOC, 'tokenRef MUST NOT equal, contain or be a bare digest of the token or its segments (§A.3)')).toEqual([]);
212
+ });
213
+
214
+ it('a run-scoped resolution records no resolvedVia', async () => {
215
+ const g = await seamGate();
216
+ if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
217
+ if ('blocked' in g) return seamAbsent(g.blocked);
218
+ if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
219
+ const s = await suspended();
220
+ if ('reason' in s) return seamAbsent(s.reason);
221
+ const res = await http(() => driver.post(`/runs/${enc(s.runId)}/interrupts/${enc(NODE_ID)}`, { resumeValue: { action: 'accept' } }));
222
+ if (res === null || res.status < 200 || res.status >= 300) return seamAbsent(`the run-scoped resolve answered ${res?.status ?? 'nothing'} — interrupt resolution owns that contract`);
223
+ const payload = await resolvedPayload(s.runId);
224
+ expect(payload, req(R('run-resolve-no-via'), DOC, 'the run-scoped resolution logged no interrupt.resolved for the gate')).toBeDefined();
225
+ expect(payload?.['resolvedVia'], req(R('run-resolve-no-via'), DOC, 'resolvedVia MUST NOT be recorded on a resolution that presented no token')).toBeUndefined();
226
+ });
227
+
228
+ it('a binding the host made to the caller is recorded as boundTo, equal to the caller\'s owner subject', async () => {
229
+ const g = await seamGate();
230
+ if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
231
+ if ('blocked' in g) return seamAbsent(g.blocked);
232
+ if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
233
+ const r = await tokenResolution(true);
234
+ if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
235
+ if ('blocked' in r) return seamAbsent(r.blocked);
236
+ const owner = obj(obj((await logOf(r.runId)).find((e) => e.type === 'run.started')?.payload?.['owner'])?.['subject']);
237
+ if (owner === undefined) return seamAbsent('run.started carried no owner.subject to compare boundTo against');
238
+ expect(obj(r.payload?.['resolvedVia'])?.['boundTo'], req(R('bound-to-caller'), DOC, 'boundTo MUST be the Subject the host bound the presentation to, built with the owner.subject mapping (§A.4)')).toEqual(owner);
239
+ expect(r.payload?.['resolvedBy'], req(R('bound-to-caller'), DOC, 'a bound but unauthenticated presentation still MUST NOT record resolvedBy (§A.1)')).toBeUndefined();
240
+ });
241
+
242
+ it('a binding altered to claim someone else binds no one', async () => {
243
+ const g = await seamGate();
244
+ if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
245
+ if ('blocked' in g) return seamAbsent(g.blocked);
246
+ if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
247
+ const r = await tokenResolution(true, true);
248
+ if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
249
+ if ('blocked' in r) return seamAbsent(r.blocked);
250
+ if (r.refused) {
251
+ expect(r.refused, req(R('forged-binding-binds-no-one'), DOC, 'refusing a presentation whose binding does not verify satisfies §A.4')).toBe(true);
252
+ } else {
253
+ expect(obj(r.payload?.['resolvedVia'])?.['boundTo'], req(R('forged-binding-binds-no-one'), DOC, 'a host MUST NOT set boundTo from a binding it did not make (§A.4)')).toBeUndefined();
254
+ expect(r.payload?.['resolvedBy'], req(R('forged-binding-binds-no-one'), DOC, 'and MUST NOT record the claimed name in resolvedBy (§A.1)')).toBeUndefined();
255
+ }
256
+ });
257
+ });