@openwop/openwop-conformance 1.29.0 → 1.34.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,206 @@
1
+ /**
2
+ * Multi-party group conversation — shared transcript + speaker attribution (RFC 0101).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies the three additive,
5
+ * normative RFC 0101 wire facts on the published schemas:
6
+ *
7
+ * 1. `conversation-event.schema.json` `ConversationOpenedPayload` carries an
8
+ * OPTIONAL `participants: AgentRef[]` roster — a conforming 3-agent council
9
+ * transcript validates, and a malformed participant item is rejected.
10
+ * 2. `conversation-turn.schema.json` REQUIRES `speakerId` when `role: 'agent'`
11
+ * (the `allOf`/`if` conditional) — an agent turn WITH a roster-instance
12
+ * `speakerId` validates; an agent turn that OMITS `speakerId` MUST FAIL
13
+ * schema validation (the RFC 0101 attribution MUST); a `user`/`system` turn
14
+ * without `speakerId` is unaffected (additive — pre-RFC-0101 producers).
15
+ * 3. `capabilities.schema.json` declares the `multiPartyConversation` block with
16
+ * its `supported` flag (+ optional `maxParticipants`), and it is closed
17
+ * (`additionalProperties: false`).
18
+ *
19
+ * The non-participant-rejection MUST (a turn whose `speakerId` is NOT in the
20
+ * declared `participants` roster MUST be rejected) is a HOST-enforced runtime
21
+ * contract — it is not expressible by JSON Schema alone (roster membership is
22
+ * cross-field state). This probe asserts the wire SHAPE that makes the check
23
+ * possible (roster present + every agent turn attributed) and verifies the
24
+ * membership predicate directly; the behavioral leg (a live host rejecting a
25
+ * non-participant turn with `validation_error`) is gated on
26
+ * `multiPartyConversation.supported` and lands at the reference-host
27
+ * implementation (RFC 0101 §Conformance — same staging as RFC 0086's roster
28
+ * `roster.run.initiated` behavioral leg). This scenario asserts the wire
29
+ * contract, not host behavior.
30
+ *
31
+ * Normative references:
32
+ * - RFCS/0101-multi-party-group-conversation.md (§Spec / §Schema / §Conformance)
33
+ * - RFCS/0005-conversation.md (the single-agent conversation primitive this extends)
34
+ * - schemas/conversation-event.schema.json (ConversationOpenedPayload.participants)
35
+ * - schemas/conversation-turn.schema.json (speakerId + the role==='agent' conditional)
36
+ * - schemas/capabilities.schema.json (multiPartyConversation)
37
+ *
38
+ * @see RFCS/0101-multi-party-group-conversation.md
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { readFileSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import Ajv2020 from 'ajv/dist/2020.js';
45
+ import addFormats from 'ajv-formats';
46
+ import { SCHEMAS_DIR } from '../lib/paths.js';
47
+
48
+ /** Server-free assertion-message helper. */
49
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
50
+
51
+ function loadSchema(name: string): Record<string, unknown> {
52
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
53
+ }
54
+
55
+ describe('multi-party-conversation-shape: participant roster on conversation.opened (RFC 0101 §Schema, server-free)', () => {
56
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
57
+ addFormats(ajv);
58
+ ajv.addSchema(loadSchema('conversation-event.schema.json'), 'conversation-event');
59
+ const opened = ajv.getSchema('conversation-event#/$defs/ConversationOpenedPayload');
60
+
61
+ it('the ConversationOpenedPayload $def exists and declares participants: AgentRef[]', () => {
62
+ expect(opened, 'the ConversationOpenedPayload $def MUST exist').toBeTruthy();
63
+ });
64
+
65
+ it('a conforming 3-agent council transcript (participant roster + user opening turn) validates', () => {
66
+ const good = {
67
+ conversationId: 'run-abc:n1:0',
68
+ agentId: 'host:advisor-cfo',
69
+ participants: [
70
+ { agentId: 'host:advisor-cfo', name: 'CFO' },
71
+ { agentId: 'host:advisor-cmo', name: 'CMO' },
72
+ { agentId: 'host:advisor-cto', name: 'CTO' },
73
+ ],
74
+ initialTurn: {
75
+ messageId: 'run-abc:n1:0:user',
76
+ from: 'user',
77
+ content: 'Should we launch in Q3 or Q4?',
78
+ ts: 1718900000000,
79
+ role: 'user',
80
+ turnIndex: 0,
81
+ },
82
+ };
83
+ expect(opened!(good), why('RFC 0101 §Schema', 'a conversation.opened with a participant roster MUST validate')).toBe(true);
84
+ });
85
+
86
+ it('a participant entry that is not a valid AgentRef (no agentId) is rejected', () => {
87
+ const bad = {
88
+ conversationId: 'run-abc:n1:0',
89
+ participants: [{ name: 'CFO' }],
90
+ initialTurn: {
91
+ messageId: 'run-abc:n1:0:user',
92
+ from: 'user',
93
+ content: 'x',
94
+ ts: 1,
95
+ role: 'user',
96
+ turnIndex: 0,
97
+ },
98
+ };
99
+ expect(opened!(bad), why('RFC 0101 §Schema', 'a participants[] item without agentId MUST be rejected')).toBe(false);
100
+ });
101
+
102
+ it('participants is OPTIONAL — a single-agent conversation.opened (no roster) still validates (back-compat)', () => {
103
+ const noRoster = {
104
+ conversationId: 'run-abc:n1:0',
105
+ agentId: 'host:advisor-cfo',
106
+ initialTurn: {
107
+ messageId: 'run-abc:n1:0:user',
108
+ from: 'user',
109
+ content: 'x',
110
+ ts: 1,
111
+ role: 'user',
112
+ turnIndex: 0,
113
+ },
114
+ };
115
+ expect(opened!(noRoster), why('RFC 0101 §Compatibility', 'participants is additive — a roster-less conversation MUST still validate')).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe('multi-party-conversation-shape: per-turn speaker attribution (RFC 0101 §Schema, server-free)', () => {
120
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
121
+ addFormats(ajv);
122
+ const turn = ajv.compile(loadSchema('conversation-turn.schema.json'));
123
+
124
+ const agentBase = {
125
+ messageId: 'council-q1:1:agent',
126
+ from: 'host:advisor-cfo',
127
+ content: "From a cash-runway view I'd push the launch one quarter.",
128
+ ts: 1718900000000,
129
+ role: 'agent' as const,
130
+ turnIndex: 1,
131
+ };
132
+
133
+ it('an agent turn WITH a roster-instance speakerId validates', () => {
134
+ expect(
135
+ turn({ ...agentBase, speakerId: 'host:advisor-cfo' }),
136
+ why('RFC 0101 §Schema', "a role:'agent' turn carrying speakerId MUST validate"),
137
+ ).toBe(true);
138
+ });
139
+
140
+ it("a role:'agent' turn MISSING speakerId MUST fail schema validation (the attribution MUST)", () => {
141
+ expect(
142
+ turn(agentBase),
143
+ why('RFC 0101 §Schema', "a role:'agent' turn that omits speakerId MUST be rejected (the conditional required)"),
144
+ ).toBe(false);
145
+ });
146
+
147
+ it("speakerId is OPTIONAL for role:'user' / role:'system' turns (additive — pre-RFC-0101 producers)", () => {
148
+ expect(
149
+ turn({ messageId: 'council-q1:0:user', from: 'user', content: 'Q3 or Q4?', ts: 1, role: 'user', turnIndex: 0 }),
150
+ why('RFC 0101 §Compatibility', "a role:'user' turn without speakerId MUST still validate"),
151
+ ).toBe(true);
152
+ expect(
153
+ turn({ messageId: 'council-q1:9:system', from: 'system', content: 'conversation timed out', ts: 1, role: 'system', turnIndex: 9 }),
154
+ why('RFC 0101 §Compatibility', "a role:'system' turn without speakerId MUST still validate"),
155
+ ).toBe(true);
156
+ });
157
+ });
158
+
159
+ describe('multi-party-conversation-shape: non-participant turn detection (RFC 0101 §Spec, server-free)', () => {
160
+ // The roster-membership MUST ("a turn whose speakerId is not a participant MUST
161
+ // be rejected") is host-enforced cross-field state, not JSON-Schema-expressible.
162
+ // This probe asserts the membership predicate the host applies, over the same
163
+ // shapes the schema validates — so the wire contract that makes the check
164
+ // possible is verified server-free; the live-host rejection is the gated
165
+ // behavioral leg (below).
166
+ const roster = new Set(['host:advisor-cfo', 'host:advisor-cmo', 'host:advisor-cto']);
167
+
168
+ it('a turn whose speakerId IS a participant is admissible', () => {
169
+ expect(roster.has('host:advisor-cmo'), why('RFC 0101 §Spec', 'an agent in the roster MAY speak')).toBe(true);
170
+ });
171
+
172
+ it('a turn whose speakerId is NOT a participant MUST be rejected by the host (roster membership)', () => {
173
+ expect(
174
+ roster.has('host:advisor-intruder'),
175
+ why('RFC 0101 §Spec', 'a turn from a non-participant agent MUST be rejected'),
176
+ ).toBe(false);
177
+ });
178
+ });
179
+
180
+ describe('multi-party-conversation-shape: capability advertisement (RFC 0101 §Schema, server-free)', () => {
181
+ it('capabilities.schema.json declares multiPartyConversation with supported + optional maxParticipants, closed', () => {
182
+ const caps = loadSchema('capabilities.schema.json');
183
+ const props = caps.properties as Record<string, Record<string, unknown>>;
184
+ const mpc = props.multiPartyConversation as
185
+ | { properties?: Record<string, unknown>; required?: string[]; additionalProperties?: boolean }
186
+ | undefined;
187
+ expect(mpc, why('RFC 0101 §Schema', 'capabilities.multiPartyConversation MUST be declared')).toBeDefined();
188
+ expect(mpc?.properties?.supported, why('RFC 0101 §Schema', 'multiPartyConversation.supported MUST be declared')).toBeDefined();
189
+ expect(mpc?.properties?.maxParticipants, why('RFC 0101 §Schema', 'multiPartyConversation.maxParticipants MUST be declared (optional)')).toBeDefined();
190
+ expect(mpc?.required, why('RFC 0101 §Schema', 'supported MUST be required on the block')).toContain('supported');
191
+ expect(mpc?.additionalProperties, why('RFC 0101 §Schema', 'the multiPartyConversation block MUST be closed')).toBe(false);
192
+ });
193
+
194
+ it('the multiPartyConversation block validates a conforming advertisement and rejects extras', () => {
195
+ const caps = loadSchema('capabilities.schema.json');
196
+ const mpc = (caps.properties as Record<string, Record<string, unknown>>).multiPartyConversation;
197
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
198
+ addFormats(ajv);
199
+ const validate = ajv.compile(mpc);
200
+ expect(validate({ supported: true, maxParticipants: 8 }), why('RFC 0101 §Schema', 'a conforming advertisement MUST validate')).toBe(true);
201
+ expect(validate({ supported: true }), why('RFC 0101 §Schema', 'maxParticipants is optional')).toBe(true);
202
+ expect(validate({ maxParticipants: 8 }), why('RFC 0101 §Schema', 'supported is required')).toBe(false);
203
+ expect(validate({ supported: true, unexpected: 1 }), why('RFC 0101 §Schema', 'an extra key MUST be rejected (closed block)')).toBe(false);
204
+ expect(validate({ supported: true, maxParticipants: 1 }), why('RFC 0101 §Schema', 'maxParticipants minimum is 2 (a council has ≥2)')).toBe(false);
205
+ });
206
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Publishable declarative pack kinds (RFC 0107, `Active`).
3
+ *
4
+ * OpenWOP defines declarative pack kinds at the SOURCE level — artifact-type
5
+ * (RFC 0075, `kind: "artifact-type"`) and connection (RFC 0095,
6
+ * `kind: "connection"`). RFC 0107 extends the PUBLISHED contract,
7
+ * `registry-version-manifest.schema.json`, so those kinds can be published to a
8
+ * registry: it adds a `kind` discriminator (absent ≡ `node`), the per-kind
9
+ * declarative payload (`artifactTypes[]` / `provider` / `chains[]` / …), and
10
+ * makes `runtime` required only for executable kinds.
11
+ *
12
+ * Always-on + server-free. Two parts (mirrors the project's other
13
+ * schema-contract scenarios):
14
+ *
15
+ * PART 1 — contract present. `registry-operations.md` §"Validation flow"
16
+ * carries the kind-aware manifest-schema selection (#3) and the
17
+ * skip-runtime-check-for-declarative-kinds rule (#7); the version-manifest
18
+ * schema carries the `kind` enum + declarative payload + conditional runtime.
19
+ * Guards against the requirement being silently dropped.
20
+ *
21
+ * PART 2 — schema admits declarative kinds and still rejects malformed ones.
22
+ * A published artifact-type and a published connection version manifest
23
+ * validate; a node manifest is unchanged; a declarative manifest carrying
24
+ * `runtime` (forbidden) and a node manifest missing `runtime` are rejected.
25
+ *
26
+ * @see spec/v1/registry-operations.md §"Validation flow"
27
+ * @see schemas/registry-version-manifest.schema.json
28
+ * @see RFCS/0107-publishable-declarative-pack-kinds.md
29
+ * @see RFCS/0075-artifact-type-packs-realworld-amendment.md, RFCS/0095-connection-packs-portable-provider-definitions.md
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import Ajv2020 from 'ajv/dist/2020.js';
36
+ import addFormats from 'ajv-formats';
37
+ import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
38
+
39
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
40
+
41
+ describe('registry-declarative-kinds: contract present in the corpus (RFC 0107, server-free)', () => {
42
+ const registryDoc = V1_DIR ? readFileSync(join(V1_DIR, 'registry-operations.md'), 'utf8') : '';
43
+ const versionManifestSchema = JSON.parse(
44
+ readFileSync(join(SCHEMAS_DIR, 'registry-version-manifest.schema.json'), 'utf8'),
45
+ );
46
+
47
+ it.skipIf(V1_DIR === null)('registry-operations.md §Validation flow selects the manifest schema by `kind`', () => {
48
+ expect(
49
+ /kind[\s\S]{0,120}artifact-type-pack-manifest\.schema\.json/.test(registryDoc),
50
+ why('registry-operations.md §Validation flow #3', 'pack.json validates against the per-`kind` source schema (RFC 0107)'),
51
+ ).toBe(true);
52
+ });
53
+
54
+ it.skipIf(V1_DIR === null)('registry-operations.md §Validation flow skips the runtime check for declarative kinds', () => {
55
+ expect(
56
+ /declarative[\s\S]{0,160}(skipped|MUST NOT)/i.test(registryDoc) && /runtime/i.test(registryDoc),
57
+ why('registry-operations.md §Validation flow #7', 'runtime-support check is skipped for declarative kinds (RFC 0107)'),
58
+ ).toBe(true);
59
+ });
60
+
61
+ it('registry-version-manifest.schema.json carries the `kind` discriminator + declarative payload + conditional runtime', () => {
62
+ const props = versionManifestSchema.properties ?? {};
63
+ expect(props.kind?.enum, why('registry-version-manifest.schema.json', '`kind` enum present')).toEqual(
64
+ expect.arrayContaining(['node', 'artifact-type', 'connection']),
65
+ );
66
+ expect(!!props.artifactTypes && !!props.provider, why('registry-version-manifest.schema.json', 'declarative payload props present')).toBe(true);
67
+ expect(
68
+ (versionManifestSchema.required ?? []).includes('runtime'),
69
+ why('registry-version-manifest.schema.json', '`runtime` is NOT top-level required (conditional on kind)'),
70
+ ).toBe(false);
71
+ expect(
72
+ JSON.stringify(versionManifestSchema.allOf ?? []).includes('runtime'),
73
+ why('registry-version-manifest.schema.json', '`allOf` if/then/else makes runtime conditional on kind'),
74
+ ).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe('registry-declarative-kinds: version-manifest schema admits declarative kinds (RFC 0107, server-free)', () => {
79
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
80
+ addFormats(ajv);
81
+ const validate = ajv.compile(
82
+ JSON.parse(readFileSync(join(SCHEMAS_DIR, 'registry-version-manifest.schema.json'), 'utf8')),
83
+ );
84
+
85
+ const base = { name: 'core.openwop.x', version: '1.0.0', engines: { openwop: '>=1.0.0' }, integrity: 'sha256-abc=' };
86
+
87
+ it('a published artifact-type version manifest validates (kind + artifactTypes, no runtime)', () => {
88
+ const ok = validate({ ...base, kind: 'artifact-type', artifactTypes: [{ artifactTypeId: 'doc.one-pager', title: 'One-Pager', schema: { type: 'object' } }] });
89
+ expect(ok, why('registry-operations.md §Validation flow', 'artifact-type manifest publishes (RFC 0107)')).toBe(true);
90
+ });
91
+
92
+ it('a published connection version manifest validates (kind + provider, no runtime)', () => {
93
+ const ok = validate({ ...base, kind: 'connection', provider: { id: 'github', category: 'dev' } });
94
+ expect(ok, why('registry-operations.md §Validation flow', 'connection manifest publishes (RFC 0107)')).toBe(true);
95
+ });
96
+
97
+ it('an unchanged node version manifest still validates (no kind, runtime + nodes)', () => {
98
+ const ok = validate({ ...base, runtime: { language: 'javascript' }, nodes: [{ typeId: 'core.openwop.x.n', version: '1.0.0', category: 'data', role: 'pure' }] });
99
+ expect(ok, why('COMPATIBILITY.md §2.1', 'RFC 0107 is additive — node manifests validate unchanged')).toBe(true);
100
+ });
101
+
102
+ it('a declarative manifest carrying `runtime` is REJECTED (declarative kinds carry no runtime)', () => {
103
+ const ok = validate({ ...base, kind: 'artifact-type', artifactTypes: [{ artifactTypeId: 'doc.x' }], runtime: { language: 'javascript' } });
104
+ expect(ok, why('registry-version-manifest.schema.json', 'declarative kind MUST NOT carry runtime')).toBe(false);
105
+ });
106
+
107
+ it('a node manifest MISSING `runtime` is still REJECTED (executable kinds require runtime)', () => {
108
+ const ok = validate({ ...base, nodes: [{ typeId: 'core.openwop.x.n', version: '1.0.0', category: 'data', role: 'pure' }] });
109
+ expect(ok, why('registry-version-manifest.schema.json', 'executable kind (node/absent) still requires runtime')).toBe(false);
110
+ });
111
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Barge-in: no partial-output leak (RFC 0106 §F INV-3) — behavioral.
3
+ *
4
+ * Backs the protocol-tier SECURITY invariant `voice-bargein-no-partial-leak`.
5
+ * Gated on `capabilities.aiProviders.realtimeVoice.bargeIn === 'supported'`.
6
+ * Soft-skips when unadvertised (default) / hard-fails under
7
+ * `OPENWOP_REQUIRE_BEHAVIOR=true`. Drives `POST /v1/host/sample/voice/barge-in`
8
+ * (soft-skips on 404):
9
+ *
10
+ * - the host MUST emit `voice.barge_in` then `voice.cancelled` (distinct events);
11
+ * - NO `voice.synthesis_chunk` (nor any partial tool/model output) may appear
12
+ * AFTER the `voice.cancelled` — a cancellation is all-or-nothing and MUST NOT
13
+ * leak the un-guardrailed partial the end-of-turn pass would otherwise scrub.
14
+ *
15
+ * Spec references:
16
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0106-realtime-voice-session-profile.md (§F INV-3)
17
+ * - https://github.com/openwop/openwop/blob/main/SECURITY/invariants.yaml (voice-bargein-no-partial-leak)
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { driver } from '../lib/driver.js';
22
+ import { behaviorGate } from '../lib/behavior-gate.js';
23
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
24
+
25
+ const SEAM = '/v1/host/sample/voice/barge-in';
26
+
27
+ function realtimeVoiceOf(ai: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
28
+ const rv = (ai as { realtimeVoice?: unknown })?.realtimeVoice;
29
+ return rv && typeof rv === 'object' ? (rv as Record<string, unknown>) : undefined;
30
+ }
31
+ function eventsOf(json: unknown): Array<{ type?: string }> {
32
+ const e = (json as { events?: unknown })?.events;
33
+ return Array.isArray(e) ? (e as Array<{ type?: string }>) : [];
34
+ }
35
+
36
+ describe('voice-bargein-no-partial-leak (RFC 0106 §F INV-3)', () => {
37
+ it('barge-in cancels with no synthesis chunk emitted after voice.cancelled', async () => {
38
+ const ai = await readCapabilityFamily<Record<string, unknown>>('aiProviders');
39
+ const advertised = realtimeVoiceOf(ai)?.bargeIn === 'supported';
40
+ if (!behaviorGate('openwop-voice-bargein', advertised)) return;
41
+
42
+ const res = await driver.post(SEAM, {});
43
+ if (res.status === 404) return; // seam unwired — soft-skip
44
+
45
+ expect(res.status === 200, driver.describe('RFC 0106 §F', 'the barge-in seam MUST return 200')).toBe(true);
46
+
47
+ const types = eventsOf(res.json).map((e) => e.type);
48
+ const bargeIdx = types.indexOf('voice.barge_in');
49
+ const cancelIdx = types.indexOf('voice.cancelled');
50
+ expect(bargeIdx >= 0, driver.describe('RFC 0106 §D', 'a barge-in MUST emit voice.barge_in')).toBe(true);
51
+ expect(cancelIdx >= 0, driver.describe('RFC 0106 §D', 'a barge-in that cancels work MUST emit voice.cancelled')).toBe(true);
52
+ expect(cancelIdx > bargeIdx, driver.describe('RFC 0106 §D', 'voice.cancelled MUST follow voice.barge_in')).toBe(true);
53
+
54
+ // The load-bearing INV-3 assertion: nothing partial after the cancel.
55
+ const afterCancel = types.slice(cancelIdx + 1);
56
+ expect(
57
+ !afterCancel.includes('voice.synthesis_chunk'),
58
+ driver.describe('RFC 0106 §F INV-3', 'NO voice.synthesis_chunk (partial output) may be emitted AFTER voice.cancelled'),
59
+ ).toBe(true);
60
+ });
61
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Voice run-event payload shapes (RFC 0106 §B/§C/§D) — server-free.
3
+ *
4
+ * Always-on schema-shape probe for the `voice.*` run-event taxonomy — the single
5
+ * canonical record of a live voice turn (the `ctx.callTranscriber` Promise resolves
6
+ * at `turn_commit`; the events ARE the streaming representation). Verifies that:
7
+ * - all seven `voice.*` types are members of `run-event.schema.json#$defs.RunEventType`;
8
+ * - each has a payload `$def` in `run-event-payloads.schema.json` reachable via the
9
+ * `typeIndex` lookup map;
10
+ * - `voice.transcript` REQUIRES `contentTrust: "untrusted"` — the schema-enforced wire
11
+ * half of SECURITY invariant `voice-transcript-untrusted` (live transcript is
12
+ * untrusted ingress and MUST NOT be promoted to system/developer authority);
13
+ * - `voice.synthesis_chunk` is metadata-shaped (seq + mimeType required; bytes by
14
+ * `url`/`streamRef`, inline `base64` optional-only) — backing the G8 metadata-only
15
+ * event-log rule (`voice-streamref-tenant-bound` keeps the log bounded);
16
+ * - the content-free events (`speech_start`/`endpoint_candidate`/`turn_commit`/
17
+ * `barge_in`/`cancelled`) reject an unknown property (`additionalProperties:false`).
18
+ *
19
+ * The behavioral legs (interim-not-durable, barge-in no-partial-leak, streamRef
20
+ * tenant-binding, the live `callTranscriber` Promise + emission) are the gated
21
+ * reference-host scenarios that land at `Active → Accepted` — those invariants are
22
+ * reference-impl tier until a host proves them non-vacuously.
23
+ *
24
+ * Spec references:
25
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0106-realtime-voice-session-profile.md (§B, §C, §D, §F)
26
+ * - https://github.com/openwop/openwop/blob/main/schemas/run-event-payloads.schema.json
27
+ */
28
+
29
+ import { describe, it, expect } from 'vitest';
30
+ import { readFileSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ import Ajv2020 from 'ajv/dist/2020.js';
33
+ import addFormats from 'ajv-formats';
34
+ import { SCHEMAS_DIR } from '../lib/paths.js';
35
+
36
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
37
+
38
+ function loadSchema(name: string): Record<string, any> {
39
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, any>;
40
+ }
41
+
42
+ const VOICE_TYPES = [
43
+ 'voice.speech_start',
44
+ 'voice.transcript',
45
+ 'voice.endpoint_candidate',
46
+ 'voice.turn_commit',
47
+ 'voice.synthesis_chunk',
48
+ 'voice.barge_in',
49
+ 'voice.cancelled',
50
+ ] as const;
51
+
52
+ const DEF_KEY: Record<string, string> = {
53
+ 'voice.speech_start': 'voiceSpeechStart',
54
+ 'voice.transcript': 'voiceTranscript',
55
+ 'voice.endpoint_candidate': 'voiceEndpointCandidate',
56
+ 'voice.turn_commit': 'voiceTurnCommit',
57
+ 'voice.synthesis_chunk': 'voiceSynthesisChunk',
58
+ 'voice.barge_in': 'voiceBargeIn',
59
+ 'voice.cancelled': 'voiceCancelled',
60
+ };
61
+
62
+ describe('voice-event-payloads-shape: the voice.* run-event taxonomy (RFC 0106 §B/§C/§D, server-free)', () => {
63
+ it('all seven voice.* types are in the RunEventType enum', () => {
64
+ const enumVals: string[] = loadSchema('run-event.schema.json').$defs.RunEventType.enum;
65
+ for (const t of VOICE_TYPES) {
66
+ expect(enumVals.includes(t), why('run-event.schema.json §RunEventType', `${t} MUST be a RunEventType`)).toBe(true);
67
+ }
68
+ });
69
+
70
+ it('each voice.* type has a payload $def reachable via typeIndex', () => {
71
+ const payloads = loadSchema('run-event-payloads.schema.json');
72
+ const typeIndex = payloads.$defs._typeIndex.properties as Record<string, { $ref: string }>;
73
+ for (const t of VOICE_TYPES) {
74
+ expect(typeIndex[t], why('run-event-payloads.schema.json §typeIndex', `${t} MUST map to a $def`)).toBeDefined();
75
+ expect(payloads.$defs[DEF_KEY[t]], why('run-event-payloads.schema.json', `$defs.${DEF_KEY[t]} MUST exist`)).toBeDefined();
76
+ }
77
+ });
78
+
79
+ it('voice.transcript REQUIRES contentTrust:"untrusted" (SECURITY: voice-transcript-untrusted)', () => {
80
+ const def = loadSchema('run-event-payloads.schema.json').$defs.voiceTranscript;
81
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
82
+ addFormats(ajv);
83
+ const validate = ajv.compile(def);
84
+
85
+ expect(
86
+ validate({ text: 'book a table', isFinal: true, atMs: 1200, contentTrust: 'untrusted' }),
87
+ why('RFC 0106 §F INV-2', 'a transcript marked untrusted MUST validate'),
88
+ ).toBe(true);
89
+ expect(
90
+ validate({ text: 'book a table', isFinal: true, atMs: 1200 }),
91
+ why('RFC 0106 §F INV-2 (voice-transcript-untrusted)', 'a transcript WITHOUT contentTrust MUST be rejected'),
92
+ ).toBe(false);
93
+ expect(
94
+ validate({ text: 'book a table', isFinal: true, atMs: 1200, contentTrust: 'trusted' }),
95
+ why('RFC 0106 §F INV-2 (voice-transcript-untrusted)', 'contentTrust MUST be the const "untrusted" — never promoted'),
96
+ ).toBe(false);
97
+ });
98
+
99
+ it('voice.synthesis_chunk is metadata-shaped (seq+mimeType required; bytes by reference, inline base64 optional)', () => {
100
+ const def = loadSchema('run-event-payloads.schema.json').$defs.voiceSynthesisChunk;
101
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
102
+ addFormats(ajv);
103
+ const validate = ajv.compile(def);
104
+
105
+ expect(
106
+ validate({ seq: 0, mimeType: 'audio/mpeg', durationMs: 240, url: 'https://host/seg/0', final: false }),
107
+ why('RFC 0106 §C', 'a metadata chunk referencing bytes by url MUST validate'),
108
+ ).toBe(true);
109
+ expect(
110
+ validate({ mimeType: 'audio/mpeg' }),
111
+ why('RFC 0106 §C', 'a chunk without seq MUST be rejected'),
112
+ ).toBe(false);
113
+ });
114
+
115
+ it('content-free voice events reject unknown properties (additionalProperties:false)', () => {
116
+ const payloads = loadSchema('run-event-payloads.schema.json');
117
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
118
+ addFormats(ajv);
119
+ for (const key of ['voiceSpeechStart', 'voiceTurnCommit', 'voiceBargeIn', 'voiceCancelled', 'voiceEndpointCandidate']) {
120
+ const validate = ajv.compile(payloads.$defs[key]);
121
+ expect(
122
+ validate({ atMs: 100, transcriptBody: 'should not be here' }),
123
+ why('RFC 0106 §D', `${key} MUST reject an unknown (content-bearing) property`),
124
+ ).toBe(false);
125
+ }
126
+ });
127
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Interim transcript is not durable (RFC 0106 §F INV-1) — behavioral.
3
+ *
4
+ * Backs the SECURITY invariant `voice-interim-not-durable` (reference-impl tier
5
+ * until a host proves it non-vacuously — graduates to protocol at the dual-witness
6
+ * step). Gated on `realtimeVoice.transcription === 'streaming'`. Soft-skips when
7
+ * unadvertised / when the host honestly rejects a live `streamRef` (§E) / on 404.
8
+ * Drives `POST /v1/host/sample/ai/call-transcriber`:
9
+ *
10
+ * - a provisional `voice.transcript` (`isFinal: false`) MUST NOT appear AFTER the
11
+ * terminal `voice.turn_commit`, and MUST NOT be the authoritative committed text;
12
+ * - the committed `finalText` is the settled turn (acting on an interim that the
13
+ * ASR later revised is the interim→final poisoning threat §F defends against).
14
+ *
15
+ * Spec references:
16
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0106-realtime-voice-session-profile.md (§F INV-1)
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { driver } from '../lib/driver.js';
21
+ import { behaviorGate } from '../lib/behavior-gate.js';
22
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
23
+
24
+ const SEAM = '/v1/host/sample/ai/call-transcriber';
25
+
26
+ function realtimeVoiceOf(ai: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
27
+ const rv = (ai as { realtimeVoice?: unknown })?.realtimeVoice;
28
+ return rv && typeof rv === 'object' ? (rv as Record<string, unknown>) : undefined;
29
+ }
30
+ function errCode(json: unknown): string | undefined {
31
+ return (json as { error?: { code?: string } })?.error?.code;
32
+ }
33
+ function eventsOf(json: unknown): Array<{ type?: string; payload?: Record<string, unknown> }> {
34
+ const e = (json as { events?: unknown })?.events;
35
+ return Array.isArray(e) ? (e as Array<{ type?: string; payload?: Record<string, unknown> }>) : [];
36
+ }
37
+
38
+ describe('voice-interim-not-durable (RFC 0106 §F INV-1)', () => {
39
+ it('no provisional (isFinal:false) transcript is durable past voice.turn_commit', async () => {
40
+ const ai = await readCapabilityFamily<Record<string, unknown>>('aiProviders');
41
+ const advertised = realtimeVoiceOf(ai)?.transcription === 'streaming';
42
+ if (!behaviorGate('openwop-voice-interim-not-durable', advertised)) return;
43
+
44
+ const res = await driver.post(SEAM, { audio: { streamRef: 'stream:conformance/mic' }, interimResults: true });
45
+ if (res.status === 404) return; // seam unwired — soft-skip
46
+ if (errCode(res.json) === 'transcription_unsupported') return; // §E live transport / no test-seam arm — soft-skip
47
+
48
+ const events = eventsOf(res.json);
49
+ if (events.length === 0) return; // host returned only the result envelope — nothing observable to assert
50
+
51
+ const commitIdx = events.findIndex((e) => e.type === 'voice.turn_commit');
52
+ if (commitIdx < 0) return; // no commit observed — soft-skip
53
+ const afterCommit = events.slice(commitIdx + 1);
54
+ expect(
55
+ !afterCommit.some((e) => e.type === 'voice.transcript' && (e.payload ?? {}).isFinal === false),
56
+ driver.describe('RFC 0106 §F INV-1', 'a provisional (isFinal:false) transcript MUST NOT appear after voice.turn_commit'),
57
+ ).toBe(true);
58
+ });
59
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * streamRef is tenant + session bound (RFC 0106 §F INV-4) — behavioral.
3
+ *
4
+ * Backs the SECURITY invariant `voice-streamref-tenant-bound` (reference-impl tier
5
+ * until a host with LIVE `streamRef` transport proves it non-vacuously — graduates
6
+ * to protocol then). Gated on `realtimeVoice.transcription === 'streaming'`.
7
+ * Soft-skips when unadvertised / on 404 / when the host's live-stream transport is
8
+ * host-internal per §E (honest `transcription_unsupported` for a `streamRef`) —
9
+ * a stateless host without live transport cannot exhibit the cross-handle read, so
10
+ * there is nothing to bind. Drives `POST /v1/host/sample/ai/call-transcriber`:
11
+ *
12
+ * - a `streamRef` is bound to exactly one tenant + session for its lifetime; no
13
+ * buffered audio / interim transcript may be read via another handle, and a
14
+ * never-finalizing stream is bounded by a max-uncommitted-audio budget (TDoS).
15
+ *
16
+ * Spec references:
17
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0106-realtime-voice-session-profile.md (§B.1, §F INV-4)
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { driver } from '../lib/driver.js';
22
+ import { behaviorGate } from '../lib/behavior-gate.js';
23
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
24
+
25
+ const SEAM = '/v1/host/sample/ai/call-transcriber';
26
+
27
+ function realtimeVoiceOf(ai: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
28
+ const rv = (ai as { realtimeVoice?: unknown })?.realtimeVoice;
29
+ return rv && typeof rv === 'object' ? (rv as Record<string, unknown>) : undefined;
30
+ }
31
+ function errCode(json: unknown): string | undefined {
32
+ return (json as { error?: { code?: string } })?.error?.code;
33
+ }
34
+
35
+ describe('voice-streamref-tenant-bound (RFC 0106 §F INV-4)', () => {
36
+ it('a live streamRef is tenant+session bound (host-internal transport per §E soft-skips)', async () => {
37
+ const ai = await readCapabilityFamily<Record<string, unknown>>('aiProviders');
38
+ const advertised = realtimeVoiceOf(ai)?.transcription === 'streaming';
39
+ if (!behaviorGate('openwop-voice-streamref-tenant', advertised)) return;
40
+
41
+ // Probe whether the host honors a LIVE streamRef at all. If it rejects with
42
+ // transcription_unsupported (§E: live transport host-internal), there is no live
43
+ // conduit to bind cross-tenant — soft-skip (the invariant stays reference-impl
44
+ // until a host with live streamRef transport proves it).
45
+ const res = await driver.post(SEAM, { audio: { streamRef: 'stream:tenant-a/mic' } });
46
+ if (res.status === 404) return;
47
+ if (errCode(res.json) === 'transcription_unsupported') return; // §E — no live transport on this host
48
+
49
+ // A host that DOES accept a live streamRef must bind it: a second tenant presenting
50
+ // tenant-A's streamRef MUST be rejected (cross-handle read forbidden). Exercising the
51
+ // two-credential path requires a second principal the suite does not synthesize here;
52
+ // assert the minimum a single-credential probe can: the host did not silently echo
53
+ // another tenant's buffered audio for an unknown streamRef.
54
+ expect(
55
+ res.status !== 200 || (res.json as { finalText?: unknown })?.finalText !== undefined,
56
+ driver.describe('RFC 0106 §F INV-4', 'a host honoring a live streamRef MUST bind it to its tenant+session'),
57
+ ).toBe(true);
58
+ });
59
+ });