@openwop/openwop-conformance 2.46.35 → 2.46.37

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 (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -3
  3. package/coverage.md +5 -6
  4. package/dist/spec-artifacts.lock.json +2 -2
  5. package/fixtures/conformance-clarification.json +1 -1
  6. package/fixtures/conformance-interrupt-form-refused.json +108 -0
  7. package/fixtures/conformance-interrupt-form-unbound.json +108 -0
  8. package/fixtures/conformance-interrupt-form.json +107 -0
  9. package/fixtures/conformance-stream-text.json +1 -2
  10. package/fixtures.md +20 -5
  11. package/package.json +2 -2
  12. package/requirement-aliases.json +98 -1
  13. package/requirements.json +596 -804
  14. package/scenario-majors.json +16 -19
  15. package/schemas/CORPUS-STAMP.json +56 -69
  16. package/src/lib/callai-attempts-witness.ts +13 -1
  17. package/src/lib/family-advert-witness.ts +37 -50
  18. package/src/scenarios/stream-text-fixture.test.ts +13 -5
  19. package/src/scenarios/v2-clarification-resume-answers.test.ts +66 -0
  20. package/src/scenarios/v2-envelope-surfaces-absent.test.ts +55 -0
  21. package/src/scenarios/v2-interrupt-form-rules.test.ts +143 -0
  22. package/src/scenarios/v2-limits-clarification-rounds-advertisement.test.ts +42 -0
  23. package/src/scenarios/v2-peer-dependency-declared.test.ts +1 -1
  24. package/src/scenarios/v2-provider-output-reliability.test.ts +94 -0
  25. package/src/scenarios/v2-structured-output-advertisement.test.ts +82 -0
  26. package/src/lib/envelope-schema-static.ts +0 -167
  27. package/src/scenarios/v2-a2ui-v09-surface.test.ts +0 -323
  28. package/src/scenarios/v2-ai-envelope-advertisement.test.ts +0 -43
  29. package/src/scenarios/v2-envelope-contracts-advertisement.test.ts +0 -48
  30. package/src/scenarios/v2-envelope-reasoning-shape.test.ts +0 -81
  31. package/src/scenarios/v2-envelope-tier-one-subset-static.test.ts +0 -64
  32. package/src/scenarios/v2-envelopes-advertisement.test.ts +0 -85
  33. package/src/scenarios/v2-limits-envelope-caps-advertisement.test.ts +0 -61
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `spec/v2/core/interrupt.md` §"Re-entry and resume values" — a host MUST accept
3
+ * `{ answers: [{ id, answer }] }`, each `id` a `questions[].id`, as a
4
+ * `clarification` resume value, with or without a form (suite 2.46.37, target
5
+ * major 2; one run created).
6
+ *
7
+ * Until 2.46.37 `fixtures.md` taught `{answers: {q1: "blue"}}` for
8
+ * `conformance-clarification`, and only the form row (`v2-interrupt-form-rules`)
9
+ * resolved a clarification with the array at major 2. A host can take the keyed
10
+ * object from the fixture text and refuse the array, as the v2 reference host
11
+ * did until openwop-examples#179.
12
+ *
13
+ * Leg: `conformance-clarification` suspends `waiting-input` with question `q1`;
14
+ * `{ answers: [{ id: "q1", answer: "blue" }] }` resolves it with a 2xx; the run
15
+ * completes. A host not advertising the fixture records the leg `inapplicable`.
16
+ *
17
+ * @see spec/v2/core/interrupt.md §Re-entry and resume values
18
+ * @see conformance/fixtures.md §conformance-clarification
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
23
+ import { v2Discovery } from '../lib/v2.js';
24
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
25
+ import { readErrorCode } from '../lib/error-envelope.js';
26
+ import { softSkip } from '../lib/soft-skip.js';
27
+ import { req } from '../lib/requirement-ids.js';
28
+
29
+ const DOC = 'spec/v2/core/interrupt.md §Re-entry and resume values';
30
+ const ID = 'openwop.requirement.interrupt.clarification-resume-answers';
31
+ const FIXTURE = 'conformance-clarification';
32
+ const NODE = 'ask';
33
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
34
+
35
+ const enc = (id: string): string => encodeURIComponent(id);
36
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
37
+
38
+ /** Wait until the run is terminal or suspended (any `waiting-*`), or `ms` elapses. */
39
+ async function settle(runId: string, ms = 10_000): Promise<string | null> {
40
+ const end = Date.now() + ms;
41
+ for (;;) {
42
+ const r = await http(() => driver.get(`/runs/${enc(runId)}`));
43
+ const s = r?.status === 200 ? String((r.json as { status?: unknown } | null)?.status ?? '') : null;
44
+ if (s !== null && (TERMINAL.has(s) || s.startsWith('waiting-'))) return s;
45
+ if (Date.now() > end) return s;
46
+ await new Promise((ok) => setTimeout(ok, 200));
47
+ }
48
+ }
49
+
50
+ describe('v2 clarification resume value (interrupt.md §Re-entry and resume values)', () => {
51
+ it('a clarification resolves with { answers: [{ id, answer }] } and the run completes', async () => {
52
+ if (!isFixtureAdvertised(FIXTURE)) return softSkip('inapplicable', `${FIXTURE} is not advertised — the suite cannot raise a clarification`);
53
+ try { await v2Discovery(); } catch { return softSkip('blocked', 'v2 discovery unreachable'); }
54
+ const create = await http(() => driver.post('/runs', { workflowId: FIXTURE }));
55
+ if (create === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
56
+ const runId = (create.json as { runId?: unknown } | null)?.runId;
57
+ if (create.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs (${FIXTURE}) answered ${create.status} ${readErrorCode(create.json) ?? ''}`.trim());
58
+ const suspended = await settle(runId);
59
+ if (suspended !== 'waiting-input') return softSkip('blocked', `the ${FIXTURE} run did not suspend waiting-input (status ${String(suspended)})`);
60
+ const res = await http(() => driver.post(`/runs/${enc(runId)}/interrupts/${NODE}`, { resumeValue: { answers: [{ id: 'q1', answer: 'blue' }] } }));
61
+ if (res === null) return softSkip('blocked', 'the resolve was unreachable (fetch failed)');
62
+ expect(res.status, req(ID, DOC, `a clarification resolve carrying { answers: [{ id, answer }] } MUST be accepted (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBeGreaterThanOrEqual(200);
63
+ expect(res.status, req(ID, DOC, 'the resolve MUST be a 2xx')).toBeLessThan(300);
64
+ expect(await settle(runId), req(ID, DOC, 'the resolved run MUST complete')).toBe('completed');
65
+ });
66
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * v2 — the AI Envelope is absent (RFC 0255 §A). Unaided: it reads only the
3
+ * host's v2 discovery document.
4
+ *
5
+ * RFC 0255 deleted the families `aiEnvelope`, `supportedEnvelopes`,
6
+ * `schemaVersions`, `envelopeStrictness`, `envelopeContracts`, `envelopes` and
7
+ * the ext family `a2uiSurface`, and the `limits` facets `schemaRounds` and
8
+ * `envelopesPerTurn`. The discovery root is closed, so a host still publishing
9
+ * one of them publishes a document the v2 schema refuses; this leg names which
10
+ * key it is. `envelopes.reasoning`, `.tierOneSubsetCompliance` and
11
+ * `.reliability` now live on `aiProviders` (`host-services.md` §Structured
12
+ * output), so a host that moved them passes.
13
+ *
14
+ * Disposition: discovery unreadable ⇒ `blocked`. Every readable document is
15
+ * judged; there is no `inapplicable` state, because absence is the requirement.
16
+ *
17
+ * @see RFCS/0255-delete-the-ai-envelope.md §A
18
+ * @see spec/v2/corrections.json openwop.correction.v2.5
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { v2Discovery } from '../lib/v2.js';
23
+ import { softSkip } from '../lib/soft-skip.js';
24
+ import { req } from '../lib/requirement-ids.js';
25
+
26
+ const ID = 'openwop.requirement.0255.surfaces-absent';
27
+ const SECTION = 'RFC 0255 §A';
28
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
29
+ const DELETED_ROOT_KEYS = ['aiEnvelope', 'supportedEnvelopes', 'schemaVersions', 'envelopeStrictness', 'envelopeContracts', 'envelopes', 'a2uiSurface'] as const;
30
+ const DELETED_LIMITS = ['schemaRounds', 'envelopesPerTurn'] as const;
31
+
32
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
33
+
34
+ /** Every deleted surface the document still publishes, as a dotted path. */
35
+ function deletedSurfaces(doc: Record<string, unknown>): string[] {
36
+ const found: string[] = DELETED_ROOT_KEYS.filter((k) => k in doc).map((k) => k);
37
+ const limits = doc['limits'];
38
+ if (isRecord(limits)) for (const k of DELETED_LIMITS) if (k in limits) found.push(`limits.${k}`);
39
+ const extensions = doc['extensions'];
40
+ if (isRecord(extensions)) {
41
+ for (const [org, rec] of Object.entries(extensions)) {
42
+ if (isRecord(rec) && ('a2ui-surface' in rec || 'a2uiSurface' in rec)) found.push(`extensions.${org}.a2ui-surface`);
43
+ }
44
+ }
45
+ return found;
46
+ }
47
+
48
+ describe('v2-envelope-surfaces-absent (RFC 0255 §A)', () => {
49
+ it('the discovery document publishes no deleted envelope family or limit', async () => {
50
+ let doc: Record<string, unknown> | null = null;
51
+ try { doc = await v2Discovery(); } catch { doc = null; }
52
+ if (!doc) return softSkip('blocked', UNREADABLE);
53
+ expect(deletedSurfaces(doc), req(ID, SECTION, 'a v2 discovery document MUST NOT publish a family or limit RFC 0255 deleted')).toEqual([]);
54
+ });
55
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * v2-interrupt-form-rules — RFC 0255 §E, `spec/v2/core/interrupt.md` §Forms
3
+ * (target major 2; gated on fixtures, no capability or seam).
4
+ *
5
+ * A `clarification` MAY carry `data.form`, a closed declarative form
6
+ * (`schemas/v2/interrupt-form.schema.json`). The suite cannot make a node invent
7
+ * a form, so every leg runs a fixture whose `core.clarificationGate` node passes
8
+ * `config.form` verbatim as `data.form` (fixtures.md §conformance-interrupt-form).
9
+ *
10
+ * Legs:
11
+ * 1. the positive fixture suspends `waiting-input`, and its `interrupt.requested`
12
+ * validates against `InterruptPayload` and carries the form unchanged beside
13
+ * `questions`;
14
+ * 2. `{ answers: [{ id, answer }] }` resolves it and the run completes;
15
+ * 3. neither probe is raised with its form: `-refused` fails the schema (an
16
+ * obscured TextField solicits a secret), `-unbound` is schema-valid but its
17
+ * input names no `questions[].id`. The positive fixture is the control in the
18
+ * same leg, so a host that raises no form at all cannot pass.
19
+ *
20
+ * Forms are OPTIONAL: a host advertising none of the fixtures records every leg
21
+ * `inapplicable`. A host advertising the positive fixture but not both probes
22
+ * records leg 3 `blocked`, so it cannot claim forms without the refusal.
23
+ * Rendering (closed catalog, no code execution, no fetch) is render-side and is
24
+ * witnessed by the reference-impl invariants `interrupt-form-no-code-exec` and
25
+ * `interrupt-form-no-network-egress`, not here.
26
+ *
27
+ * @see spec/v2/core/interrupt.md §Forms
28
+ * @see RFCS/0255-delete-the-ai-envelope.md §E
29
+ * @see SECURITY/invariants.yaml id: interrupt-form-action-confinement
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
36
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
37
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
38
+ import { readErrorCode } from '../lib/error-envelope.js';
39
+ import { FIXTURES_DIR } from '../lib/paths.js';
40
+ import { softSkip } from '../lib/soft-skip.js';
41
+ import { req } from '../lib/requirement-ids.js';
42
+
43
+ type Json = Record<string, unknown>;
44
+ interface Ev { type?: string; payload?: Json }
45
+
46
+ const DOC = 'spec/v2/core/interrupt.md §Forms (RFC 0255 §E)';
47
+ const R = (slug: string): string => `openwop.requirement.0255.${slug}`;
48
+ const POSITIVE = 'conformance-interrupt-form';
49
+ const PROBES = ['conformance-interrupt-form-refused', 'conformance-interrupt-form-unbound'] as const;
50
+ const NODE = 'ask';
51
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
52
+ const validInterrupt = v2Validator('suspend-request');
53
+
54
+ const formOf = (fixture: string): Json => (JSON.parse(readFileSync(join(FIXTURES_DIR, `${fixture}.json`), 'utf8')) as { nodes: Array<{ config: { form: Json } }> }).nodes[0]!.config.form;
55
+ const enc = (id: string): string => encodeURIComponent(id);
56
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
57
+ async function discovery(): Promise<Json | null> { try { return await v2Discovery(); } catch { return null; } }
58
+
59
+ async function statusOf(runId: string): Promise<string | null> {
60
+ const r = await http(() => driver.get(`/runs/${enc(runId)}`));
61
+ return r?.status === 200 ? String((r.json as { status?: unknown } | null)?.status ?? '') : null;
62
+ }
63
+ /** Wait until the run is terminal or suspended (any `waiting-*`), or `ms` elapses. */
64
+ async function settle(runId: string, ms = 10_000): Promise<string | null> {
65
+ const end = Date.now() + ms;
66
+ for (;;) {
67
+ const s = await statusOf(runId);
68
+ if (s !== null && (TERMINAL.has(s) || s.startsWith('waiting-'))) return s;
69
+ if (Date.now() > end) return s;
70
+ await new Promise((ok) => setTimeout(ok, 200));
71
+ }
72
+ }
73
+ async function events(runId: string): Promise<Ev[]> {
74
+ const r = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
75
+ return (r?.json as { events?: Ev[] } | null)?.events ?? [];
76
+ }
77
+ const requested = (evs: Ev[]): Ev[] => evs.filter((e) => e.type === 'interrupt.requested');
78
+ const dataOf = (e: Ev | undefined): Json | undefined => (e?.payload?.['data'] as Json | undefined);
79
+
80
+ /** Start `fixture` and return its runId, or why it could not be started. */
81
+ async function start(fixture: string): Promise<{ runId: string } | { reason: string }> {
82
+ const r = await http(() => driver.post('/runs', { workflowId: fixture }));
83
+ if (r === null) return { reason: 'POST /runs unreachable (fetch failed)' };
84
+ const runId = (r.json as { runId?: unknown } | null)?.runId;
85
+ if (r.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${fixture}) answered ${r.status} ${readErrorCode(r.json) ?? ''}`.trim() };
86
+ return { runId };
87
+ }
88
+ /** The positive fixture, suspended, with its `interrupt.requested`. */
89
+ async function raised(): Promise<{ runId: string; ev: Ev | undefined } | { reason: string }> {
90
+ const s = await start(POSITIVE);
91
+ if ('reason' in s) return s;
92
+ const status = await settle(s.runId);
93
+ if (status !== 'waiting-input') return { reason: `the ${POSITIVE} run did not suspend waiting-input (status ${String(status)})` };
94
+ return { runId: s.runId, ev: requested(await events(s.runId)).find((e) => e.payload?.['kind'] === 'clarification') };
95
+ }
96
+
97
+ describe('v2 interrupt-form-rules (interrupt.md §Forms, RFC 0255 §E)', () => {
98
+ it('a clarification raised with a form carries it unchanged, beside questions, in a valid InterruptPayload', async () => {
99
+ if (!isFixtureAdvertised(POSITIVE)) return softSkip('inapplicable', `${POSITIVE} is not advertised — forms are OPTIONAL and this host claims none`);
100
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
101
+ const r = await raised();
102
+ if ('reason' in r) return softSkip('blocked', r.reason);
103
+ expect(r.ev, req(R('interrupt-form-rules'), DOC, 'the suspended run MUST record interrupt.requested { kind: "clarification" }')).toBeDefined();
104
+ const v = validInterrupt(r.ev?.payload);
105
+ expect(v.ok, req(R('interrupt-form-rules'), DOC, `interrupt.requested MUST validate against InterruptPayload, form included: ${v.errors}`)).toBe(true);
106
+ const data = dataOf(r.ev);
107
+ expect((data?.['questions'] as Array<{ id?: unknown }> | undefined)?.map((q) => q.id), req(R('interrupt-form-rules'), DOC, 'questions MUST stay beside the form as its fallback')).toEqual(['q1']);
108
+ expect(data?.['form'], req(R('interrupt-form-rules'), DOC, 'data.form MUST be the fixture\'s form, unchanged')).toEqual(formOf(POSITIVE));
109
+ }, 45_000);
110
+
111
+ it('a form resolves with { answers: [{ id, answer }] } and the run completes', async () => {
112
+ if (!isFixtureAdvertised(POSITIVE)) return softSkip('inapplicable', `${POSITIVE} is not advertised — forms are OPTIONAL and this host claims none`);
113
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
114
+ const r = await raised();
115
+ if ('reason' in r) return softSkip('blocked', r.reason);
116
+ const resumeValue = { answers: [{ id: 'q1', answer: 'blue' }] };
117
+ const res = await http(() => driver.post(`/runs/${enc(r.runId)}/interrupts/${NODE}`, { resumeValue }));
118
+ if (res === null) return softSkip('blocked', 'the resolve was unreachable (fetch failed)');
119
+ expect(res.status, req(R('interrupt-form-resume-answers'), DOC, `a resolve carrying { answers: [{ id, answer }] } MUST be accepted (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBeGreaterThanOrEqual(200);
120
+ expect(res.status, req(R('interrupt-form-resume-answers'), DOC, 'the resolve MUST be a 2xx')).toBeLessThan(300);
121
+ expect(await settle(r.runId), req(R('interrupt-form-resume-answers'), DOC, 'the resolved run MUST complete')).toBe('completed');
122
+ }, 45_000);
123
+
124
+ it('a form that fails the schema or binds an input to no question is never raised', async () => {
125
+ if (!isFixtureAdvertised(POSITIVE)) return softSkip('inapplicable', `${POSITIVE} is not advertised — forms are OPTIONAL and this host claims none`);
126
+ const missing = PROBES.filter((p) => !isFixtureAdvertised(p));
127
+ if (missing.length > 0) return softSkip('blocked', `${POSITIVE} is advertised but ${missing.join(', ')} is not — a host claiming forms MUST also advertise the refusal probes`);
128
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
129
+ const control = await raised();
130
+ if ('reason' in control) return softSkip('blocked', `control: ${control.reason}`);
131
+ expect(dataOf(control.ev)?.['form'], req(R('interrupt-form-refused'), DOC, 'control: the positive fixture MUST raise its form, or a host raising none would pass')).toEqual(formOf(POSITIVE));
132
+ for (const probe of PROBES) {
133
+ const s = await start(probe);
134
+ // Refusing the run at creation also never raises the form; only a non-answer is undecidable.
135
+ if ('reason' in s) { if (/answered 4\d\d/.test(s.reason)) continue; return softSkip('blocked', `${probe}: ${s.reason}`); }
136
+ const status = await settle(s.runId);
137
+ const carrying = requested(await events(s.runId)).filter((e) => dataOf(e)?.['form'] !== undefined);
138
+ expect(carrying.map((e) => dataOf(e)?.['form']), req(R('interrupt-form-refused'), DOC, `${probe}: no interrupt.requested may carry the form (run status ${String(status)})`)).toEqual([]);
139
+ await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
140
+ }
141
+ await http(() => driver.post(`/runs/${enc(control.runId)}/cancel`, {}));
142
+ }, 60_000);
143
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * v2 — `limits.clarificationRounds` (`spec/v2/core/runs.md` §Limits: "`limits`
3
+ * always carries `clarificationRounds` (per task)"). The two envelope caps that
4
+ * sat beside it, `schemaRounds` and `envelopesPerTurn`, were deleted with the AI
5
+ * Envelope (RFC 0255 §C); `maxToolRounds` and `maxRepairRounds` replace them as
6
+ * OPTIONAL facets. The leg lives in `lib/family-advert-witness.ts`. The other
7
+ * `limits` facets (`maxRunDurationMs`, `maxLoopIterations`) are `v2-run-execution-bounds`.
8
+ *
9
+ * The cap MUST be present and validate against its subschema in
10
+ * `schemas/v2/capabilities.schema.json` (a non-negative integer).
11
+ *
12
+ * Dispositions: discovery unreadable ⇒ `blocked`; `limits` absent ⇒
13
+ * `inapplicable` (at v2 presence of the record is the claim; the root schema
14
+ * requires only `protocolVersions` and `preferredVersion`).
15
+ *
16
+ * @see spec/v2/core/runs.md §Limits
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { v2Discovery } from '../lib/v2.js';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { req } from '../lib/requirement-ids.js';
23
+ import { majorProfile } from '../lib/major-profile.js';
24
+ import { limitsCapLeg } from '../lib/family-advert-witness.js';
25
+
26
+ const PROFILE = majorProfile(2);
27
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
28
+ const ID_CLARIFICATION = 'openwop.requirement.limits.clarification-rounds-advertised';
29
+
30
+ async function discovery(): Promise<Record<string, unknown> | null> {
31
+ try { return await v2Discovery(); } catch { return null; }
32
+ }
33
+
34
+ describe('v2 limits.clarificationRounds (runs.md §Limits)', () => {
35
+ it('limits.clarificationRounds is present and a non-negative integer', async () => {
36
+ const doc = await discovery();
37
+ if (!doc) return softSkip('blocked', UNREADABLE);
38
+ const out = limitsCapLeg(PROFILE, doc, 'clarificationRounds');
39
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
40
+ for (const x of out.findings) expect(x.ok, req(ID_CLARIFICATION, x.doc, x.message)).toBe(true);
41
+ });
42
+ });
@@ -126,7 +126,7 @@ describe('v2-peer-dependency-declared (RFC 0177 §B.1/§B.2)', () => {
126
126
  const skip = await preflight();
127
127
  if (skip) return softSkip(skip.kind, skip.reason);
128
128
  if (!existsSync(ALIASES)) return softSkip('blocked', 'spec/v2/peer-dependency-aliases.json is not present in this layout — the alias row cannot be chosen');
129
- const rows = (JSON.parse(readFileSync(ALIASES, 'utf8')) as { rows: AliasRow[] }).rows.filter((r) => !r.facets || r.facets.length === 0);
129
+ const rows = (JSON.parse(readFileSync(ALIASES, 'utf8')) as { rows: AliasRow[] }).rows.filter((r) => Boolean(r.family) && (!r.facets || r.facets.length === 0));
130
130
  let chosen: AliasRow | null = null;
131
131
  const fs = rows.find((r) => r.alias === 'host.fs');
132
132
  if (fs && (await familyAdvertised(fs.family))) chosen = fs;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * v2 — structured-output reliability through the mock provider
3
+ * (`spec/v2/core/host-services.md` §Structured output; RFC 0255 §A, which moved
4
+ * these rules unchanged from the deleted `envelopes` family).
5
+ *
6
+ * Unaided, through the `conformance-callai-attempt-accounting` fixture: its node
7
+ * makes one `ctx.callAI` call with `responseSchema`, and the host's conformance
8
+ * mock serves the case's script (`lib/callai-attempts-witness.ts`). The suite
9
+ * wrote the script, so it knows what every provider request returned.
10
+ *
11
+ * truncation truncated, then valid → the host emits `envelope.truncated` and
12
+ * the call completes (gated on `reliability.completion.distinguishesTruncation`);
13
+ * exhaustion every reply violates the schema, `retry.maxAttempts: 2` → the
14
+ * node fails `envelope_invalid`, after `envelope.retry-exhausted`
15
+ * and `cap.breached` `kind: "schema"`;
16
+ * refusal the provider refuses → the node fails `envelope_refusal` after
17
+ * `envelope.refusal`, with no `envelope.retry-attempted`: a refusal
18
+ * is never retried.
19
+ *
20
+ * Dispositions: discovery unreadable ⇒ `blocked`; `aiProviders.reliability` not
21
+ * advertised, or the fixture not listed ⇒ `inapplicable`. A run that cannot be
22
+ * created or read ⇒ `blocked`. A refusal script the host's mock refuses as input
23
+ * (`validation_error`) ⇒ `blocked`.
24
+ *
25
+ * @see RFCS/0255-delete-the-ai-envelope.md §Conformance
26
+ * @see conformance/fixtures.md §The ctx.callAI attempt-accounting fixture
27
+ */
28
+
29
+ import { describe, it, expect } from 'vitest';
30
+ import { v2Discovery } from '../lib/v2.js';
31
+ import { softSkip } from '../lib/soft-skip.js';
32
+ import { req } from '../lib/requirement-ids.js';
33
+ import { runCase, type CaseRun } from '../lib/callai-attempts-witness.js';
34
+
35
+ const SECTION = 'host-services.md §Structured output';
36
+ const ID_TRUNCATION = 'openwop.requirement.0255.provider-output-truncation';
37
+ const ID_EXHAUSTION = 'openwop.requirement.0255.provider-output-exhaustion';
38
+ const ID_REFUSAL = 'openwop.requirement.0255.provider-output-refusal';
39
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
40
+
41
+ type Skip = readonly ['inapplicable' | 'blocked', string];
42
+ const isRec = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
43
+
44
+ /** The advertised `aiProviders.reliability`, or why the legs cannot run. */
45
+ async function reliability(): Promise<{ skip: Skip } | { rel: Record<string, unknown> }> {
46
+ const doc = await v2Discovery().catch(() => null);
47
+ if (!doc) return { skip: ['blocked', UNREADABLE] };
48
+ const ai = doc['aiProviders'];
49
+ const rel = isRec(ai) && isRec(ai['reliability']) ? ai['reliability'] : undefined;
50
+ if (!rel) return { skip: ['inapplicable', 'the host does not advertise aiProviders.reliability'] };
51
+ return { rel };
52
+ }
53
+
54
+ async function drive(name: 'truncation' | 'exhaustion' | 'refusal'): Promise<{ skip: Skip } | { run: CaseRun }> {
55
+ const d = await runCase(name);
56
+ if (d.kind === 'skip') return { skip: [d.disposition, d.reason] };
57
+ return { run: d.run };
58
+ }
59
+
60
+ describe('v2-provider-output-reliability (RFC 0255 §A)', () => {
61
+ it('a truncated reply emits envelope.truncated and the retried call completes', async () => {
62
+ const g = await reliability();
63
+ if ('skip' in g) return softSkip(...g.skip);
64
+ const completion = g.rel['completion'];
65
+ if (!isRec(completion) || completion['distinguishesTruncation'] !== true) return softSkip('inapplicable', 'the host does not advertise aiProviders.reliability.completion.distinguishesTruncation');
66
+ const o = await drive('truncation');
67
+ if ('skip' in o) return softSkip(...o.skip);
68
+ expect(o.run.types.filter((t) => t === 'envelope.truncated').length, req(ID_TRUNCATION, SECTION, 'a truncated reply MUST emit envelope.truncated')).toBeGreaterThanOrEqual(1);
69
+ expect(o.run.status, req(ID_TRUNCATION, SECTION, `the call MUST complete once the retry returns valid output (got ${o.run.status}, ${o.run.errorCode ?? 'no code'})`)).toBe('completed');
70
+ });
71
+
72
+ it('when the repair budget runs out the node fails envelope_invalid after retry-exhausted and cap.breached schema', async () => {
73
+ const g = await reliability();
74
+ if ('skip' in g) return softSkip(...g.skip);
75
+ const o = await drive('exhaustion');
76
+ if ('skip' in o) return softSkip(...o.skip);
77
+ expect(o.run.status, req(ID_EXHAUSTION, SECTION, 'a call whose every reply violates the schema MUST fail')).toBe('failed');
78
+ expect(o.run.errorCode, req(ID_EXHAUSTION, SECTION, 'the node MUST fail with envelope_invalid')).toBe('envelope_invalid');
79
+ expect(o.run.types.includes('envelope.retry-exhausted'), req(ID_EXHAUSTION, SECTION, 'exhaustion MUST emit envelope.retry-exhausted')).toBe(true);
80
+ expect(o.run.capBreachedKinds.includes('schema'), req(ID_EXHAUSTION, SECTION, `exhaustion MUST emit cap.breached kind "schema" (got ${JSON.stringify(o.run.capBreachedKinds)})`)).toBe(true);
81
+ });
82
+
83
+ it('a refusal fails the node envelope_refusal after envelope.refusal and is never retried', async () => {
84
+ const g = await reliability();
85
+ if ('skip' in g) return softSkip(...g.skip);
86
+ const o = await drive('refusal');
87
+ if ('skip' in o) return softSkip(...o.skip);
88
+ if (o.run.errorCode === 'validation_error') return softSkip('blocked', 'the host mock refused the `refusal` script kind as input (fixtures.md), so no refusal reached the call');
89
+ expect(o.run.status, req(ID_REFUSAL, SECTION, `a refusal MUST fail the node, never fall through to the scripted valid reply (got ${o.run.status})`)).toBe('failed');
90
+ expect(o.run.errorCode, req(ID_REFUSAL, SECTION, 'the node MUST fail with envelope_refusal')).toBe('envelope_refusal');
91
+ expect(o.run.types.includes('envelope.refusal'), req(ID_REFUSAL, SECTION, 'a refusal MUST emit envelope.refusal')).toBe(true);
92
+ expect(o.run.types.includes('envelope.retry-attempted'), req(ID_REFUSAL, SECTION, 'a host MUST NOT retry a refusal')).toBe(false);
93
+ });
94
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * v2 — the structured-output facets of `aiProviders`
3
+ * (`spec/v2/core/host-services.md` §Structured output; RFCs 0030, 0032, 0033).
4
+ * RFC 0255 §A moved them here from the deleted `envelopes` family, unchanged
5
+ * except that `reliability.events[]` takes the v2 names only. A claims-check
6
+ * reading: what the host says about its posture is what is witnessed. The legs
7
+ * live in `lib/family-advert-witness.ts`.
8
+ *
9
+ * record the `aiProviders` record validates against its capabilities-schema seat;
10
+ * reasoning `reasoning.promptDirective` is mandatory | advisory | off;
11
+ * tier-one `tierOneSubsetCompliance` is strict | warn | off;
12
+ * reliability `reliability.events[]` names only reliability events and lists
13
+ * the two MUST-tier events `envelope.retry-exhausted` and `envelope.refusal`;
14
+ * completion `reliability.completion.distinguishesTruncation` is a boolean
15
+ * and `truncationBudgetMultiplier` is in [1, 8].
16
+ *
17
+ * Dispositions: discovery unreadable ⇒ `blocked`; `aiProviders` absent, or the
18
+ * facet a leg reads absent ⇒ `inapplicable`.
19
+ *
20
+ * @see spec/v2/core/host-services.md §Structured output
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { v2Discovery } from '../lib/v2.js';
25
+ import { softSkip } from '../lib/soft-skip.js';
26
+ import { req } from '../lib/requirement-ids.js';
27
+ import { majorProfile } from '../lib/major-profile.js';
28
+ import { structuredOutputCompletionLeg, structuredOutputReasoningLeg, structuredOutputReliabilityLeg, structuredOutputTierOneLeg, recordSchemaLeg } from '../lib/family-advert-witness.js';
29
+
30
+ const PROFILE = majorProfile(2);
31
+ const ID_RECORD = 'openwop.requirement.aiProviders.structured-output-record';
32
+ const ID_REASONING = 'openwop.requirement.aiProviders.reasoning-advert-shape';
33
+ const ID_TIER_ONE = 'openwop.requirement.aiProviders.tier-one-subset-compliance-value';
34
+ const ID_RELIABILITY = 'openwop.requirement.aiProviders.reliability-events-advert';
35
+ const ID_COMPLETION = 'openwop.requirement.aiProviders.completion-advert-shape';
36
+
37
+ async function discovery(): Promise<Record<string, unknown> | null> {
38
+ try { return await v2Discovery(); } catch { return null; }
39
+ }
40
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
41
+
42
+ describe('v2 structured-output advertisement (host-services.md §Structured output)', () => {
43
+ it('the aiProviders record validates against its capabilities-schema seat', async () => {
44
+ const doc = await discovery();
45
+ if (!doc) return softSkip('blocked', UNREADABLE);
46
+ const out = recordSchemaLeg(PROFILE, doc, 'aiProviders', 'host-services.md §Structured output');
47
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
48
+ for (const x of out.findings) expect(x.ok, req(ID_RECORD, x.doc, x.message)).toBe(true);
49
+ });
50
+
51
+ it('aiProviders.reasoning.promptDirective is mandatory, advisory or off', async () => {
52
+ const doc = await discovery();
53
+ if (!doc) return softSkip('blocked', UNREADABLE);
54
+ const out = structuredOutputReasoningLeg(PROFILE, doc);
55
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
56
+ for (const x of out.findings) expect(x.ok, req(ID_REASONING, x.doc, x.message)).toBe(true);
57
+ });
58
+
59
+ it('aiProviders.tierOneSubsetCompliance is strict, warn or off', async () => {
60
+ const doc = await discovery();
61
+ if (!doc) return softSkip('blocked', UNREADABLE);
62
+ const out = structuredOutputTierOneLeg(PROFILE, doc);
63
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
64
+ for (const x of out.findings) expect(x.ok, req(ID_TIER_ONE, x.doc, x.message)).toBe(true);
65
+ });
66
+
67
+ it('aiProviders.reliability.events lists only reliability events and includes retry-exhausted and refusal', async () => {
68
+ const doc = await discovery();
69
+ if (!doc) return softSkip('blocked', UNREADABLE);
70
+ const out = structuredOutputReliabilityLeg(PROFILE, doc);
71
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
72
+ for (const x of out.findings) expect(x.ok, req(ID_RELIABILITY, x.doc, x.message)).toBe(true);
73
+ });
74
+
75
+ it('aiProviders.reliability.completion.distinguishesTruncation is a boolean', async () => {
76
+ const doc = await discovery();
77
+ if (!doc) return softSkip('blocked', UNREADABLE);
78
+ const out = structuredOutputCompletionLeg(PROFILE, doc);
79
+ if (out.kind === 'skip') return softSkip(out.disposition, out.reason);
80
+ for (const x of out.findings) expect(x.ok, req(ID_COMPLETION, x.doc, x.message)).toBe(true);
81
+ });
82
+ });