@volter/world-core 3.0.166 → 3.0.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/src/changeset.d.ts +9 -0
  2. package/dist/src/changeset.js +5 -2
  3. package/dist/src/completeness.d.ts +36 -0
  4. package/dist/src/completeness.js +46 -0
  5. package/dist/src/derived-core.d.ts +27 -0
  6. package/dist/src/derived-core.js +31 -2
  7. package/dist/src/events.d.ts +6 -1
  8. package/dist/src/events.js +58 -13
  9. package/dist/src/index.d.ts +4 -2
  10. package/dist/src/index.js +3 -2
  11. package/dist/src/openai-wire.js +0 -1
  12. package/dist/src/pack-fetch.d.ts +1 -3
  13. package/dist/src/pack-fetch.js +103 -43
  14. package/dist/src/runtime.d.ts +5 -2
  15. package/dist/src/runtime.js +3 -1
  16. package/dist/src/scenario-consume.d.ts +43 -0
  17. package/dist/src/scenario-consume.js +111 -0
  18. package/dist/src/scenario-serve.d.ts +16 -0
  19. package/dist/src/scenario-serve.js +47 -0
  20. package/dist/src/scenario-timeline.d.ts +131 -0
  21. package/dist/src/scenario-timeline.js +242 -0
  22. package/dist/src/scenario.d.ts +15 -93
  23. package/dist/src/scenario.js +34 -323
  24. package/dist/src/sockets.d.ts +8 -4
  25. package/dist/src/sockets.js +1 -1
  26. package/dist/src/world-clock.d.ts +3 -0
  27. package/dist/src/world-clock.js +14 -9
  28. package/package.json +1 -1
  29. package/src/changeset.ts +13 -3
  30. package/src/completeness.ts +57 -0
  31. package/src/derived-core.ts +41 -2
  32. package/src/events.ts +58 -11
  33. package/src/index.ts +5 -3
  34. package/src/openai-wire.ts +0 -1
  35. package/src/pack-fetch.ts +81 -39
  36. package/src/runtime.ts +5 -2
  37. package/src/scenario-consume.ts +123 -0
  38. package/src/scenario-serve.ts +47 -0
  39. package/src/scenario-timeline.ts +269 -0
  40. package/src/scenario.ts +43 -341
  41. package/src/sockets.ts +5 -5
  42. package/src/world-clock.ts +16 -9
