@openwop/openwop-conformance 1.37.0 → 1.43.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,93 @@
1
+ /**
2
+ * Channel presence — `channel.presence` ephemeral event (RFC 0110).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies the additive, normative
5
+ * RFC 0110 wire facts on the published schemas:
6
+ *
7
+ * 1. `channel-presence-payload.schema.json` validates a conforming presence
8
+ * snapshot (`{ conversationId, present[], typing? }`), REQUIRES
9
+ * conversationId + present, and is CLOSED (`additionalProperties: false`) —
10
+ * the no-PII guard: no `ip`/`location`/free-text can ride the payload.
11
+ * 2. `typing` is OPTIONAL — a snapshot with nobody typing validates.
12
+ * 3. `run-event.schema.json` enumerates `channel.presence` as a RunEvent type.
13
+ * 4. `capabilities.schema.json` declares `channelPresence` with `supported`,
14
+ * closed.
15
+ *
16
+ * The host-side MUSTs — presence is membership-gated (every ref a current
17
+ * participant; never delivered to a non-member) and EPHEMERAL (never persisted
18
+ * to the replayable log; replay/`:fork`-invisible) — are behavioral contracts
19
+ * gated on `channelPresence.supported`, landing at the reference-host
20
+ * implementation (RFC 0110 §Conformance). This scenario asserts the wire SHAPE.
21
+ *
22
+ * Normative references:
23
+ * - RFCS/0110-channel-presence.md (§Proposal / §Conformance)
24
+ * - schemas/channel-presence-payload.schema.json
25
+ * - schemas/run-event.schema.json (the channel.presence type)
26
+ * - schemas/capabilities.schema.json (channelPresence)
27
+ *
28
+ * @see RFCS/0110-channel-presence.md
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { readFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import Ajv2020 from 'ajv/dist/2020.js';
35
+ import addFormats from 'ajv-formats';
36
+ import { SCHEMAS_DIR } from '../lib/paths.js';
37
+
38
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
39
+
40
+ function loadSchema(name: string): Record<string, unknown> {
41
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
42
+ }
43
+
44
+ describe('channel-presence-shape: the channel.presence payload (RFC 0110 §Proposal, server-free)', () => {
45
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
46
+ addFormats(ajv);
47
+ const presence = ajv.compile(loadSchema('channel-presence-payload.schema.json'));
48
+
49
+ it('a conforming presence snapshot (present + typing) validates', () => {
50
+ expect(
51
+ presence({ conversationId: 'chan-eng', present: ['user:alice', 'agent:iris'], typing: ['user:alice'] }),
52
+ why('RFC 0110 §Proposal', 'a conforming channel.presence payload MUST validate'),
53
+ ).toBe(true);
54
+ });
55
+
56
+ it('typing is OPTIONAL — a snapshot with nobody typing validates', () => {
57
+ expect(
58
+ presence({ conversationId: 'chan-eng', present: ['user:alice'] }),
59
+ why('RFC 0110 §Proposal', 'typing is optional'),
60
+ ).toBe(true);
61
+ });
62
+
63
+ it('conversationId and present are REQUIRED', () => {
64
+ expect(presence({ present: ['user:a'] }), why('RFC 0110 §Proposal', 'conversationId is required')).toBe(false);
65
+ expect(presence({ conversationId: 'c' }), why('RFC 0110 §Proposal', 'present is required')).toBe(false);
66
+ });
67
+
68
+ it('the payload is CLOSED — a non-subject-ref field (ip/location) MUST be rejected (the no-PII guard)', () => {
69
+ expect(
70
+ presence({ conversationId: 'c', present: ['user:a'], ip: '10.0.0.1' }),
71
+ why('RFC 0110 §Proposal', 'channel.presence MUST forbid extra keys — no PII rides the payload'),
72
+ ).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe('channel-presence-shape: event type + capability advertisement (RFC 0110 §Conformance, server-free)', () => {
77
+ it('run-event.schema.json enumerates `channel.presence` as a RunEvent type', () => {
78
+ const re = loadSchema('run-event.schema.json');
79
+ const enumVals = JSON.stringify(re);
80
+ expect(enumVals.includes('"channel.presence"'), why('RFC 0110 §Proposal', 'channel.presence MUST be a declared RunEvent type')).toBe(true);
81
+ });
82
+
83
+ it('capabilities.schema.json declares channelPresence with supported, closed', () => {
84
+ const caps = loadSchema('capabilities.schema.json');
85
+ const block = (caps.properties as Record<string, Record<string, unknown>>).channelPresence as
86
+ | { properties?: Record<string, unknown>; required?: string[]; additionalProperties?: boolean }
87
+ | undefined;
88
+ expect(block, why('RFC 0110 §Conformance', 'capabilities.channelPresence MUST be declared')).toBeDefined();
89
+ expect(block?.properties?.supported, why('RFC 0110 §Conformance', 'channelPresence.supported MUST be declared')).toBeDefined();
90
+ expect(block?.required, why('RFC 0110 §Conformance', 'supported MUST be required on the block')).toContain('supported');
91
+ expect(block?.additionalProperties, why('RFC 0110 §Conformance', 'the block MUST be closed')).toBe(false);
92
+ });
93
+ });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * RFC 0111 — Context Economy: transcript token budget.
3
+ *
4
+ * Verifies the OPT-IN per-turn token bound on the orchestrator transcript
5
+ * (`spec/v1/multi-agent-execution.md` §"Context economy"). A host advertising
6
+ * `multiAgent.executionModel.contextBudget.transcriptTokenBudget` MUST NOT feed
7
+ * more than that many tokens of transcript to any single orchestrator turn,
8
+ * measured in the advertised `tokenCounter` unit.
9
+ *
10
+ * Capability-gated on `multiAgent.executionModel.contextBudget.transcriptTokenBudget`
11
+ * being PRESENT (root-first per RFC 0073) via `behaviorGate`. The assembled
12
+ * transcript is host-internal and never crosses the wire, so the scenario reads
13
+ * the host's own per-iteration accounting via the OPTIONAL conformance seam
14
+ * `GET /v1/host/sample/agent/transcript-window?runId=…&iteration=N`
15
+ * (`host-sample-test-seams.md` §14): `{ tokenCounter, tokenCount, eventIds,
16
+ * summarizedRanges }`. The seam is OPTIONAL — the scenario soft-skips on
17
+ * `404`/`405` (the RFC defers reference-host implementation).
18
+ *
19
+ * Asserts, for each iteration the host reports:
20
+ * 1. `tokenCounter` equals the advertised `contextBudget.tokenCounter`.
21
+ * 2. `tokenCount ≤ transcriptTokenBudget` (the per-turn bound).
22
+ * 3. CROSS-CHECK — the harness independently reads the events named in
23
+ * `eventIds` from the run event-log (`/v1/host/sample/test/runs/:runId/events`)
24
+ * and confirms every named id is a real persisted event of the run, so the
25
+ * host's reported accounting is internally consistent (not fabricated).
26
+ * 4. RECENT-TAIL — `eventIds` are a contiguous most-recent suffix of the run's
27
+ * eligible event-log entries (no older event included while a newer eligible
28
+ * one is dropped).
29
+ * 5. SUMMARIZED-RANGE — every `summarizedRanges[].summaryRef` has a matching
30
+ * `context.summarized` event in the run event-log.
31
+ *
32
+ * Honest non-vacuity ceiling (RFC 0111 §"Conformance seam"): the model-facing
33
+ * prompt is genuinely host-internal, so this proves the host's DECLARED
34
+ * accounting is internally consistent + within budget — it cannot black-box-prove
35
+ * the host feeds nothing additional off-seam. The capability is advertise-and-attest.
36
+ *
37
+ * @see RFCS/0111-context-economy.md
38
+ * @see spec/v1/multi-agent-execution.md §"Context economy (RFC 0111)"
39
+ * @see spec/v1/host-sample-test-seams.md §14
40
+ */
41
+
42
+ import { describe, it, expect } from 'vitest';
43
+ import { driver } from '../lib/driver.js';
44
+ import { pollUntilTerminal } from '../lib/polling.js';
45
+ import { behaviorGate } from '../lib/behavior-gate.js';
46
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
47
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
48
+ import { queryTestEvents } from '../lib/event-log-query.js';
49
+
50
+ const FIXTURE = 'conformance-context-budget-multiturn';
51
+ const PROFILE = 'openwop-context-budget';
52
+ const MAX_ITERATIONS_PROBED = 16;
53
+
54
+ interface SummarizationCap {
55
+ readonly supported?: boolean;
56
+ readonly strategy?: string;
57
+ readonly keepLastTurns?: number;
58
+ }
59
+ interface ContextBudgetCap {
60
+ readonly transcriptTokenBudget?: number;
61
+ readonly tokenCounter?: string;
62
+ readonly summarization?: SummarizationCap;
63
+ }
64
+ interface ExecutionModelCap {
65
+ readonly contextBudget?: ContextBudgetCap;
66
+ }
67
+ interface MultiAgentCap {
68
+ readonly executionModel?: ExecutionModelCap;
69
+ }
70
+
71
+ // ── cast-free typed accessors (no `as`) ──────────────────────────────────
72
+ function isRecord(v: unknown): v is Record<string, unknown> {
73
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
74
+ }
75
+ function isString(v: unknown): v is string {
76
+ return typeof v === 'string';
77
+ }
78
+ function isNumber(v: unknown): v is number {
79
+ return typeof v === 'number';
80
+ }
81
+ function stringOf(v: unknown): string | undefined {
82
+ return isString(v) ? v : undefined;
83
+ }
84
+ function numberOf(v: unknown): number | undefined {
85
+ return isNumber(v) ? v : undefined;
86
+ }
87
+ function stringArrayOf(v: unknown): string[] | undefined {
88
+ return Array.isArray(v) && v.every(isString) ? v : undefined;
89
+ }
90
+ function runIdOf(v: unknown): string | undefined {
91
+ return isRecord(v) ? stringOf(v['runId']) : undefined;
92
+ }
93
+
94
+ interface SummarizedRange {
95
+ readonly summaryRef: string;
96
+ readonly replacedTurns: string[];
97
+ }
98
+ interface TranscriptWindow {
99
+ readonly tokenCounter: string;
100
+ readonly tokenCount: number;
101
+ readonly eventIds: string[];
102
+ readonly summarizedRanges: SummarizedRange[];
103
+ }
104
+
105
+ function summarizedRangeOf(v: unknown): SummarizedRange | undefined {
106
+ if (!isRecord(v)) return undefined;
107
+ const summaryRef = stringOf(v['summaryRef']);
108
+ const replacedTurns = stringArrayOf(v['replacedTurns']);
109
+ if (summaryRef === undefined || replacedTurns === undefined) return undefined;
110
+ return { summaryRef, replacedTurns };
111
+ }
112
+
113
+ /** Parse the seam response into a typed window — undefined if the shape is wrong. */
114
+ function transcriptWindowOf(v: unknown): TranscriptWindow | undefined {
115
+ if (!isRecord(v)) return undefined;
116
+ const tokenCounter = stringOf(v['tokenCounter']);
117
+ const tokenCount = numberOf(v['tokenCount']);
118
+ const eventIds = stringArrayOf(v['eventIds']);
119
+ if (tokenCounter === undefined || tokenCount === undefined || eventIds === undefined) return undefined;
120
+ const rawRanges = v['summarizedRanges'];
121
+ const summarizedRanges: SummarizedRange[] = [];
122
+ if (Array.isArray(rawRanges)) {
123
+ for (const r of rawRanges) {
124
+ const parsed = summarizedRangeOf(r);
125
+ if (parsed === undefined) return undefined; // malformed range → fail loudly via caller
126
+ summarizedRanges.push(parsed);
127
+ }
128
+ }
129
+ return { tokenCounter, tokenCount, eventIds, summarizedRanges };
130
+ }
131
+
132
+ describe('context-budget-transcript-bound (RFC 0111 §"Context economy")', () => {
133
+ it('bounds the per-turn transcript to transcriptTokenBudget with an internally-consistent, recent-tail accounting', async () => {
134
+ const ma = await readCapabilityFamily<MultiAgentCap>('multiAgent');
135
+ const cb = ma?.executionModel?.contextBudget;
136
+ const budget = numberOf(cb?.transcriptTokenBudget);
137
+ if (!behaviorGate(PROFILE, budget !== undefined)) return;
138
+ if (!isFixtureAdvertised(FIXTURE)) return; // fixture-gated soft-skip
139
+
140
+ const advertisedCounter = stringOf(cb?.tokenCounter);
141
+ expect(
142
+ advertisedCounter,
143
+ driver.describe('RFC 0111', 'tokenCounter MUST be advertised when transcriptTokenBudget is present (schema if/then)'),
144
+ ).toBeDefined();
145
+
146
+ // Drive the multi-turn orchestrator run.
147
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
148
+ expect(create.status).toBe(201);
149
+ const runId = runIdOf(create.json);
150
+ expect(runId, 'POST /v1/runs MUST return a runId').toBeDefined();
151
+ if (runId === undefined) return;
152
+ await pollUntilTerminal(runId);
153
+
154
+ // Probe the per-iteration transcript-window seam (OPTIONAL).
155
+ const windows: Array<{ iteration: number; window: TranscriptWindow }> = [];
156
+ for (let iteration = 1; iteration <= MAX_ITERATIONS_PROBED; iteration += 1) {
157
+ const res = await driver.get(
158
+ `/v1/host/sample/agent/transcript-window?runId=${encodeURIComponent(runId)}&iteration=${iteration}`,
159
+ );
160
+ if (res.status === 404 || res.status === 405) {
161
+ if (iteration === 1) return; // seam unwired — soft-skip the whole scenario
162
+ break; // iterations exhausted
163
+ }
164
+ if (res.status === 400 || res.status === 422) break; // iteration past the run's last turn
165
+ expect(
166
+ res.status === 200,
167
+ driver.describe('host-sample-test-seams.md §14', 'the transcript-window seam MUST return 200 for a valid iteration'),
168
+ ).toBe(true);
169
+ const window = transcriptWindowOf(res.json);
170
+ expect(
171
+ window,
172
+ driver.describe('host-sample-test-seams.md §14', 'the seam MUST return { tokenCounter, tokenCount, eventIds, summarizedRanges }'),
173
+ ).toBeDefined();
174
+ if (window === undefined) return;
175
+ windows.push({ iteration, window });
176
+ }
177
+
178
+ // Non-vacuity: a wired seam MUST report at least one iteration.
179
+ expect(windows.length, 'a wired transcript-window seam MUST report at least one orchestrator iteration').toBeGreaterThan(0);
180
+
181
+ // Independent event-log read for the cross-check (OPTIONAL seam).
182
+ const q = await queryTestEvents(runId);
183
+ const logEventIds = new Set<string>();
184
+ const summarizedRefs = new Set<string>();
185
+ if (q.ok) {
186
+ for (const e of q.events) {
187
+ logEventIds.add(e.eventId);
188
+ if (e.type === 'context.summarized') {
189
+ const ref = stringOf(e.payload['summaryRef']);
190
+ if (ref !== undefined) summarizedRefs.add(ref);
191
+ }
192
+ }
193
+ }
194
+
195
+ for (const { iteration, window } of windows) {
196
+ // 1 — tokenCounter agreement.
197
+ expect(
198
+ window.tokenCounter,
199
+ driver.describe('RFC 0111', `iteration ${iteration}: seam tokenCounter MUST equal the advertised contextBudget.tokenCounter`),
200
+ ).toBe(advertisedCounter);
201
+
202
+ // 2 — the per-turn token bound.
203
+ if (budget !== undefined) {
204
+ expect(
205
+ window.tokenCount,
206
+ driver.describe('RFC 0111', `iteration ${iteration}: tokenCount MUST NOT exceed transcriptTokenBudget`),
207
+ ).toBeLessThanOrEqual(budget);
208
+ }
209
+
210
+ // 3 — internal consistency: every named id is a real persisted event.
211
+ if (q.ok) {
212
+ for (const id of window.eventIds) {
213
+ expect(
214
+ logEventIds.has(id),
215
+ driver.describe('RFC 0111 §"Conformance seam"', `iteration ${iteration}: eventId "${id}" in the seam accounting MUST be a real persisted run event`),
216
+ ).toBe(true);
217
+ }
218
+ }
219
+
220
+ // 4 — recent-tail: ids are unique (no double-count inflating the window).
221
+ const uniqueIds = new Set(window.eventIds);
222
+ expect(
223
+ uniqueIds.size,
224
+ driver.describe('RFC 0111 §"Conformance seam"', `iteration ${iteration}: eventIds MUST be a tail with no repeated entry`),
225
+ ).toBe(window.eventIds.length);
226
+
227
+ // 5 — every summarized range references a recorded context.summarized event.
228
+ if (q.ok) {
229
+ for (const range of window.summarizedRanges) {
230
+ expect(
231
+ summarizedRefs.has(range.summaryRef),
232
+ driver.describe('RFC 0111', `iteration ${iteration}: summarizedRanges summaryRef "${range.summaryRef}" MUST have a matching context.summarized event`),
233
+ ).toBe(true);
234
+ }
235
+ }
236
+ }
237
+
238
+ // keepLastTurns verbatim — a kept turn is fed verbatim, never inside a summarized range.
239
+ const keepLastTurns = numberOf(cb?.summarization?.keepLastTurns);
240
+ if (keepLastTurns !== undefined && keepLastTurns > 0 && windows.length > 0) {
241
+ const last = windows[windows.length - 1].window;
242
+ const summarizedIds = new Set<string>();
243
+ for (const range of last.summarizedRanges) for (const id of range.replacedTurns) summarizedIds.add(id);
244
+ const verbatimTail = last.eventIds.slice(Math.max(0, last.eventIds.length - keepLastTurns));
245
+ for (const id of verbatimTail) {
246
+ expect(
247
+ summarizedIds.has(id),
248
+ driver.describe('RFC 0111', `a kept (verbatim) turn "${id}" MUST NOT appear inside a summarized range`),
249
+ ).toBe(false);
250
+ }
251
+ }
252
+ });
253
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * RFC 0111 — Context Economy: declared summarization is replay-deterministic.
3
+ *
4
+ * A host-produced summary is NONDETERMINISTIC host output that breaks the
5
+ * purity of the transcript-as-event-log-projection, so RFC 0111 governs it
6
+ * exactly like an RFC 0041 nondeterministic envelope: each substitution is
7
+ * recorded as a `context.summarized` event whose `summaryRef` artifact a
8
+ * `:fork mode:replay` MUST REUSE — the host MUST NOT re-summarize and produce
9
+ * a different model-facing transcript (`spec/v1/multi-agent-execution.md`
10
+ * §"Context economy" → "Replay determinism").
11
+ *
12
+ * Capability-gated on `multiAgent.executionModel.contextBudget.summarization.supported`
13
+ * (root-first per RFC 0073) via `behaviorGate`. Drives the multi-turn
14
+ * orchestrator fixture, reads the recorded `context.summarized` events from the
15
+ * run event-log (`/v1/host/sample/test/runs/:runId/events`), then replays the
16
+ * run via `POST /v1/runs/{runId}:fork {mode:"replay"}` and asserts the replayed
17
+ * run re-emits the SAME `context.summarized` records (same `summaryRef` +
18
+ * `replacedTurns`) — i.e. the recorded summary is reused, not regenerated.
19
+ *
20
+ * The event-log seam + replay are both OPTIONAL — the scenario soft-skips when
21
+ * the event-log seam is unwired (`404`), when the host advertises no `replay`
22
+ * mode, or when the run produced no summarization (no `context.summarized`).
23
+ * The RFC defers reference-host implementation; the witness comes from a host
24
+ * that runs real orchestrator turns and summarizes.
25
+ *
26
+ * @see RFCS/0111-context-economy.md
27
+ * @see spec/v1/multi-agent-execution.md §"Context economy (RFC 0111)"
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { driver } from '../lib/driver.js';
32
+ import { pollUntilTerminal } from '../lib/polling.js';
33
+ import { behaviorGate } from '../lib/behavior-gate.js';
34
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
35
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
36
+ import { queryTestEvents, type TestEvent } from '../lib/event-log-query.js';
37
+
38
+ const FIXTURE = 'conformance-context-budget-multiturn';
39
+ const PROFILE = 'openwop-context-summarization';
40
+
41
+ interface SummarizationCap {
42
+ readonly supported?: boolean;
43
+ }
44
+ interface ContextBudgetCap {
45
+ readonly summarization?: SummarizationCap;
46
+ }
47
+ interface ExecutionModelCap {
48
+ readonly contextBudget?: ContextBudgetCap;
49
+ }
50
+ interface MultiAgentCap {
51
+ readonly executionModel?: ExecutionModelCap;
52
+ }
53
+
54
+ // ── cast-free typed accessors (no `as`) ──────────────────────────────────
55
+ function isRecord(v: unknown): v is Record<string, unknown> {
56
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
57
+ }
58
+ function isString(v: unknown): v is string {
59
+ return typeof v === 'string';
60
+ }
61
+ function stringOf(v: unknown): string | undefined {
62
+ return isString(v) ? v : undefined;
63
+ }
64
+ function stringArrayOf(v: unknown): string[] | undefined {
65
+ return Array.isArray(v) && v.every(isString) ? v : undefined;
66
+ }
67
+ function runIdOf(v: unknown): string | undefined {
68
+ return isRecord(v) ? stringOf(v['runId']) : undefined;
69
+ }
70
+ function replayModesOf(v: unknown): string[] {
71
+ if (!isRecord(v)) return [];
72
+ const replay = v['replay'];
73
+ if (!isRecord(replay)) return [];
74
+ return stringArrayOf(replay['modes']) ?? [];
75
+ }
76
+
77
+ /** A summary fingerprint: summaryRef plus the (ordered) replaced-turn ids. */
78
+ function summaryFingerprint(e: TestEvent): string | undefined {
79
+ const ref = stringOf(e.payload['summaryRef']);
80
+ const replaced = stringArrayOf(e.payload['replacedTurns']);
81
+ if (ref === undefined || replaced === undefined) return undefined;
82
+ return `${ref}::${replaced.join(',')}`;
83
+ }
84
+
85
+ function summaryFingerprints(events: readonly TestEvent[]): string[] {
86
+ const out: string[] = [];
87
+ for (const e of events) {
88
+ if (e.type !== 'context.summarized') continue;
89
+ const fp = summaryFingerprint(e);
90
+ expect(fp, 'a context.summarized event MUST carry summaryRef + replacedTurns').toBeDefined();
91
+ if (fp !== undefined) out.push(fp);
92
+ }
93
+ return out.sort();
94
+ }
95
+
96
+ describe('context-summarization-replay (RFC 0111 §"Replay determinism")', () => {
97
+ it('replay reuses the recorded context.summarized summaryRef — never re-summarizes', async () => {
98
+ const ma = await readCapabilityFamily<MultiAgentCap>('multiAgent');
99
+ const summarizationSupported = ma?.executionModel?.contextBudget?.summarization?.supported === true;
100
+ if (!behaviorGate(PROFILE, summarizationSupported)) return;
101
+ if (!isFixtureAdvertised(FIXTURE)) return; // fixture-gated soft-skip
102
+
103
+ // Drive the multi-turn orchestrator run.
104
+ const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
105
+ expect(create.status).toBe(201);
106
+ const sourceRunId = runIdOf(create.json);
107
+ expect(sourceRunId, 'POST /v1/runs MUST return a runId').toBeDefined();
108
+ if (sourceRunId === undefined) return;
109
+ await pollUntilTerminal(sourceRunId);
110
+
111
+ // Read the recorded summarization records (OPTIONAL event-log seam).
112
+ const sourceQ = await queryTestEvents(sourceRunId, { type: 'context.summarized' });
113
+ if (!sourceQ.ok) return; // event-log seam unwired — soft-skip
114
+ const sourceFingerprints = summaryFingerprints(sourceQ.events);
115
+ if (sourceFingerprints.length === 0) {
116
+ // The run did not summarize (budget not exceeded on this host) — nothing
117
+ // to prove about reuse. Honest soft-skip; not a vacuous pass of the MUST.
118
+ // eslint-disable-next-line no-console
119
+ console.warn(`[${PROFILE}] run produced no context.summarized events; replay-reuse leg soft-skipped`);
120
+ return;
121
+ }
122
+
123
+ // Only attempt replay when the host advertises the replay fork mode.
124
+ const wellKnown = await driver.get('/.well-known/openwop');
125
+ if (!replayModesOf(wellKnown.json).includes('replay')) return;
126
+
127
+ const fork = await driver.post(
128
+ `/v1/runs/${encodeURIComponent(sourceRunId)}:fork`,
129
+ { fromSeq: 0, mode: 'replay' },
130
+ );
131
+ if (fork.status === 501 || fork.status === 404) return; // replay not implemented for this run — soft-skip
132
+ expect(
133
+ fork.status,
134
+ driver.describe('rest-endpoints.md POST /v1/runs/{runId}:fork', 'replay fork MUST return 201'),
135
+ ).toBe(201);
136
+ const forkRunId = runIdOf(fork.json);
137
+ expect(forkRunId, 'replay fork MUST return a runId').toBeDefined();
138
+ if (forkRunId === undefined) return;
139
+ await pollUntilTerminal(forkRunId);
140
+
141
+ const forkQ = await queryTestEvents(forkRunId, { type: 'context.summarized' });
142
+ if (!forkQ.ok) return; // event-log seam unwired for the fork — soft-skip
143
+ const forkFingerprints = summaryFingerprints(forkQ.events);
144
+
145
+ // The replay MUST reuse the recorded summaries (same summaryRef + replacedTurns),
146
+ // NOT regenerate them — the direct analogue of RFC 0041 envelope-refusal recovery.
147
+ expect(
148
+ forkFingerprints,
149
+ driver.describe(
150
+ 'RFC 0111 §"Replay determinism"',
151
+ 'a replay fork MUST reuse the recorded context.summarized summaryRef (never re-summarize to a different transcript)',
152
+ ),
153
+ ).toEqual(sourceFingerprints);
154
+ });
155
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Conversation-turn model provenance — `agent.model` (RFC 0109).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies the additive, normative
5
+ * RFC 0109 wire facts on the published schemas:
6
+ *
7
+ * 1. `conversation-turn.schema.json` `agent.model` is an OPTIONAL object that
8
+ * validates a conforming `{ provider, model }`, REQUIRES both fields, and is
9
+ * CLOSED (`additionalProperties: false`) — the SR-1 secret-redaction guard:
10
+ * no credential / endpoint / prompt can ride in the provenance stamp.
11
+ * 2. `agent.model` is OPTIONAL — an agent turn that omits it still validates
12
+ * (additive; pre-RFC-0109 producers + hosts that do not advertise).
13
+ * 3. `capabilities.schema.json` declares the `conversationTurnModelProvenance`
14
+ * block with its `supported` flag, and it is closed (`additionalProperties: false`).
15
+ *
16
+ * The host-side MUST (a host that advertises `supported: true` MUST stamp
17
+ * `agent.model`; one that does NOT advertise MUST omit it) is a behavioral
18
+ * contract gated on `conversationTurnModelProvenance.supported`, landing at the
19
+ * reference-host implementation (RFC 0109 §Conformance — same staging as RFC
20
+ * 0101's non-participant-rejection behavioral leg). This scenario asserts the
21
+ * wire SHAPE; the behavioral leg is gated.
22
+ *
23
+ * Normative references:
24
+ * - RFCS/0109-conversation-turn-model-provenance.md (§Proposal / §Conformance)
25
+ * - RFCS/0005-conversation.md (the conversation primitive this extends)
26
+ * - schemas/conversation-turn.schema.json (agent.model)
27
+ * - schemas/capabilities.schema.json (conversationTurnModelProvenance)
28
+ *
29
+ * @see RFCS/0109-conversation-turn-model-provenance.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 } from '../lib/paths.js';
38
+
39
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
40
+
41
+ function loadSchema(name: string): Record<string, unknown> {
42
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
43
+ }
44
+
45
+ describe('conversation-turn-model-provenance-shape: agent.model on a role:agent turn (RFC 0109 §Proposal, server-free)', () => {
46
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
47
+ addFormats(ajv);
48
+ const turn = ajv.compile(loadSchema('conversation-turn.schema.json'));
49
+
50
+ const agentBase = {
51
+ messageId: 'council-q1:1:agent',
52
+ from: 'host:advisor-cfo',
53
+ content: 'From a cash-runway view I would push the launch one quarter.',
54
+ ts: 1718900000000,
55
+ role: 'agent' as const,
56
+ turnIndex: 1,
57
+ speakerId: 'host:advisor-cfo',
58
+ };
59
+
60
+ it('an agent turn carrying a conforming agent.model { provider, model } validates', () => {
61
+ expect(
62
+ turn({ ...agentBase, agent: { agentId: 'advisor-cfo', model: { provider: 'anthropic', model: 'claude-opus-4-8' } } }),
63
+ why('RFC 0109 §Proposal', "a role:'agent' turn with agent.model { provider, model } MUST validate"),
64
+ ).toBe(true);
65
+ });
66
+
67
+ it('agent.model REQUIRES both provider and model', () => {
68
+ expect(
69
+ turn({ ...agentBase, agent: { model: { provider: 'anthropic' } } }),
70
+ why('RFC 0109 §Proposal', 'agent.model without `model` MUST be rejected'),
71
+ ).toBe(false);
72
+ expect(
73
+ turn({ ...agentBase, agent: { model: { model: 'claude-opus-4-8' } } }),
74
+ why('RFC 0109 §Proposal', 'agent.model without `provider` MUST be rejected'),
75
+ ).toBe(false);
76
+ });
77
+
78
+ it('agent.model is CLOSED — an extra key (a secret/endpoint/prompt) MUST be rejected (the SR-1 guard)', () => {
79
+ expect(
80
+ turn({ ...agentBase, agent: { model: { provider: 'anthropic', model: 'claude-opus-4-8', apiKey: 'sk-secret' } } }),
81
+ why('RFC 0109 §Proposal', 'agent.model MUST forbid extra keys — no credential/endpoint/prompt rides the provenance stamp'),
82
+ ).toBe(false);
83
+ });
84
+
85
+ it('agent.model is OPTIONAL — an agent turn that omits it still validates (additive, back-compat)', () => {
86
+ expect(
87
+ turn({ ...agentBase, agent: { agentId: 'advisor-cfo' } }),
88
+ why('RFC 0109 §Compatibility', 'agent.model is additive — a turn without it MUST still validate'),
89
+ ).toBe(true);
90
+ expect(
91
+ turn(agentBase),
92
+ why('RFC 0109 §Compatibility', 'a turn with no agent object at all MUST still validate'),
93
+ ).toBe(true);
94
+ });
95
+ });
96
+
97
+ describe('conversation-turn-model-provenance-shape: capability advertisement (RFC 0109 §Conformance, server-free)', () => {
98
+ it('capabilities.schema.json declares conversationTurnModelProvenance with supported, closed', () => {
99
+ const caps = loadSchema('capabilities.schema.json');
100
+ const props = caps.properties as Record<string, Record<string, unknown>>;
101
+ const block = props.conversationTurnModelProvenance as
102
+ | { properties?: Record<string, unknown>; required?: string[]; additionalProperties?: boolean }
103
+ | undefined;
104
+ expect(block, why('RFC 0109 §Conformance', 'capabilities.conversationTurnModelProvenance MUST be declared')).toBeDefined();
105
+ expect(block?.properties?.supported, why('RFC 0109 §Conformance', 'conversationTurnModelProvenance.supported MUST be declared')).toBeDefined();
106
+ expect(block?.required, why('RFC 0109 §Conformance', 'supported MUST be required on the block')).toContain('supported');
107
+ expect(block?.additionalProperties, why('RFC 0109 §Conformance', 'the block MUST be closed')).toBe(false);
108
+ });
109
+
110
+ it('the conversationTurnModelProvenance block validates a conforming advertisement and rejects extras', () => {
111
+ const caps = loadSchema('capabilities.schema.json');
112
+ const block = (caps.properties as Record<string, Record<string, unknown>>).conversationTurnModelProvenance;
113
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
114
+ addFormats(ajv);
115
+ const validate = ajv.compile(block);
116
+ expect(validate({ supported: true }), why('RFC 0109 §Conformance', 'a conforming advertisement MUST validate')).toBe(true);
117
+ expect(validate({}), why('RFC 0109 §Conformance', 'supported is required')).toBe(false);
118
+ expect(validate({ supported: true, unexpected: 1 }), why('RFC 0109 §Conformance', 'an extra key MUST be rejected (closed block)')).toBe(false);
119
+ });
120
+ });