@openwop/openwop-conformance 2.45.3 → 2.45.5

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,163 @@
1
+ /**
2
+ * The backpressure witness, shared by every major (`major-profile.ts`).
3
+ *
4
+ * A host that advertises `production.backpressure.inflightCap` names a number
5
+ * the suite can saturate: `cap` long-lived requests hold the slots and the
6
+ * next request must be refused `503 service_unavailable` with `Retry-After`.
7
+ *
8
+ * Two halves, so each can be proven alone:
9
+ * - {@link saturate} drives the host and returns what it saw (or why it
10
+ * could not see anything). It asserts nothing.
11
+ * - {@link judge} is pure: observation in, findings out. The scenario turns
12
+ * findings into `expect` calls; the self-test feeds it a conforming and a
13
+ * defective observation and checks the verdicts differ.
14
+ *
15
+ * Run with `--no-file-parallelism`: saturating the cap leaves no headroom for
16
+ * a neighbouring scenario's requests.
17
+ */
18
+
19
+ import { driver, type OpenWOPResponse } from './driver.js';
20
+ import { loadEnv } from './env.js';
21
+ import { readErrorCode } from './error-envelope.js';
22
+ import { isFixtureAdvertised } from './fixtures.js';
23
+ import type { MajorProfile } from './major-profile.js';
24
+
25
+ /** The long-running fixture that holds a slot, and the cheap one that probes. */
26
+ export const HOLD_FIXTURE = 'conformance-delay';
27
+ export const PROBE_FIXTURE = 'conformance-noop';
28
+ /** Long enough that the first slot is still held when the last is filled; the runs are cancelled afterwards. */
29
+ const HOLD_MS = 15_000;
30
+ /** The most streams the suite will hold open. A larger advertised cap is not saturated. */
31
+ export const MAX_SATURABLE_CAP = 64;
32
+ const STREAM_OPEN_MS = 5_000;
33
+ const RETRY_DETAIL_KEYS = ['retryAfter', 'retryAfterMs', 'retryAfterSeconds'] as const;
34
+
35
+ export interface Refusal {
36
+ readonly cap: number;
37
+ readonly status: number;
38
+ readonly code: string | undefined;
39
+ readonly retryAfterHeader: string | null;
40
+ readonly details: Record<string, unknown> | null;
41
+ readonly advertisedRetryAfterSeconds: number | undefined;
42
+ }
43
+ export type Saturation =
44
+ | { readonly kind: 'skip'; readonly disposition: 'inapplicable' | 'blocked'; readonly reason: string }
45
+ | { readonly kind: 'refused'; readonly refusal: Refusal };
46
+
47
+ export interface Finding {
48
+ /** Which rule the finding is about; the scenario maps it to a requirement id. */
49
+ readonly rule: 'refusal' | 'retry-after-advertised' | 'retry-timing';
50
+ readonly ok: boolean;
51
+ readonly doc: string;
52
+ readonly message: string;
53
+ }
54
+
55
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
57
+ try { return await fn(); } catch { return null; }
58
+ }
59
+
60
+ /** Hold `cap` slots with long-lived event streams, send one more request, and report its answer. */
61
+ export async function saturate(profile: MajorProfile, discovery: unknown): Promise<Saturation> {
62
+ const production = profile.family(discovery, 'production');
63
+ if (production === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise production' };
64
+ const bp = production['backpressure'];
65
+ if (!isRecord(bp)) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise production.backpressure' };
66
+ const cap = bp['inflightCap'];
67
+ if (typeof cap !== 'number' || !Number.isInteger(cap) || cap < 1) {
68
+ return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise production.backpressure.inflightCap — there is no cap the suite can saturate' };
69
+ }
70
+ if (cap > MAX_SATURABLE_CAP) return { kind: 'skip', disposition: 'inapplicable', reason: `inflightCap ${cap} is above the ${MAX_SATURABLE_CAP} streams the suite will hold open — not saturated` };
71
+ for (const f of [HOLD_FIXTURE, PROBE_FIXTURE]) {
72
+ if (!isFixtureAdvertised(f)) return { kind: 'skip', disposition: 'inapplicable', reason: `${f} fixture not advertised — the suite cannot hold or probe a slot` };
73
+ }
74
+
75
+ const env = loadEnv();
76
+ const streams: AbortController[] = [];
77
+ const pending: Promise<number | null>[] = [];
78
+ const runIds: string[] = [];
79
+ try {
80
+ for (let i = 0; i < cap; i++) {
81
+ const created = await http(() => driver.post(profile.runsPath, { workflowId: HOLD_FIXTURE, inputs: { delayMs: HOLD_MS } }));
82
+ if (created === null) return { kind: 'skip', disposition: 'blocked', reason: `POST ${profile.runsPath} unreachable while filling slot ${i + 1} of ${cap}` };
83
+ const runId = (created.json as { runId?: unknown } | undefined)?.runId;
84
+ if (created.status !== 201 || typeof runId !== 'string') {
85
+ return { kind: 'skip', disposition: 'blocked', reason: `slot ${i + 1} of ${cap} could not be filled: POST ${profile.runsPath} answered ${created.status} ${readErrorCode(created.json) ?? ''} (already saturated by a parallel file? run with --no-file-parallelism)`.trim() };
86
+ }
87
+ runIds.push(runId);
88
+ const ctl = new AbortController();
89
+ streams.push(ctl);
90
+ // `Accept: text/event-stream` keeps the request in flight; without it a
91
+ // negotiating host answers a one-shot snapshot and the slot drops.
92
+ const stream = fetch(`${env.baseUrl}${profile.runsPath}/${encodeURIComponent(runId)}/events`, {
93
+ headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'text/event-stream', ...profile.versionHeaders },
94
+ signal: ctl.signal,
95
+ }).then((r) => r.status, () => null);
96
+ pending.push(stream);
97
+ // The slot is held once the stream's headers are back, not after a
98
+ // guessed delay. A probe sent before that would convict a host for a slot
99
+ // the suite had not yet taken.
100
+ const opened = await Promise.race([stream, new Promise<'slow'>((r) => setTimeout(() => r('slow'), STREAM_OPEN_MS))]);
101
+ if (opened !== 200) {
102
+ return { kind: 'skip', disposition: 'blocked', reason: `slot ${i + 1} of ${cap} was not held: the event stream ${opened === 'slow' ? `did not open within ${STREAM_OPEN_MS} ms` : opened === null ? 'failed to connect' : `answered ${opened}`}` };
103
+ }
104
+ }
105
+
106
+ const probe = await http(() => driver.post(profile.runsPath, { workflowId: PROBE_FIXTURE }));
107
+ if (probe === null) return { kind: 'skip', disposition: 'blocked', reason: `the cap+1 probe got no response (POST ${profile.runsPath})` };
108
+ const accepted = (probe.json as { runId?: unknown } | undefined)?.runId;
109
+ if (probe.status === 201 && typeof accepted === 'string') runIds.push(accepted);
110
+ const body = isRecord(probe.json) ? probe.json : {};
111
+ const advertised = bp['retryAfterSeconds'];
112
+ return {
113
+ kind: 'refused',
114
+ refusal: {
115
+ cap,
116
+ status: probe.status,
117
+ code: readErrorCode(probe.json) ?? undefined,
118
+ retryAfterHeader: probe.headers.get('retry-after'),
119
+ details: isRecord(body['details']) ? body['details'] : null,
120
+ advertisedRetryAfterSeconds: typeof advertised === 'number' ? advertised : undefined,
121
+ },
122
+ };
123
+ } finally {
124
+ // The held runs outlive their streams. Cancel them so the next file does
125
+ // not meet a saturated host.
126
+ for (const c of streams) c.abort();
127
+ if (runIds.length > 0) {
128
+ const bulk = await http(() => driver.post(`${profile.runsPath}:bulk-cancel`, { runIds }));
129
+ if (bulk === null || bulk.status >= 400) {
130
+ for (const id of runIds) await http(() => driver.post(`${profile.runsPath}/${encodeURIComponent(id)}/cancel`, {}));
131
+ }
132
+ }
133
+ await Promise.allSettled(pending);
134
+ }
135
+ }
136
+
137
+ /** What the refusal must look like at this major. Pure. */
138
+ export function judge(profile: MajorProfile, r: Refusal): Finding[] {
139
+ const home = `${profile.specRoot}/${profile.major === 1 ? 'production-profile.md §Backpressure' : 'conformance.md §Production profile'}`;
140
+ const out: Finding[] = [];
141
+ out.push({ rule: 'refusal', ok: r.status === 503, doc: home, message: `with inflightCap ${r.cap} slots held, the next request MUST be refused 503 (got ${r.status})` });
142
+ out.push({ rule: 'refusal', ok: r.code === 'service_unavailable', doc: home, message: `the refusal MUST carry the code service_unavailable (got ${r.code ?? 'none'})` });
143
+ const header = (r.retryAfterHeader ?? '').trim();
144
+ out.push({ rule: 'refusal', ok: header.length > 0, doc: home, message: 'the 503 MUST set Retry-After' });
145
+
146
+ if (r.advertisedRetryAfterSeconds !== undefined) {
147
+ out.push({
148
+ rule: 'retry-after-advertised',
149
+ ok: /^\d+$/.test(header) && Number(header) === r.advertisedRetryAfterSeconds,
150
+ doc: 'capabilities.schema.json production.backpressure.retryAfterSeconds',
151
+ message: `Retry-After MUST equal the advertised retryAfterSeconds ${r.advertisedRetryAfterSeconds} (got "${header}")`,
152
+ });
153
+ }
154
+
155
+ if (profile.retryTiming === 'header-only') {
156
+ const spelled = RETRY_DETAIL_KEYS.filter((k) => r.details !== null && k in r.details);
157
+ out.push({ rule: 'retry-timing', ok: spelled.length === 0, doc: `${profile.specRoot}/errors.md §Retry timing`, message: `retry timing lives in the Retry-After header only; details MUST NOT carry ${spelled.join(', ') || 'retryAfter*'}` });
158
+ } else {
159
+ const d = r.details?.['retryAfter'];
160
+ out.push({ rule: 'retry-timing', ok: typeof d === 'number' && /^\d+$/.test(header) && d === Number(header), doc: home, message: 'details.retryAfter MUST be numeric and equal the Retry-After header in seconds' });
161
+ }
162
+ return out;
163
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The run-budget witness, shared by every major that gives a budget a run wire
3
+ * surface (`major-profile.ts` `runBudget`).
4
+ *
5
+ * Unaided: the suite creates a run of `conformance-budget-tool-calls` (three
6
+ * scripted tool calls, no model, no seam) with a two-call budget, waits for it
7
+ * to end, and reads its event log through the poll.
8
+ *
9
+ * Two halves, as in `backpressure-witness.ts`: {@link drive} observes and
10
+ * asserts nothing; {@link judge} is pure.
11
+ *
12
+ * The fixture is the opt-in. A host that advertises `budget` without seeding it
13
+ * records `inapplicable`: the family is then unwitnessed at this major, which
14
+ * the coverage report shows, and the host is not denied certification for a
15
+ * fixture it was never asked to seed.
16
+ */
17
+
18
+ import { driver, type OpenWOPResponse } from './driver.js';
19
+ import { readErrorCode } from './error-envelope.js';
20
+ import { isFixtureAdvertised } from './fixtures.js';
21
+ import type { MajorProfile } from './major-profile.js';
22
+
23
+ export const BUDGET_FIXTURE = 'conformance-budget-tool-calls';
24
+ /** The fixture makes 3 calls: 50% is crossed on the first, and the budget cannot cover the third. */
25
+ export const BUDGET_POLICY = { maxToolCalls: 2, thresholdPercent: 50, onExhaustion: 'fail' } as const;
26
+ const DIMENSION = 'toolCalls';
27
+ const CAP_KIND = 'budget-tool-calls';
28
+ /** Keys a `budget.*` or `cap.breached` payload must never carry (`budget-no-pricing-leak`). */
29
+ export const PRICING_KEYS = ['pricing', 'priceTable', 'prices', 'rate', 'rates', 'unitPrice', 'costModel', 'tokenPrice', 'secret', 'apiKey'] as const;
30
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
31
+
32
+ export interface RunEvent { readonly type: string; readonly sequence: number; readonly payload: Record<string, unknown> }
33
+ export interface BudgetObservation {
34
+ readonly enforce: 'hard' | 'advisory' | undefined;
35
+ readonly status: string;
36
+ readonly errorCode: string | undefined;
37
+ readonly events: readonly RunEvent[];
38
+ /** This major's names for the four events the witness reads. */
39
+ readonly names: { readonly reserved: string; readonly threshold: string; readonly exhausted: string; readonly capBreached: string };
40
+ }
41
+ export type BudgetRun =
42
+ | { readonly kind: 'skip'; readonly disposition: 'inapplicable' | 'blocked'; readonly reason: string }
43
+ | { readonly kind: 'refused'; readonly status: number; readonly code: string | undefined }
44
+ | { readonly kind: 'observed'; readonly observation: BudgetObservation };
45
+
46
+ export interface Finding {
47
+ readonly rule: 'create' | 'lifecycle' | 'hard-stop' | 'advisory' | 'content-free';
48
+ readonly ok: boolean;
49
+ readonly doc: string;
50
+ readonly message: string;
51
+ }
52
+
53
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
54
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
55
+ try { return await fn(); } catch { return null; }
56
+ }
57
+
58
+ async function readEvents(profile: MajorProfile, runId: string): Promise<RunEvent[] | null> {
59
+ const out: RunEvent[] = [];
60
+ let after = 0;
61
+ for (let page = 0; page < 50; page++) {
62
+ const res = await http(() => driver.get(`${profile.runsPath}/${encodeURIComponent(runId)}/events/poll?timeout=1&afterSequence=${after}`));
63
+ if (res === null || res.status !== 200) return page === 0 ? null : out;
64
+ const batch = (res.json as { events?: unknown } | undefined)?.events;
65
+ if (!Array.isArray(batch) || batch.length === 0) return out;
66
+ for (const e of batch) {
67
+ if (!isRecord(e) || typeof e['type'] !== 'string' || typeof e['sequence'] !== 'number') continue;
68
+ out.push({ type: e['type'], sequence: e['sequence'], payload: isRecord(e['payload']) ? e['payload'] : {} });
69
+ after = Math.max(after, e['sequence']);
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** Create the budgeted run, wait for it to end, and return its log. */
76
+ export async function drive(profile: MajorProfile, discovery: unknown, timeoutMs = 20_000): Promise<BudgetRun> {
77
+ const family = profile.family(discovery, 'budget');
78
+ if (family === null) return { kind: 'skip', disposition: 'inapplicable', reason: 'the host does not advertise budget' };
79
+ const fragment = profile.runBudget(BUDGET_POLICY);
80
+ if (fragment === null) return { kind: 'skip', disposition: 'inapplicable', reason: `major ${profile.major} gives a run budget no createRun surface` };
81
+ const dims = family['dimensions'];
82
+ if (!Array.isArray(dims) || !dims.includes(DIMENSION)) return { kind: 'skip', disposition: 'inapplicable', reason: `budget.dimensions does not list ${DIMENSION} — the host does not enforce the dimension this witness spends` };
83
+ if (!isFixtureAdvertised(BUDGET_FIXTURE)) return { kind: 'skip', disposition: 'inapplicable', reason: `${BUDGET_FIXTURE} fixture not advertised — the host has not opted in to the unaided budget witness` };
84
+
85
+ const names = {
86
+ reserved: profile.eventType('budget.reserved'),
87
+ threshold: profile.eventType('budget.threshold.crossed'),
88
+ exhausted: profile.eventType('budget.exhausted'),
89
+ capBreached: profile.eventType('cap.breached'),
90
+ };
91
+ if (names.reserved === undefined || names.threshold === undefined || names.exhausted === undefined || names.capBreached === undefined) {
92
+ return { kind: 'skip', disposition: 'blocked', reason: `the event name map for major ${profile.major} is not on disk in this layout — the witness will not guess event names` };
93
+ }
94
+
95
+ const created = await http(() => driver.post(profile.runsPath, { workflowId: BUDGET_FIXTURE, ...fragment }));
96
+ if (created === null) return { kind: 'skip', disposition: 'blocked', reason: `POST ${profile.runsPath} unreachable (fetch failed)` };
97
+ if (created.status === 429) return { kind: 'skip', disposition: 'blocked', reason: `POST ${profile.runsPath} answered 429 — the run budget of the host, not the wire` };
98
+ const runId = (created.json as { runId?: unknown } | undefined)?.runId;
99
+ if (created.status !== 201 || typeof runId !== 'string') return { kind: 'refused', status: created.status, code: readErrorCode(created.json) ?? undefined };
100
+
101
+ const deadline = Date.now() + timeoutMs;
102
+ let snap: Record<string, unknown> | null = null;
103
+ for (;;) {
104
+ const res = await http(() => driver.get(`${profile.runsPath}/${encodeURIComponent(runId)}`));
105
+ snap = res?.status === 200 && isRecord(res.json) ? res.json : null;
106
+ if (snap !== null && TERMINAL.has(String(snap['status']))) break;
107
+ if (Date.now() > deadline) {
108
+ await http(() => driver.post(`${profile.runsPath}/${encodeURIComponent(runId)}/cancel`, {}));
109
+ return { kind: 'skip', disposition: 'blocked', reason: `the budgeted run did not reach a terminal status within ${timeoutMs} ms (last: ${snap === null ? 'unreadable' : String(snap['status'])})` };
110
+ }
111
+ await new Promise((r) => setTimeout(r, 250));
112
+ }
113
+ const events = await readEvents(profile, runId);
114
+ if (events === null) return { kind: 'skip', disposition: 'blocked', reason: `GET ${profile.runsPath}/{runId}/events/poll unreadable — the run ended but its log could not be read` };
115
+
116
+ const enforce = family['enforce'];
117
+ const error = snap['error'];
118
+ return {
119
+ kind: 'observed',
120
+ observation: {
121
+ enforce: enforce === 'hard' || enforce === 'advisory' ? enforce : undefined,
122
+ status: String(snap['status']),
123
+ errorCode: isRecord(error) && typeof error['code'] === 'string' ? error['code'] : undefined,
124
+ events,
125
+ names: names as BudgetObservation['names'],
126
+ },
127
+ };
128
+ }
129
+
130
+ /** What the log must show at this major. Pure. */
131
+ export function judge(profile: MajorProfile, o: BudgetObservation): Finding[] {
132
+ const home = `${profile.specRoot}/runs.md §budget section`;
133
+ const first = (type: string): RunEvent | undefined => o.events.find((e) => e.type === type);
134
+ const reserved = first(o.names.reserved);
135
+ const threshold = first(o.names.threshold);
136
+ const exhausted = first(o.names.exhausted);
137
+ const breach = o.events.find((e) => e.type === o.names.capBreached && typeof e.payload['kind'] === 'string' && (e.payload['kind'] as string).startsWith('budget-'));
138
+ const out: Finding[] = [];
139
+
140
+ out.push({ rule: 'lifecycle', ok: reserved !== undefined, doc: home, message: `a budgeted run MUST emit ${o.names.reserved}` });
141
+ out.push({ rule: 'lifecycle', ok: threshold !== undefined && typeof threshold.payload['percent'] === 'number', doc: home, message: `spending past thresholdPercent MUST emit ${o.names.threshold} with a numeric percent` });
142
+ out.push({ rule: 'lifecycle', ok: exhausted !== undefined, doc: home, message: `a budget that cannot cover the run MUST emit ${o.names.exhausted}` });
143
+ if (reserved !== undefined && threshold !== undefined && exhausted !== undefined) {
144
+ out.push({ rule: 'lifecycle', ok: reserved.sequence < threshold.sequence && threshold.sequence < exhausted.sequence, doc: home, message: `the log MUST order ${o.names.reserved} < ${o.names.threshold} < ${o.names.exhausted}` });
145
+ }
146
+
147
+ if (o.enforce === 'hard') {
148
+ out.push({ rule: 'hard-stop', ok: breach !== undefined && breach.payload['kind'] === CAP_KIND, doc: home, message: `hard exhaustion under onExhaustion: fail MUST emit ${o.names.capBreached} with kind ${CAP_KIND} (got ${breach === undefined ? 'none' : String(breach.payload['kind'])})` });
149
+ if (breach !== undefined && exhausted !== undefined) {
150
+ out.push({ rule: 'hard-stop', ok: exhausted.sequence <= breach.sequence, doc: home, message: `${o.names.capBreached} MUST NOT precede ${o.names.exhausted}` });
151
+ }
152
+ out.push({ rule: 'hard-stop', ok: o.status === 'failed' && o.errorCode === 'budget_exhausted', doc: home, message: `hard exhaustion MUST fail the run budget_exhausted (got ${o.status} ${o.errorCode ?? ''})`.trim() });
153
+ }
154
+ if (o.enforce === 'advisory') {
155
+ out.push({ rule: 'advisory', ok: breach === undefined && o.errorCode !== 'budget_exhausted', doc: home, message: 'an advisory host MUST NOT stop the run' });
156
+ }
157
+
158
+ const leaks: string[] = [];
159
+ for (const e of o.events) {
160
+ if (!e.type.startsWith('budget.') && e !== breach) continue;
161
+ for (const k of PRICING_KEYS) if (k in e.payload) leaks.push(`${e.type}.${k}`);
162
+ }
163
+ out.push({ rule: 'content-free', ok: leaks.length === 0, doc: home, message: `budget.* and cap.breached MUST NOT carry rate cards, unit prices or credentials (found ${leaks.join(', ') || 'none'})` });
164
+ return out;
165
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What differs between protocol majors, as data.
3
+ *
4
+ * A scenario that is "the same requirement at another major" used to be a
5
+ * copy of the earlier file with the paths, event names and envelope rules
6
+ * edited by hand. The differences are few and regular, so they live in one
7
+ * table here. A shared witness (`backpressure-witness.ts`) takes a profile and
8
+ * never names a major; the scenario file for each major is a thin wrapper.
9
+ *
10
+ * **Adding a major** is one row in {@link MAJOR_PROFILES} plus one thin
11
+ * scenario file per ported witness. `majorProfile()` throws on a major with no
12
+ * row, so a missing row is a loud failure and never a silent fall back to an
13
+ * older major's rules.
14
+ *
15
+ * Keep a field here only when a shared witness reads it. A rule one major
16
+ * states and another does not is not a field: the witness asks the profile
17
+ * whether the rule binds (see `retryTiming`), and cites the document that
18
+ * states it.
19
+ */
20
+
21
+ import { capabilityFamily } from './discovery-capabilities.js';
22
+ import { codemapV1toV2 } from './era2-seed.js';
23
+
24
+ export interface MajorProfile {
25
+ readonly major: number;
26
+ /** The run collection, e.g. `/v1/runs` or `/runs`. */
27
+ readonly runsPath: string;
28
+ /** Headers a raw `fetch` must add to speak this major (the driver adds its own). */
29
+ readonly versionHeaders: Readonly<Record<string, string>>;
30
+ /** Where the corpus states this major's rules, for requirement citations. */
31
+ readonly specRoot: string;
32
+ /**
33
+ * Where retry timing lives on an error response. `header-and-details`: the
34
+ * envelope repeats `Retry-After` in `details.retryAfter`. `header-only`: the
35
+ * header is the only place, and `details.retryAfter*` is forbidden.
36
+ */
37
+ readonly retryTiming: 'header-and-details' | 'header-only';
38
+ /** The advertised record for a capability family, or `null` when the host does not advertise it. */
39
+ family(doc: unknown, key: string): Record<string, unknown> | null;
40
+ /**
41
+ * The `createRun` body fragment that carries a run budget policy, or `null`
42
+ * when this major has no run wire surface for one (a witness that needs it
43
+ * is then `inapplicable` at this major).
44
+ */
45
+ runBudget(policy: Readonly<Record<string, unknown>>): Record<string, unknown> | null;
46
+ /**
47
+ * This major's name for an event, given its era-1 name. `undefined` when the
48
+ * mapping is not on disk in this layout: the caller records that as an
49
+ * unread observation and never guesses a name.
50
+ */
51
+ eventType(era1Name: string): string | undefined;
52
+ }
53
+
54
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
55
+
56
+ export const MAJOR_PROFILES: Readonly<Record<number, MajorProfile>> = {
57
+ 1: {
58
+ major: 1,
59
+ runsPath: '/v1/runs',
60
+ versionHeaders: {},
61
+ specRoot: 'spec/v1',
62
+ retryTiming: 'header-and-details',
63
+ // v1: a family is advertised when its record says `supported: true`.
64
+ family: (doc, key) => {
65
+ const rec = capabilityFamily(doc, key);
66
+ return isRecord(rec) && rec['supported'] === true ? rec : null;
67
+ },
68
+ // v1 budgets are driven through a host seam, not `createRun`.
69
+ runBudget: () => null,
70
+ eventType: (era1Name) => era1Name,
71
+ },
72
+ 2: {
73
+ major: 2,
74
+ runsPath: '/runs',
75
+ versionHeaders: { 'OpenWOP-Version': '2.0' },
76
+ specRoot: 'spec/v2/core',
77
+ retryTiming: 'header-only',
78
+ // v2: presence of the record is the advertisement.
79
+ family: (doc, key) => {
80
+ const rec = isRecord(doc) ? doc[key] : undefined;
81
+ return isRecord(rec) ? rec : null;
82
+ },
83
+ runBudget: (policy) => ({ configurable: { version: 1, budget: { ...policy } } }),
84
+ eventType: (era1Name) => codemapV1toV2().get(era1Name),
85
+ },
86
+ };
87
+
88
+ /** The profile for a major. Throws when the table has no row for it. */
89
+ export function majorProfile(major: number): MajorProfile {
90
+ const p = MAJOR_PROFILES[major];
91
+ if (p === undefined) throw new Error(`no MajorProfile for major ${major} — add a row to MAJOR_PROFILES in lib/major-profile.ts`);
92
+ return p;
93
+ }
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import { scenarioFileOfItId } from './requirement-ids.js';
30
- import { PROFILE_FLOOR_SCENARIOS, floorMemberFiles } from './profiles.js';
30
+ import { DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, floorMemberFiles } from './profiles.js';
31
31
  import { targetMajor } from './seams.js';
32
32
  import { PKG_ROOT_PATH } from './paths.js';
33
33
  import { v2ProfileFloorFiles } from './requirement-registry.js';
@@ -450,8 +450,17 @@ export function deriveRequirementDispositions(
450
450
  // `openwop.floor.any.interrupt-` row recorded `blocked` ("no interrupt-*
451
451
  // scenario ran"), which by RFC 0168 §E.1 denied certification to every
452
452
  // profile on every major-2 bundle. The fifth unjoined floor site.
453
+ //
454
+ // Only for a CLAIMED profile's floor (2.45.5), as the any-of rows below have
455
+ // been since 2.45.4, and for the same reason: a floor nobody claims is not a
456
+ // requirement on this host, and its summary row is `blocked` whenever no
457
+ // matching file passed. A major-1 host that does not claim
458
+ // `openwop-interrupts` got an `openwop.floor.any.interrupt-` row it could
459
+ // only satisfy by passing a scenario of a profile it never claimed, and a v3
460
+ // bundle with any `blocked` row certifies nothing (RFC 0168 §E.1).
461
+ const claimed = new Set(claimedProfiles.map((p) => DEPRECATED_PROFILE_ALIASES[p] ?? p));
453
462
  const prefixIds = new Set<string>();
454
- if (!v2FloorsActive()) for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
463
+ if (!v2FloorsActive()) for (const [profile, floor] of Object.entries(PROFILE_FLOOR_SCENARIOS)) if (claimed.has(profile)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
455
464
  for (const prefix of [...prefixIds].sort()) {
456
465
  const matching = [...perFile.entries()].filter(([f]) => f.startsWith(prefix)).map(([, r]) => r);
457
466
  const id = requirementIdForPrefix(prefix);
@@ -478,7 +487,15 @@ export function deriveRequirementDispositions(
478
487
  // no member failing; `inapplicable`/`skipped` members never satisfy it. v1
479
488
  // hand table only, like the prefix groups (the major-2 floors have none).
480
489
  const groups = new Map<string, readonly string[]>();
481
- if (!v2FloorsActive()) for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const g of floor.requiredAnyOf ?? []) groups.set(requirementIdForAnyOf(g), g);
490
+ //
491
+ // Only for a CLAIMED profile's floor (2.45.4). The row is `blocked` when no
492
+ // member was witnessed, and a v1 floor reads `inapplicable` as certifiable, so
493
+ // the row cannot simply say `inapplicable`. But 2.45.3 wrote it for every
494
+ // floor in the table: a host that does not advertise secrets records both
495
+ // members `inapplicable`, got one `blocked` row for a profile it never
496
+ // claimed, and a bundle with any `blocked` row certifies nothing (RFC 0168
497
+ // §E.1). A group nobody claims is not a requirement on this host.
498
+ if (!v2FloorsActive()) for (const [profile, floor] of Object.entries(PROFILE_FLOOR_SCENARIOS)) if (claimed.has(profile)) for (const g of floor.requiredAnyOf ?? []) groups.set(requirementIdForAnyOf(g), g);
482
499
  for (const [id, members] of [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
483
500
  const scenarioId = `anyof:${members.join('|')}`;
484
501
  const matching = members.map((f) => perFile.get(f)).filter((r): r is DerivedRequirement => r !== undefined);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * A scratch host: the smallest HTTP server a shared witness can be proven
3
+ * against, in both directions, inside the suite's own self-tests.
4
+ *
5
+ * Some families are advertised by no reference host, so a new witness for them
6
+ * cannot be sabotage-proved against a real one. A witness that was never seen
7
+ * to fail is not a witness. The scratch host serves a discovery document and a
8
+ * scripted run surface, and each self-test turns on ONE defect to show the
9
+ * witness convicts it.
10
+ *
11
+ * It is a test double, not a host: it executes nothing, and nothing it does is
12
+ * evidence about any implementation. It takes a {@link MajorProfile}, so the
13
+ * same double proves the next major's port.
14
+ */
15
+
16
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
17
+ import type { AddressInfo } from 'node:net';
18
+ import type { MajorProfile } from './major-profile.js';
19
+
20
+ export interface ScriptedEvent { readonly type: string; readonly payload?: Record<string, unknown> | undefined }
21
+ export interface ScriptedRun {
22
+ readonly status: 'completed' | 'failed' | 'running';
23
+ readonly error?: { readonly code: string; readonly message: string };
24
+ readonly events: readonly ScriptedEvent[];
25
+ }
26
+ export interface ScratchRefusal {
27
+ readonly status: number;
28
+ readonly headers?: Readonly<Record<string, string>>;
29
+ readonly body: Record<string, unknown>;
30
+ }
31
+ export interface ScratchOptions {
32
+ readonly profile: MajorProfile;
33
+ /** The discovery document, served as given. */
34
+ readonly discovery: Readonly<Record<string, unknown>>;
35
+ /** What a created run looks like, by workflowId and request body. Default: an empty running run. */
36
+ readonly script?: ((workflowId: string, body: Readonly<Record<string, unknown>>) => ScriptedRun) | undefined;
37
+ /** Concurrent in-flight requests admitted before {@link refusal} answers. Unset: no cap. */
38
+ readonly inflightCap?: number | undefined;
39
+ readonly refusal?: ScratchRefusal | undefined;
40
+ }
41
+
42
+ interface StoredRun { readonly id: string; run: ScriptedRun }
43
+
44
+ export class ScratchHost {
45
+ private server: Server | undefined;
46
+ private readonly runs = new Map<string, StoredRun>();
47
+ private inflight = 0;
48
+ private seq = 0;
49
+ url = '';
50
+
51
+ constructor(private opts: ScratchOptions) {}
52
+
53
+ /** Swap behaviour between cases. The address stays the same, since the driver caches its base URL per file. */
54
+ reconfigure(next: Partial<ScratchOptions>): void {
55
+ this.opts = { ...this.opts, ...next };
56
+ this.runs.clear();
57
+ }
58
+
59
+ async start(): Promise<string> {
60
+ this.server = createServer((req, res) => { void this.handle(req, res); });
61
+ await new Promise<void>((r) => this.server!.listen(0, '127.0.0.1', r));
62
+ this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
63
+ return this.url;
64
+ }
65
+
66
+ async stop(): Promise<void> {
67
+ const s = this.server;
68
+ if (s === undefined) return;
69
+ s.closeAllConnections();
70
+ await new Promise<void>((r) => s.close(() => r()));
71
+ }
72
+
73
+ private send(res: ServerResponse, status: number, body: unknown, headers: Readonly<Record<string, string>> = {}): void {
74
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
75
+ res.end(JSON.stringify(body));
76
+ }
77
+
78
+ private async body(req: IncomingMessage): Promise<Record<string, unknown>> {
79
+ const chunks: Buffer[] = [];
80
+ for await (const c of req) chunks.push(c as Buffer);
81
+ try {
82
+ const v: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
83
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
84
+ } catch { return {}; }
85
+ }
86
+
87
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
88
+ const path = (req.url ?? '/').split('?')[0] ?? '/';
89
+ const runs = this.opts.profile.runsPath;
90
+ // Discovery is never counted against the cap.
91
+ if (req.method === 'GET' && path === '/.well-known/openwop') return this.send(res, 200, this.opts.discovery);
92
+
93
+ const cap = this.opts.inflightCap;
94
+ if (cap !== undefined && this.inflight >= cap) {
95
+ const r = this.opts.refusal ?? { status: 503, headers: { 'retry-after': '1' }, body: { error: 'service_unavailable', message: 'at capacity' } };
96
+ return this.send(res, r.status, r.body, r.headers);
97
+ }
98
+ this.inflight++;
99
+ res.on('close', () => { this.inflight--; });
100
+
101
+ if (req.method === 'POST' && path === runs) {
102
+ const body = await this.body(req);
103
+ const workflowId = String(body['workflowId'] ?? '');
104
+ const id = `scratch/run-${++this.seq}`;
105
+ this.runs.set(id, { id, run: this.opts.script?.(workflowId, body) ?? { status: 'running', events: [] } });
106
+ return this.send(res, 201, { runId: id, status: 'running' });
107
+ }
108
+ if (req.method === 'POST' && path === `${runs}:bulk-cancel`) return this.send(res, 200, { results: [] });
109
+
110
+ const m = new RegExp(`^${runs}/([^/]+)(/events/poll|/events|/cancel)?$`).exec(path);
111
+ const stored = m ? this.runs.get(decodeURIComponent(m[1] as string)) : undefined;
112
+ if (!m || stored === undefined) return this.send(res, 404, { error: 'not_found', message: 'no such route or run' });
113
+ const tail = m[2];
114
+ if (tail === '/cancel') return this.send(res, 200, { status: 'cancelled' });
115
+ if (tail === '/events') {
116
+ // Held open until the client aborts: this is what occupies a slot.
117
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
118
+ res.write(': held\n\n');
119
+ return;
120
+ }
121
+ if (tail === '/events/poll') {
122
+ const after = Number(new URL(req.url ?? '/', 'http://x').searchParams.get('afterSequence') ?? '0');
123
+ const events = stored.run.events
124
+ .map((e, i) => ({ eventId: `e${i + 1}`, runId: stored.id, type: e.type, payload: e.payload ?? {}, sequence: i + 1, schemaVersion: 2, timestamp: new Date(0).toISOString() }))
125
+ .filter((e) => e.sequence > after);
126
+ return this.send(res, 200, { events });
127
+ }
128
+ return this.send(res, 200, { runId: stored.id, workflowId: 'scratch', status: stored.run.status, ...(stored.run.error === undefined ? {} : { error: stored.run.error }) });
129
+ }
130
+ }
@@ -135,12 +135,9 @@ describe('RFC 0148 §A (S6) — the runner derivation', () => {
135
135
  expect(p.disposition).toBe('executed-pass');
136
136
  expect(p.scenarioId).toBe(`${prefix}*`);
137
137
  }
138
- // An any-of group belongs to its own profile's floor (RFC 0229 §E). None of
139
- // its members is in this synthetic report, so its summary row is honestly
140
- // `blocked`, and it is not one of the requirements this floor certifies on.
141
- const foreignAnyOf = d.requirements.filter((r) => r.scenarioId.startsWith('anyof:') && !(floor.requiredAnyOf ?? []).some((g) => g.join('|') === r.scenarioId.slice('anyof:'.length)));
142
- for (const r of foreignAnyOf) expect(r.disposition, r.requirementId).toBe('blocked');
143
- expect(d.totals.executedPass).toBe(d.requirements.length - foreignAnyOf.length);
138
+ // An any-of group's summary row is written only for a claimed profile's floor
139
+ // (2.45.4), so this floor's rows are all there is and every one passed.
140
+ expect(d.totals.executedPass).toBe(d.requirements.length);
144
141
  });
145
142
 
146
143
  it('a floor file that vitest passed but that recorded NOTHING is unclassified when a ledger exists — silence is not a witness', () => {