@@ -0,0 +1,242 @@
1
+ // A SCENARIO is a timeline of inputs (ADR 0036; architecture, "Scenarios"): calls, people's actions, decisions, faults
2
+ // and the clock, played at World times after the World's seed, its prefix. Nothing in it answers in a twin's place: a
3
+ // twin consumes a decision where its vendor decides and a fault where it lands, and records both in its own log.
4
+ //
5
+ // This module is the grammar (a strict parse that names the vocabulary on every refusal), the resolution of a
6
+ // document's times, and the twins' read of the scenario a World is playing: the instance's resolved document, read
7
+ // through the World's store at every request as the clock is (TWIN_WORLD_SCENARIO_FILE), so a decision or a fault holds
8
+ // from its `at` with no reboot.
9
+ import { getActiveWorldStore } from "./world-store.js";
10
+ import { worldNow } from "./world-clock.js";
11
+ import { parseExactJson } from "./exact-json.js";
12
+ export const WORLD_SCENARIO_ENV = 'TWIN_WORLD_SCENARIO_FILE';
13
+ const FAULT_KINDS = {
14
+ request: ['rejected', 'failed', 'lost', 'slow', 'drop', 'generation'],
15
+ 'over-time': ['webhook-delay', 'webhook-duplicate', 'webhook-drop', 'lag', 'rate-limit'],
16
+ network: ['reset', 'dns', 'tls', 'latency', 'loss', 'partition'],
17
+ infrastructure: ['oom', 'disk-full', 'crash', 'restart'],
18
+ time: ['jump'],
19
+ };
20
+ /** The layers asked of the machine the app runs on, through its fault door (ADR 0036 §4). */
21
+ export const MACHINE_LAYERS = ['network', 'infrastructure'];
22
+ export class ScenarioTimelineError extends Error {
23
+ }
24
+ const fail = (where, message) => { throw new ScenarioTimelineError(`scenario: ${where}: ${message}`); };
25
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
26
+ const onlyKeys = (value, keys, where) => {
27
+ for (const key of Object.keys(value))
28
+ if (key !== '$comment' && !keys.includes(key))
29
+ fail(where, `unknown key "${key}" (valid: ${keys.join(', ')})`);
30
+ };
31
+ const text = (value, where, name) => (typeof value === 'string' && value.length > 0 ? value : fail(where, `${name} is a non-empty string`));
32
+ const instant = (value, where, name) => {
33
+ const at = text(value, where, name);
34
+ return Number.isNaN(Date.parse(at)) || !/^\d{4}-\d{2}-\d{2}T/.test(at) ? fail(where, `${name} is an ISO-8601 instant, got ${JSON.stringify(at)}`) : new Date(Date.parse(at)).toISOString();
35
+ };
36
+ /** `90s`, `5m`, `12h`, `30d` in milliseconds. */
37
+ export function durationMs(by) {
38
+ const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(by.trim());
39
+ if (!m)
40
+ throw new ScenarioTimelineError(`${JSON.stringify(by)} is not <N>(s|m|h|d)`);
41
+ return Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
42
+ }
43
+ const duration = (value, where, name) => {
44
+ const by = text(value, where, name);
45
+ try {
46
+ durationMs(by);
47
+ }
48
+ catch {
49
+ fail(where, `${name} is a duration (90s, 5m, 12h, 30d), got ${JSON.stringify(by)}`);
50
+ }
51
+ return by;
52
+ };
53
+ const positive = (value, where, name) => (Number.isInteger(value) && value > 0 ? value : fail(where, `${name} is a positive integer`));
54
+ function parseCall(raw, where) {
55
+ if (!isObject(raw))
56
+ return fail(where, 'call is an object { method, url, headers?, body?, as? }');
57
+ onlyKeys(raw, ['method', 'url', 'headers', 'body', 'as'], `${where} call`);
58
+ const method = text(raw.method, where, 'call.method').toUpperCase();
59
+ const url = text(raw.url, where, 'call.url');
60
+ if (!/^https?:\/\//.test(url))
61
+ fail(where, 'call.url is the vendor\'s own absolute URL (https://...)');
62
+ if (raw.headers !== undefined && (!isObject(raw.headers) || Object.values(raw.headers).some((x) => typeof x !== 'string')))
63
+ fail(where, 'call.headers is an object of strings');
64
+ return { method, url, ...(raw.headers ? { headers: raw.headers } : {}), ...('body' in raw ? { body: raw.body } : {}), ...(raw.as !== undefined ? { as: text(raw.as, where, 'call.as') } : {}) };
65
+ }
66
+ function parseAction(raw, where) {
67
+ if (!isObject(raw))
68
+ return fail(where, 'action is an object { person, browser?, run }');
69
+ onlyKeys(raw, ['person', 'browser', 'run'], `${where} action`);
70
+ return { person: text(raw.person, where, 'action.person'), ...(raw.browser !== undefined ? { browser: text(raw.browser, where, 'action.browser') } : {}), run: text(raw.run, where, 'action.run') };
71
+ }
72
+ function parseDecision(raw, where) {
73
+ if (!isObject(raw))
74
+ return fail(where, 'decision is an object { vendor, point, subject?, on?, outcome, once? }');
75
+ onlyKeys(raw, ['vendor', 'point', 'subject', 'on', 'outcome', 'once'], `${where} decision`);
76
+ if (!('outcome' in raw))
77
+ fail(where, 'decision.outcome is required: what the vendor decided');
78
+ if (raw.on !== undefined && !isObject(raw.on))
79
+ fail(where, 'decision.on is an object of the pack\'s turn matchers');
80
+ if (raw.once !== undefined && typeof raw.once !== 'boolean')
81
+ fail(where, 'decision.once is boolean');
82
+ return { vendor: text(raw.vendor, where, 'decision.vendor'), point: text(raw.point, where, 'decision.point'), ...(raw.subject !== undefined ? { subject: text(raw.subject, where, 'decision.subject') } : {}), ...(raw.on !== undefined ? { on: raw.on } : {}), outcome: raw.outcome, ...(raw.once !== undefined ? { once: raw.once } : {}) };
83
+ }
84
+ const FAULT_KEYS = {
85
+ rejected: ['status', 'message', 'errorType', 'metadata', 'retryAfterSeconds'],
86
+ failed: ['status', 'message', 'errorType', 'metadata', 'retryAfterSeconds'],
87
+ lost: ['status', 'message', 'errorType'],
88
+ slow: ['ms'],
89
+ drop: ['ms'],
90
+ generation: ['status', 'message', 'errorType', 'metadata', 'partialText', 'event'],
91
+ 'webhook-delay': ['event', 'by'],
92
+ 'webhook-duplicate': ['event'],
93
+ 'webhook-drop': ['event'],
94
+ lag: ['by'],
95
+ 'rate-limit': ['limit', 'per', 'retryAfterSeconds', 'message'],
96
+ reset: ['target', 'to'], dns: ['target'], tls: ['target', 'to'], latency: ['target', 'ms'], loss: ['target', 'rate'], partition: ['between'],
97
+ oom: ['target'], 'disk-full': ['target', 'volume'], crash: ['target'], restart: ['target'],
98
+ jump: ['by'],
99
+ };
100
+ function parseFault(raw, where) {
101
+ if (!isObject(raw))
102
+ return fail(where, 'fault is an object { layer, kind, ... }');
103
+ const layer = raw.layer;
104
+ if (!(layer in FAULT_KINDS))
105
+ fail(where, `fault.layer is one of ${Object.keys(FAULT_KINDS).join(', ')}, got ${JSON.stringify(raw.layer)}`);
106
+ const kinds = FAULT_KINDS[layer];
107
+ if (!kinds.includes(raw.kind))
108
+ fail(where, `a ${layer} fault's kind is one of ${kinds.join(', ')}, got ${JSON.stringify(raw.kind)}`);
109
+ const kind = raw.kind;
110
+ const vendorLayer = layer === 'request' || layer === 'over-time';
111
+ const placement = vendorLayer ? ['vendor', 'times', 'until', ...(layer === 'request' ? ['operation', 'on', 'point', 'subject'] : ['operation'])] : ['times', 'until'];
112
+ onlyKeys(raw, ['layer', 'kind', ...placement, ...FAULT_KEYS[kind]], `${where} fault (${layer} ${kind})`);
113
+ if (vendorLayer)
114
+ text(raw.vendor, where, 'fault.vendor');
115
+ if (raw.operation !== undefined)
116
+ text(raw.operation, where, 'fault.operation');
117
+ if (raw.on !== undefined && !isObject(raw.on))
118
+ fail(where, 'fault.on is an object of the pack\'s turn matchers');
119
+ if ([raw.operation, raw.on, raw.point].filter((v) => v !== undefined).length > 1)
120
+ fail(where, 'a fault lands on one of operation, on (a model turn) or point');
121
+ if (raw.subject !== undefined && raw.point === undefined)
122
+ fail(where, 'fault.subject keys a fault at a pack\'s point; name the point');
123
+ if (raw.status !== undefined && !(Number.isInteger(raw.status) && raw.status >= 400 && raw.status <= 599))
124
+ fail(where, 'fault.status is an integer 400..599: a fault is a refusal or a failure, never a success');
125
+ if (kind === 'generation' && raw.on === undefined)
126
+ fail(where, 'a generation failure is a model turn\'s: name its `on`');
127
+ if (kind === 'lost' && raw.on !== undefined)
128
+ fail(where, 'a lost answer lands on an operation: name its `operation`');
129
+ if (kind === 'generation' && (typeof raw.message !== 'string' || typeof raw.errorType !== 'string' || !raw.errorType || raw.status === undefined))
130
+ fail(where, 'a generation fault names its status, message and nonempty errorType');
131
+ for (const name of ['message', 'errorType', 'partialText', 'event', 'target', 'to', 'volume'])
132
+ if (raw[name] !== undefined && typeof raw[name] !== 'string')
133
+ fail(where, `fault.${name} is a string`);
134
+ if (raw.metadata !== undefined && !isObject(raw.metadata))
135
+ fail(where, 'fault.metadata is an object');
136
+ if (raw.ms !== undefined && !(Number.isInteger(raw.ms) && raw.ms >= 0 && raw.ms <= 600_000))
137
+ fail(where, 'fault.ms is an integer 0..600000 (milliseconds)');
138
+ if (raw.retryAfterSeconds !== undefined && !(Number.isInteger(raw.retryAfterSeconds) && raw.retryAfterSeconds >= 0))
139
+ fail(where, 'fault.retryAfterSeconds is a non-negative integer');
140
+ for (const name of ['by', 'per'])
141
+ if (raw[name] !== undefined)
142
+ duration(raw[name], where, `fault.${name}`);
143
+ if (['webhook-delay', 'lag', 'jump'].includes(kind))
144
+ duration(raw.by, where, 'fault.by');
145
+ if (MACHINE_LAYERS.includes(layer) && kind !== 'partition' && raw.target === undefined)
146
+ fail(where, `a ${layer} fault names its target, the part of the app it lands on`);
147
+ if (kind === 'rate-limit') {
148
+ positive(raw.limit, where, 'fault.limit');
149
+ duration(raw.per, where, 'fault.per');
150
+ }
151
+ if (raw.rate !== undefined && !(typeof raw.rate === 'number' && raw.rate > 0 && raw.rate <= 1))
152
+ fail(where, 'fault.rate is a fraction in (0, 1]');
153
+ if (kind === 'partition' && !(Array.isArray(raw.between) && raw.between.length === 2 && raw.between.every((s) => typeof s === 'string' && s)))
154
+ fail(where, 'a partition names its two sides: between [a, b]');
155
+ if (raw.times !== undefined)
156
+ positive(raw.times, where, 'fault.times');
157
+ if (raw.until !== undefined)
158
+ instant(raw.until, where, 'fault.until');
159
+ return raw;
160
+ }
161
+ function parseClock(raw, where) {
162
+ if (!isObject(raw))
163
+ return fail(where, 'clock is { advance: <duration> } or { set: <instant> }');
164
+ onlyKeys(raw, ['advance', 'set'], `${where} clock`);
165
+ if (('advance' in raw) === ('set' in raw))
166
+ fail(where, 'clock is one of { advance } or { set }');
167
+ return 'advance' in raw ? { advance: duration(raw.advance, where, 'clock.advance') } : { set: instant(raw.set, where, 'clock.set') };
168
+ }
169
+ const ENTRY_KINDS = ['call', 'action', 'decision', 'fault', 'clock'];
170
+ /** Strict parse of a scenario document: every refusal names where it is and the vocabulary it expected. */
171
+ export function parseScenario(raw) {
172
+ if (!isObject(raw))
173
+ return fail('document', 'a scenario is an object { start, random?, timeline, properties? }');
174
+ onlyKeys(raw, ['start', 'random', 'timeline', 'properties'], 'document');
175
+ const start = instant(raw.start, 'document', 'start');
176
+ if (!Array.isArray(raw.timeline))
177
+ fail('document', 'timeline is an array of entries');
178
+ const timeline = raw.timeline.map((entry, i) => {
179
+ const where = `entry ${i + 1}`;
180
+ if (!isObject(entry))
181
+ return fail(where, `an entry is { at, ${ENTRY_KINDS.join(' | ')} }`);
182
+ onlyKeys(entry, ['at', ...ENTRY_KINDS], where);
183
+ const kinds = ENTRY_KINDS.filter((k) => k in entry);
184
+ if (kinds.length !== 1)
185
+ fail(where, `an entry is exactly one of ${ENTRY_KINDS.join(', ')}`);
186
+ const at = text(entry.at, where, 'at');
187
+ if (Number.isNaN(Date.parse(at)))
188
+ duration(at, where, 'at');
189
+ const kind = kinds[0];
190
+ const parsed = kind === 'call' ? parseCall(entry.call, where) : kind === 'action' ? parseAction(entry.action, where) : kind === 'decision' ? parseDecision(entry.decision, where) : kind === 'fault' ? parseFault(entry.fault, where) : parseClock(entry.clock, where);
191
+ return { at, [kind]: parsed };
192
+ });
193
+ return { start, ...(raw.random !== undefined ? { random: text(raw.random, 'document', 'random') } : {}), timeline, ...(raw.properties !== undefined ? { properties: text(raw.properties, 'document', 'properties') } : {}) };
194
+ }
195
+ /** Every `at` an instant: an instant as written, a duration after `start`. An entry before the one ahead of it, or before
196
+ * `start`, is refused: the clock moves only forward through a timeline. */
197
+ export function resolveScenario(name, doc) {
198
+ const start = Date.parse(doc.start);
199
+ let last = start;
200
+ const timeline = doc.timeline.map((entry, i) => {
201
+ const at = Number.isNaN(Date.parse(entry.at)) || !/^\d{4}-/.test(entry.at) ? start + durationMs(entry.at) : Date.parse(entry.at);
202
+ if (at < last)
203
+ fail(`entry ${i + 1}`, `at ${new Date(at).toISOString()} is before the entry ahead of it (${new Date(last).toISOString()}); a timeline runs forward`);
204
+ last = at;
205
+ return { ...entry, at: new Date(at).toISOString(), index: i + 1 };
206
+ });
207
+ return { name, start: doc.start, random: doc.random ?? name, timeline, ...(doc.properties !== undefined ? { properties: doc.properties } : {}) };
208
+ }
209
+ const kept = new Map();
210
+ /** A JSON file of the World named by an env variable (its scenario, its facts), read through the World's store at each
211
+ * call as the clock is, and parsed again only when it changed; null where the variable or the file is absent. */
212
+ export function worldJsonFile(env) {
213
+ const file = process.env[env];
214
+ if (!file)
215
+ return null;
216
+ const store = getActiveWorldStore();
217
+ const raw = store.readKept ? store.readKept(file) : store.read(file);
218
+ if (raw === null)
219
+ return null;
220
+ const held = kept.get(env);
221
+ if (held?.raw === raw)
222
+ return held.value;
223
+ const value = parseExactJson(raw);
224
+ kept.set(env, { raw, value });
225
+ return value;
226
+ }
227
+ /** The scenario this World is playing (the instance's resolved document), or null when it plays none. */
228
+ function activeScenario() {
229
+ return worldJsonFile(WORLD_SCENARIO_ENV);
230
+ }
231
+ /** The decisions and vendor faults the World's scenario places on one vendor, in timeline order. */
232
+ export function placedOn(vendor) {
233
+ return (activeScenario()?.timeline ?? []).filter((e) => ('decision' in e && e.decision.vendor === vendor) || ('fault' in e && e.fault.vendor === vendor));
234
+ }
235
+ /** The decisions placed for a vendor's point at or before the World's now, in timeline order. */
236
+ export function placedDecisions(vendor, point, now = worldNow()) {
237
+ return placedOn(vendor).flatMap((e) => ('decision' in e && e.decision.point === point && e.at <= now ? [{ ...e.decision, index: e.index, at: e.at }] : []));
238
+ }
239
+ /** The vendor faults placed on a vendor at or before the World's now and not past their `until`, in timeline order. */
240
+ export function placedFaults(vendor, layer, now = worldNow()) {
241
+ return placedOn(vendor).flatMap((e) => ('fault' in e && e.fault.layer === layer && e.at <= now && (e.fault.until === undefined || now < new Date(Date.parse(e.fault.until)).toISOString()) ? [{ ...e.fault, index: e.index, at: e.at }] : []));
242
+ }
@@ -12,8 +12,8 @@ export type PackScenarioAdapter<Req> = {
12
12
  /** The legal `on` keys and their per-request semantics. Pure. */
13
13
  matchers: Record<string, ScenarioMatcher<Req>>;
14
14
  /** Validate `on` CONDITION VALUES at load (key membership is the kernel's; VALUE typing is
15
- * the pack's — "a string nthCall silently never matches" is exactly the misfire strict
16
- * loading exists to prevent). Return an error string to refuse. */
15
+ * the pack's — a mistyped value silently never matching is the misfire strict loading exists
16
+ * to prevent). Return an error string to refuse. */
17
17
  validateOn?: (on: Record<string, unknown>) => string | null;
18
18
  /** Validate a handler's `respond` payload at load; return an error string to refuse.
19
19
  * This is ALSO where a pack refuses success-shaped handlers on stateful routes
@@ -22,22 +22,10 @@ export type PackScenarioAdapter<Req> = {
22
22
  /** Whether the request admits a handler's answer (OpenAI's `tool_choice: 'none'` admits no tool call): a handler whose
23
23
  * answer it does not admit is passed over, and the next that matches answers, else the stub. Absent, every answer. */
24
24
  admits?: (req: Req, respond: unknown) => boolean;
25
- /** The request's text corpus for `textPattern` extractors (packs with text requests). */
26
- text?: (req: Req) => string;
27
- /** Pack-defined extractor KINDS beyond the builtins (feature, textPattern). A pack kind with
28
- * a builtin's name OVERRIDES the builtin (e.g. a richer textPattern with flags/group).
29
- * validate returns an error string to refuse the spec at load; extract runs at serve time
30
- * and throws ScenarioError when the request cannot supply the value. */
31
- extractorKinds?: Record<string, {
32
- validate: (spec: Record<string, unknown>, name: string) => string | null;
33
- extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number;
34
- }>;
35
- /** The per-session scope discriminator; omitted → all requests share one "world" scope. */
36
- scopeKey?: (req: Req) => string;
37
- /** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
38
- * vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
39
25
  /** Opt-in scope check for post-admission model generation failures; absent rejects them. */
40
26
  validateGenerationFault?: (fault: ScenarioGenerationFault, handler: ScenarioHandler) => string | null;
27
+ /** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
28
+ * vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
41
29
  renderFault?: (fault: ScenarioStatusFault, req: Req) => {
42
30
  body: unknown;
43
31
  headers?: Record<string, string>;
@@ -90,49 +78,21 @@ export type ScenarioFaultResult = {
90
78
  body: unknown;
91
79
  };
92
80
  export type ScenarioHandler = {
93
- /** Stable id for status/telemetry; defaults to `handler-<1-based index>`. */
81
+ /** The rule's id: `entry-<n>`, the timeline entry it came from. */
94
82
  id?: string;
95
- /** Conditions — ALL must hold. `{}` matches every request (an ordered catch-all). */
83
+ /** Conditions — ALL must hold. `{}` matches every request. */
96
84
  on: Record<string, unknown>;
97
- /** Pack-realized response content (the pack's realizer builds the faithful envelope).
98
- * Optional only on a handler that carries a `fault` (a `slow` may still carry one; a
99
- * `status` or `drop` serves no content, so it must not). */
85
+ /** The turn's answer, which the pack realizes into the vendor's envelope; absent on a fault. */
100
86
  respond?: unknown;
101
- /** A fault to inject when this handler fires (ScenarioFault). Same once/phase/scope
102
- * semantics as any handler — a mid-story outage is `{on, fault, once}` or `{phase}`. */
87
+ /** The fault that answers the turn instead (ScenarioFault). */
103
88
  fault?: ScenarioFault;
104
- /** Fire at most once per scope. */
105
- once?: boolean;
106
- /** Reserved: "world" (default) | "session" — with "session", once/phase state is per
107
- * scopeKey instead of shared. */
108
- scope?: "world" | "session";
109
- /** Fires only while the scope's phase equals this. Handlers without `phase` fire in any. */
110
- phase?: string;
111
- /** On fire, move the scope's phase — the tiny sequencing primitive that replaces
112
- * linear scripts and nthCall arithmetic. */
113
- advancePhase?: string;
114
89
  };
