@openwop/openwop-conformance 2.46.34 → 2.46.36

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 +30 -0
  2. package/README.md +3 -3
  3. package/coverage.md +4 -5
  4. package/dist/spec-artifacts.lock.json +2 -2
  5. package/fixtures/conformance-interrupt-form-refused.json +108 -0
  6. package/fixtures/conformance-interrupt-form-unbound.json +108 -0
  7. package/fixtures/conformance-interrupt-form.json +107 -0
  8. package/fixtures/conformance-stream-text.json +1 -2
  9. package/fixtures.md +44 -4
  10. package/package.json +2 -2
  11. package/requirement-aliases.json +98 -1
  12. package/requirements.json +663 -804
  13. package/scenario-majors.json +16 -19
  14. package/schemas/CORPUS-STAMP.json +72 -85
  15. package/src/lib/callai-attempts-witness.ts +13 -1
  16. package/src/lib/deployment-lifecycle-witness.ts +308 -0
  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-deployment-lifecycle.test.ts +526 -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,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
+ });
@@ -1,167 +0,0 @@
1
- /**
2
- * Static walkers over envelope payload schemas, shared by the v2 static twins:
3
- * the scenario `v2-envelope-tier-one-subset-static` and the corpus gates
4
- * `v2-envelope-tier-one-universal-static` and `v2-envelope-variant-discriminator-static`.
5
- *
6
- * The walk rules are the v1 rules unchanged (RFC 0030 §B, RFC 0031 §A); what
7
- * moved at major 2 is where the schemas live (`schemas/v2/envelopes/`) and
8
- * where a host's kind catalog is advertised (`supportedEnvelopes.kinds`, a
9
- * family record, instead of a root `string[]`).
10
- *
11
- * Every function here is pure and is proven in both directions in
12
- * `envelope-schema-static.test.ts`: a conforming schema yields no violation,
13
- * and each defect yields the one it names.
14
- */
15
-
16
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
17
- import { join } from 'node:path';
18
- import { SCHEMAS_DIR } from './paths.js';
19
-
20
- export interface SchemaViolation { readonly path: string; readonly rule: string; readonly detail?: string }
21
-
22
- const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
23
-
24
- /** The v2 envelope payload schema directory. */
25
- export const V2_ENVELOPES_DIR = join(SCHEMAS_DIR, 'v2', 'envelopes');
26
-
27
- /** The four kinds every engine recognizes (`events.md` §"AI envelopes"). */
28
- export const UNIVERSAL_KINDS = ['clarification.request', 'schema.request', 'schema.response', 'error'] as const;
29
-
30
- /** A v2 envelope payload schema by kind, or `null` when the corpus has none. */
31
- export function loadV2EnvelopeSchema(kind: string): Record<string, unknown> | null {
32
- const p = join(V2_ENVELOPES_DIR, `${kind}.schema.json`);
33
- if (!existsSync(p)) return null;
34
- const v: unknown = JSON.parse(readFileSync(p, 'utf8'));
35
- return isRecord(v) ? v : null;
36
- }
37
-
38
- /** Every kind with a payload schema under `schemas/v2/envelopes/`. */
39
- export function listV2EnvelopeKinds(): string[] {
40
- if (!existsSync(V2_ENVELOPES_DIR)) return [];
41
- return readdirSync(V2_ENVELOPES_DIR).filter((f) => f.endsWith('.schema.json')).map((f) => f.replace(/\.schema\.json$/, '')).sort();
42
- }
43
-
44
- // ---------------------------------------------------------------------------
45
- // RFC 0030 §B — the Tier-1 structured-output subset.
46
- // ---------------------------------------------------------------------------
47
-
48
- /** Keywords that fail across more than one Tier-1 vendor (Gemini drops `oneOf` silently). Checked in both modes. */
49
- const LOAD_BEARING_KEYWORDS = ['oneOf', 'allOf', 'not', 'if', 'then', 'else', 'dependencies', 'prefixItems', 'propertyNames'] as const;
50
- export const MAX_DEPTH = 5;
51
- export const MAX_PROPERTIES = 100;
52
-
53
- const hasType = (schema: Record<string, unknown>, t: string): boolean => schema['type'] === t || (Array.isArray(schema['type']) && (schema['type'] as unknown[]).includes(t));
54
-
55
- /**
56
- * Walk a payload schema for Tier-1 subset violations.
57
- *
58
- * `load-bearing`: only the rules that fail across several vendors — the floor
59
- * every corpus schema is held to. `strict`: every rule of the OpenAI-strict ∩
60
- * Anthropic-strict ∩ Gemini intersection, applied only when a host advertises
61
- * `envelopes.tierOneSubsetCompliance: "strict"`.
62
- */
63
- export function tierOneViolations(schema: Record<string, unknown>, mode: 'load-bearing' | 'strict'): SchemaViolation[] {
64
- const out: SchemaViolation[] = [];
65
- const count = { n: 0 };
66
- walkTierOne(schema, '#', 0, count, out, mode);
67
- if (count.n > MAX_PROPERTIES) out.push({ path: '#', rule: 'max-property-count-100-exceeded', detail: `count=${count.n}` });
68
- return out;
69
- }
70
-
71
- function walkTierOne(schema: Record<string, unknown>, path: string, depth: number, count: { n: number }, out: SchemaViolation[], mode: 'load-bearing' | 'strict'): void {
72
- if (depth > MAX_DEPTH) { out.push({ path, rule: 'max-nesting-depth-5', detail: `depth=${depth}` }); return; }
73
- for (const kw of LOAD_BEARING_KEYWORDS) if (kw in schema) out.push({ path, rule: 'forbidden-keyword', detail: kw });
74
- if (Array.isArray(schema['anyOf'])) {
75
- (schema['anyOf'] as unknown[]).forEach((b, i) => { if (isRecord(b)) walkTierOne(b, `${path}/anyOf/${i}`, depth + 1, count, out, mode); });
76
- }
77
- if (hasType(schema, 'object')) {
78
- if (mode === 'strict' && schema['additionalProperties'] !== false) out.push({ path, rule: 'additionalProperties-must-be-false-on-object-strict-only' });
79
- const props = isRecord(schema['properties']) ? schema['properties'] : {};
80
- const required = Array.isArray(schema['required']) ? (schema['required'] as unknown[]) : [];
81
- for (const [name, sub] of Object.entries(props)) {
82
- count.n++;
83
- if (mode === 'strict' && !required.includes(name)) out.push({ path: `${path}/properties/${name}`, rule: 'property-not-in-required-strict-mode-only' });
84
- if (isRecord(sub)) walkTierOne(sub, `${path}/properties/${name}`, depth + 1, count, out, mode);
85
- }
86
- }
87
- if (mode === 'strict') {
88
- const bounded: Array<[boolean, readonly string[], string]> = [
89
- [hasType(schema, 'string'), ['minLength', 'maxLength', 'pattern', 'format'], 'forbidden-string-constraint-strict-only'],
90
- [hasType(schema, 'number') || hasType(schema, 'integer'), ['minimum', 'maximum', 'multipleOf'], 'forbidden-number-constraint-strict-only'],
91
- [hasType(schema, 'array'), ['minItems', 'maxItems', 'uniqueItems'], 'forbidden-array-constraint-strict-only'],
92
- ];
93
- for (const [applies, kws, rule] of bounded) if (applies) for (const kw of kws) if (kw in schema) out.push({ path, rule, detail: kw });
94
- }
95
- if (hasType(schema, 'array') && isRecord(schema['items'])) walkTierOne(schema['items'], `${path}/items`, depth + 1, count, out, mode);
96
- const defs = isRecord(schema['$defs']) ? schema['$defs'] : {};
97
- for (const [name, sub] of Object.entries(defs)) if (isRecord(sub)) walkTierOne(sub, `${path}/$defs/${name}`, depth + 1, count, out, mode);
98
- }
99
-
100
- // ---------------------------------------------------------------------------
101
- // RFC 0031 §A — variant payloads: no `oneOf`; every `anyOf` branch discriminated.
102
- // ---------------------------------------------------------------------------
103
-
104
- /** `oneOf` anywhere in a payload schema. */
105
- export function oneOfViolations(schema: unknown, path = '#'): SchemaViolation[] {
106
- const out: SchemaViolation[] = [];
107
- const walk = (s: unknown, p: string): void => {
108
- if (Array.isArray(s)) { s.forEach((x, i) => walk(x, `${p}/${i}`)); return; }
109
- if (!isRecord(s)) return;
110
- if ('oneOf' in s) out.push({ path: p, rule: 'oneOf-forbidden', detail: 'use anyOf with a single-string-enum discriminator (RFC 0031 §A)' });
111
- for (const [k, v] of Object.entries(s)) walk(v, `${p}/${k}`);
112
- };
113
- walk(schema, path);
114
- return out;
115
- }
116
-
117
- /**
118
- * A branch is discriminated when one of its `required` properties is
119
- * `type: string` with an `enum` of exactly one value. A `$ref` branch is
120
- * accepted unresolved, as at v1: the referenced shape is walked where it is
121
- * defined, and a version-selected union (one branch per `schemaVersion`, as
122
- * `ui.a2ui-surface` declares) is not a payload-field discrimination at all.
123
- */
124
- export function branchDiscriminated(branch: unknown): boolean {
125
- if (!isRecord(branch)) return false;
126
- if ('$ref' in branch) return true;
127
- const required = Array.isArray(branch['required']) ? (branch['required'] as unknown[]) : [];
128
- const props = isRecord(branch['properties']) ? branch['properties'] : {};
129
- return required.some((name) => {
130
- const p = typeof name === 'string' ? props[name] : undefined;
131
- return isRecord(p) && p['type'] === 'string' && Array.isArray(p['enum']) && (p['enum'] as unknown[]).length === 1;
132
- });
133
- }
134
-
135
- /** Whether a branch can match a JSON object: an explicit object type, a `$ref`, or object keywords with no type. */
136
- function admitsObject(branch: unknown): boolean {
137
- if (!isRecord(branch)) return false;
138
- if ('$ref' in branch) return true;
139
- const t = branch['type'];
140
- if (t === undefined) return 'properties' in branch || 'required' in branch;
141
- return t === 'object' || (Array.isArray(t) && t.includes('object'));
142
- }
143
-
144
- /**
145
- * Every `anyOf` branch, at any depth, that carries no single-string-enum
146
- * discriminator — in a VARIANT union, one where two or more branches can be
147
- * objects. A union with at most one object branch (`string | binding`,
148
- * `string | number | boolean | array | binding`, as `ui.a2ui-surface`
149
- * payload v2 declares) is a type union: JSON type alone selects the branch, no
150
- * field is inspected, and a scalar branch has no property to discriminate on.
151
- * RFC 0031 §A governs variant payloads; it is not read as forbidding these.
152
- */
153
- export function anyOfDiscriminatorViolations(schema: unknown, path = '#'): SchemaViolation[] {
154
- const out: SchemaViolation[] = [];
155
- const walk = (s: unknown, p: string): void => {
156
- if (Array.isArray(s)) { s.forEach((x, i) => walk(x, `${p}/${i}`)); return; }
157
- if (!isRecord(s)) return;
158
- if (Array.isArray(s['anyOf']) && (s['anyOf'] as unknown[]).filter(admitsObject).length >= 2) {
159
- (s['anyOf'] as unknown[]).forEach((b, i) => {
160
- if (admitsObject(b) && !branchDiscriminated(b)) out.push({ path: `${p}/anyOf/${i}`, rule: 'anyOf-branch-missing-discriminator', detail: 'a branch MUST declare a single-string-enum discriminator property in required (RFC 0031 §A)' });
161
- });
162
- }
163
- for (const [k, v] of Object.entries(s)) walk(v, `${p}/${k}`);
164
- };
165
- walk(schema, path);
166
- return out;
167
- }