@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.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -2
- package/dist/lib/scenario-disposition.js +26 -7
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-budget-tool-calls.json +81 -0
- package/fixtures.md +11 -0
- package/package.json +2 -2
- package/requirements.json +84 -14
- package/scenario-majors.json +8 -2
- package/schemas/CORPUS-STAMP.json +9 -9
- package/src/lib/backpressure-witness.ts +163 -0
- package/src/lib/budget-witness.ts +165 -0
- package/src/lib/major-profile.ts +93 -0
- package/src/lib/scenario-disposition.ts +20 -3
- package/src/lib/scratch-host.ts +130 -0
- package/src/scenarios/runner-ledger.test.ts +3 -6
- package/src/scenarios/v2-budget-enforcement.test.ts +94 -0
- package/src/scenarios/v2-production-backpressure.test.ts +74 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
139
|
-
//
|
|
140
|
-
|
|
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', () => {
|