115
- /** Request-derived values a `respond` payload may reference as "{{name}}" placeholders —
116
- * builtin kinds, or any kind the pack's adapter declares. Payload placeholders support the
117
- * numeric transforms `{{name|min:N}}` / `{{name|max:N}}`; a whole-string placeholder yields
118
- * the TYPED value (numbers stay numbers). */
119
- export type ScenarioExtractorSpec = {
120
- kind: "feature";
121
- feature: string;
122
- } | {
123
- kind: "textPattern";
124
- pattern: string;
125
- as?: "string" | "number";
126
- } | ({
127
- kind: string;
128
- } & Record<string, unknown>);
90
+ /** The ordered rules of one turn. */
129
91
  export type ScenarioDocument = {
130
- extractors?: Record<string, ScenarioExtractorSpec>;
131
92
  handlers: ScenarioHandler[];
132
93
  };
133
94
  export type ScenarioMissRecord = {
134
95
  features: ScenarioFeatures;
135
- phase: string | undefined;
136
96
  };
137
97
  export type ScenarioDecision = {
138
98
  kind: "handler";
@@ -144,54 +104,17 @@ export type ScenarioDecision = {
144
104
  kind: "miss";
145
105
  miss: ScenarioMissRecord;
146
106
  };
147
- export type ScenarioStatus = {
148
- vendor: string;
149
- handlers: Array<{
150
- id: string;
151
- phase?: string;
152
- once?: boolean;
153
- scope?: string;
154
- matches: number;
155
- source: "file" | "use";
156
- }>;
157
- misses: number;
158
- recentMisses: ScenarioMissRecord[];
159
- };
160
107
  export declare class ScenarioError extends Error {
161
108
  }
162
- /** Strict-loud parse of a scenario DOCUMENT (the per-vendor handlers/<vendor>.json content).
163
- * The caller does file IO; this validates. Every refusal names the valid vocabulary. */
164
- export declare function parseScenarioDocument<Req>(raw: unknown, adapter: PackScenarioAdapter<Req>): ScenarioDocument;
165
- /** The engine: pure state machine over registered handlers. One instance per twin server. */
109
+ /** Strict-loud parse of a turn's rules. Every refusal names the valid vocabulary. */
110
+ export declare function parseScenarioDocument<Req>(raw: ScenarioDocument, adapter: PackScenarioAdapter<Req>): ScenarioDocument;
111
+ /** The engine over one turn's rules: pure, the same rules and request giving the same decision. */
166
112
  export declare class ScenarioEngine<Req> {
167
113
  private readonly adapter;
168
- private readonly extractors;
169
- private baseline;
170
- private overrides;
171
- private scopes;
172
- private missCount;
173
- private recentMisses;
174
- constructor(adapter: PackScenarioAdapter<Req>, document?: ScenarioDocument);
175
- /** In-process test overrides: LIFO over the baseline; returns a remover. NOT a runtime
176
- * door — nothing outside this process can reach it, by design. */
177
- use(...handlers: ScenarioHandler[]): () => void;
178
- /** Decide one request. Mutates scope state (calls, once, phase) exactly like serving. */
114
+ private readonly document;
115
+ constructor(adapter: PackScenarioAdapter<Req>, document: ScenarioDocument);
116
+ /** Decide one request: the first rule that matches and whose answer the request admits, else the miss. */
179
117
  next(req: Req): ScenarioDecision;
180
- /** Decide one request AND honor its fault: a `slow` is awaited here and the decision is
181
- * returned for the pack to serve; a `status` returns the rendered refusal to serve as-is; a
182
- * `drop` never resolves within its hold. The one call a realizer makes so every vendor's
183
- * outage reads the same. */
184
- serve(req: Req, signal?: AbortSignal): Promise<ScenarioDecision | {
185
- kind: "fault";
186
- result: ScenarioFaultResult;
187
- ruleId: string;
188
- }>;
189
- /** For GET /twin/scenario — active handlers with match counts, and the recent misses. */
190
- status(): ScenarioStatus;
191
- private scope;
192
- private matches;
193
- private extractValue;
194
- private substitute;
195
118
  }
196
119
  export declare function scenarioFaultResult(fault: ScenarioFault, render?: () => {
197
120
  body: unknown;
@@ -218,5 +141,4 @@ export declare function twinManifest(input: {
218
141
  stateSentence: string;
219
142
  behaviorSentence: string;
220
143
  exampleHandler: ScenarioHandler | null;
221
- engine?: ScenarioEngine<never>;
222
144
  }): Record<string, unknown>;