@volter/twin-standard 1.0.161 → 1.0.162

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.
@@ -1,10 +1,10 @@
1
1
  const MESSAGES = 'https://platform.claude.com/docs/en/api/messages';
2
2
  const TOOLS = [{ name: 'lookup', input_schema: { type: 'object', properties: {} } }];
3
3
  export function messagesCases(v) {
4
- const SCRIPT = { handlers: [
5
- { id: 'calls', on: { hasTool: 'lookup' }, respond: v.toolTurn },
6
- { id: 'words', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words' } },
7
- ] };
4
+ const SCRIPT = [
5
+ { on: { hasTool: 'lookup' }, outcome: v.toolTurn },
6
+ { on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
7
+ ];
8
8
  const ask = async (c, extra = {}) => {
9
9
  const { default: Anthropic } = await import('@anthropic-ai/sdk');
10
10
  const client = new Anthropic({ apiKey: c.env[v.key] ?? '', baseURL: v.base(c.host), fetch: c.fetch, maxRetries: 0, timeout: 10_000 });
@@ -41,9 +41,9 @@ export function messagesCases(v) {
41
41
  asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
42
42
  source: 'architecture "The scenario is read from the World\'s store at every turn"',
43
43
  async run(c) {
44
- c.scenario({ handlers: [{ id: 'one', on: { userTextIncludes: 'hello' }, respond: { text: 'first script' } }] });
44
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'first script' } }]);
45
45
  const first = await ask(c).then(text, (e) => `error ${e.message}`);
46
- c.scenario({ handlers: [{ id: 'two', on: { userTextIncludes: 'hello' }, respond: { text: 'second script' } }] });
46
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'second script' } }]);
47
47
  const second = await ask(c).then(text, (e) => `error ${e.message}`);
48
48
  return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
49
49
  },
@@ -22,7 +22,7 @@ export const cases = [
22
22
  source: `${REFERENCE} "a stream of GenerateContentResponse instances"`,
23
23
  async run(c) {
24
24
  return routed(c, async () => {
25
- c.scenario({ handlers: [{ id: 'words', on: { userTextIncludes: 'workshop' }, respond: { text: 'A scripted workshop reply.' } }] });
25
+ c.scenario([{ on: { userTextIncludes: 'workshop' }, outcome: { text: 'A scripted workshop reply.' } }]);
26
26
  const result = await (await chat(c)).generateContentStream('Plan the workshop.');
27
27
  let text = '';
28
28
  for await (const chunk of result.stream)
@@ -40,10 +40,10 @@ export const cases = [
40
40
  async run(c) {
41
41
  return routed(c, async () => {
42
42
  const { SchemaType } = await import('@google/generative-ai');
43
- c.scenario({ handlers: [
44
- { id: 'returned', on: { toolResultFor: 'lookup_company', lastMessageIsToolResult: true }, respond: { text: 'Tidewater renewal is in October.' } },
45
- { id: 'lookup', on: { hasTool: 'lookup_company' }, respond: { functionCalls: [{ name: 'lookup_company', args: { company: 'Tidewater' } }] } },
46
- ] });
43
+ c.scenario([
44
+ { on: { toolResultFor: 'lookup_company', lastMessageIsToolResult: true }, outcome: { text: 'Tidewater renewal is in October.' } },
45
+ { on: { hasTool: 'lookup_company' }, outcome: { functionCalls: [{ name: 'lookup_company', args: { company: 'Tidewater' } }] } },
46
+ ]);
47
47
  const session = (await chat(c)).startChat({ tools: [{ functionDeclarations: [{ name: 'lookup_company', parameters: { type: SchemaType.OBJECT, properties: { company: { type: SchemaType.STRING } }, required: ['company'] } }] }] });
48
48
  const first = await session.sendMessage('Find the company renewal.');
49
49
  const call = first.response.functionCalls()?.[0];
@@ -78,9 +78,9 @@ export const cases = [
78
78
  async run(c) {
79
79
  return routed(c, async () => {
80
80
  const { GoogleGenAI } = await import('@google/genai');
81
- c.scenario({ handlers: [{ id: 'picture', on: { userTextIncludes: 'square illustration' }, respond: {
82
- text: 'A synthetic square illustration.', parts: [{ inlineData: { mimeType: 'image/png', data: PNG } }],
83
- } }] });
81
+ c.scenario([{ on: { userTextIncludes: 'square illustration' }, outcome: {
82
+ text: 'A synthetic square illustration.', parts: [{ inlineData: { mimeType: 'image/png', data: PNG } }],
83
+ } }]);
84
84
  const ai = new GoogleGenAI({ apiKey: c.env.GEMINI_API_KEY ?? '', httpOptions: { baseUrl: `https://${c.host}` } });
85
85
  const response = await ai.models.generateContent({ model: 'gemini-3.1-flash-image', contents: 'Make a square illustration.', config: { responseModalities: ['TEXT', 'IMAGE'] } });
86
86
  const image = response.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData;
@@ -14,8 +14,16 @@ export type ClientContext = {
14
14
  /** an HTTPS agent as a World routes it, for a client that takes an agent (Slack's, Stripe's): its connections reach the
15
15
  * served port, the client's URL and Host unchanged */
16
16
  agent: HttpsAgent;
17
- /** writes the World's scenario file (read at the pack's next turn) */
18
- scenario: (doc: unknown) => void;
17
+ /** plays the World's scenario (read at the pack's next request): the model vendor's turns, each a decision at its
18
+ * `turn` point keyed by `on` (the pack's turn matchers) with `outcome` its answer, holding from the scenario's start;
19
+ * playing another replaces it */
20
+ scenario: (turns: ScriptedTurn[]) => void;
21
+ };
22
+ /** A model vendor's scripted turn: a decision `{ point: 'turn', on, outcome, once? }` (ADR 0036) of the case's vendor. */
23
+ export type ScriptedTurn = {
24
+ on: Record<string, unknown>;
25
+ outcome: unknown;
26
+ once?: boolean;
19
27
  };
20
28
  /** One expectation of the vendor's documented behaviour, driven through its client. */
21
29
  export type ClientCase = {
@@ -59,4 +67,7 @@ export declare function servedHttpFetch(port: number): typeof fetch;
59
67
  /** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
60
68
  /** `raw`: a raw protocol's served port (SMTP's listener), which a connection reaches directly, as a World passes a raw TCP
61
69
  * connection through untouched; the proxy speaks HTTP only. */
62
- export declare function clientContext(port: number, host: string, env: Record<string, string>, scenarioPath: string, raw?: number): ClientContext;
70
+ export declare function clientContext(port: number, host: string, env: Record<string, string>, scenario: {
71
+ vendor: string;
72
+ file: string;
73
+ }, raw?: number): ClientContext;
@@ -8,6 +8,8 @@ import { createServer, request as httpRequest } from 'node:http';
8
8
  import { Agent as HttpsAgent } from 'node:https';
9
9
  import { connect } from 'node:net';
10
10
  import { writeFileSync } from 'node:fs';
11
+ /** Where a case's scenario starts, every entry with it: long before any clock the served pack keeps, so each holds at once. */
12
+ const HELD_FROM = '2000-01-01T00:00:00.000Z';
11
13
  /** The World's proxy in front of a pack served on `served`: it ends the caller's TLS (here, none was begun) and forwards
12
14
  * each request and socket upgrade to the pack with `x-forwarded-proto: https`, the vendor's Host unchanged, as a World's
13
15
  * proxy forwards a caller of the vendor's https host (twin-fetch.ts, twinPublicBase: the links and socket URLs the pack
@@ -249,13 +251,18 @@ export function servedHttpFetch(port) {
249
251
  /** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
250
252
  /** `raw`: a raw protocol's served port (SMTP's listener), which a connection reaches directly, as a World passes a raw TCP
251
253
  * connection through untouched; the proxy speaks HTTP only. */
252
- export function clientContext(port, host, env, scenarioPath, raw) {
254
+ export function clientContext(port, host, env, scenario, raw) {
253
255
  const agent = new (class extends HttpsAgent {
254
256
  createConnection() { return connect(port, '127.0.0.1'); }
255
257
  })({ keepAlive: false });
256
258
  return {
257
259
  host, env, fetch: servedHttpFetch(port), agent,
258
260
  connect: () => connect(raw ?? port, '127.0.0.1'),
259
- scenario: (doc) => writeFileSync(scenarioPath, JSON.stringify(doc)),
261
+ // written as a World resolves a document whose every entry is at `0s`: the cases run in Node, whose type stripping
262
+ // does not load the kernel's source entry (its parameter properties), so they write the resolved form it reads
263
+ scenario: (turns) => writeFileSync(scenario.file, JSON.stringify({
264
+ name: 'client-case', start: HELD_FROM, random: 'client-case',
265
+ timeline: turns.map((t, i) => ({ at: HELD_FROM, decision: { vendor: scenario.vendor, point: 'turn', on: t.on, outcome: t.outcome, ...(t.once === undefined ? {} : { once: t.once }) }, index: i + 1 })),
266
+ })),
260
267
  };
261
268
  }
@@ -1,9 +1,9 @@
1
1
  const CHAT = 'https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create';
2
2
  const TOOLS = [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object', properties: {} } } }];
3
- const SCRIPT = { handlers: [
4
- { id: 'calls', on: { hasTool: 'lookup' }, respond: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
5
- { id: 'words', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words' } },
6
- ] };
3
+ const SCRIPT = [
4
+ { on: { hasTool: 'lookup' }, outcome: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
5
+ { on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
6
+ ];
7
7
  export async function openaiClient(c, v) {
8
8
  const { default: OpenAI } = await import('openai');
9
9
  return new OpenAI({ apiKey: c.env[v.key] ?? '', baseURL: v.base(c.host), fetch: c.fetch, maxRetries: 0, timeout: 10_000 });
@@ -40,9 +40,9 @@ export function chatCases(v) {
40
40
  asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
41
41
  source: 'architecture "The scenario is read from the World\'s store at every turn"',
42
42
  async run(c) {
43
- c.scenario({ handlers: [{ id: 'one', on: { userTextIncludes: 'hello' }, respond: { text: 'first script' } }] });
43
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'first script' } }]);
44
44
  const first = await ask(c).then((a) => a.choices?.[0]?.message?.content, (e) => `error ${e.message}`);
45
- c.scenario({ handlers: [{ id: 'two', on: { userTextIncludes: 'hello' }, respond: { text: 'second script' } }] });
45
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'second script' } }]);
46
46
  const second = await ask(c).then((a) => a.choices?.[0]?.message?.content, (e) => `error ${e.message}`);
47
47
  return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
48
48
  },
@@ -8,10 +8,10 @@ async function client(c) {
8
8
  }
9
9
  const TOOLS = [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object', properties: {} } } }];
10
10
  // a turn that calls the tool wherever one is offered, and one that answers in words
11
- const SCRIPT = { handlers: [
12
- { id: 'calls', on: { hasTool: 'lookup' }, respond: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
13
- { id: 'words', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words' } },
14
- ] };
11
+ const SCRIPT = [
12
+ { on: { hasTool: 'lookup' }, outcome: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
13
+ { on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
14
+ ];
15
15
  /** A Realtime session through the client (openai-node's server-side Realtime client, on the ws library, as a Node
16
16
  * application opens it), until its response is done: its first event and the output items' types, or what went wrong. */
17
17
  async function realtimeTurn(c, toolChoice) {
@@ -7,7 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  // The vendor's client cases, run in Node as an application runs its SDK: `node run.ts <job.json>`. The job names the
10
- // vendor, the served port, the host, the issued credentials and the scenario file; each case's failures are printed as
10
+ // vendor, the served port, the host, the issued credentials and the World's scenario file; each case's failures are printed as
11
11
  // one JSON line, `{ id, asks, failures }`.
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { createRequire } from 'node:module';
@@ -28,7 +28,7 @@ try {
28
28
  const facts = JSON.parse(readFileSync(job.packFacts, 'utf8')).packs[job.vendor];
29
29
  const keys = [...new Set((facts.hosts ?? []).map((rule) => rule.key ?? job.vendor))];
30
30
  require('@volter/world-core/inject').install(Object.fromEntries(keys.map((key) => [key, `http://127.0.0.1:${proxy.port}`])));
31
- const context = clientContext(proxy.port, job.host, job.env, job.scenarioPath, job.raw ? job.port : undefined);
31
+ const context = clientContext(proxy.port, job.host, job.env, { vendor: job.vendor, file: job.scenarioFile }, job.raw ? job.port : undefined);
32
32
  for (const c of cases) {
33
33
  const failures = await Promise.race([
34
34
  c.run(context).catch((e) => [`the case threw: ${e.message}`]),
@@ -7,7 +7,7 @@ export const cases = [
7
7
  asks: "a reasoning turn streamed through the SDK's Responses stream completes with its reasoning item and its message",
8
8
  source: 'https://developers.openai.com/api/reference/resources/responses/streaming-events "response.reasoning_summary_part.added"',
9
9
  async run(c) {
10
- c.scenario({ handlers: [{ id: 'reasons', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words', reasoning: 'thinking it through' } }] });
10
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words', reasoning: 'thinking it through' } }]);
11
11
  const client = await openaiClient(c, XAI);
12
12
  try {
13
13
  const stream = client.responses.stream({ model: XAI.model, input: 'hello', reasoning: { summary: 'auto' } });
@@ -34,6 +34,7 @@ import { fileURLToPath } from 'node:url';
34
34
  import { parseEnv } from 'node:util';
35
35
  import { openWebSocket, readHttpResponse, servedHttpFetch, worldProxy } from "./clients/harness.js";
36
36
  import { servePack as serve } from "./walk/serve-pack.js";
37
+ import { playScenario, resolvedScenario } from "./walk/world-scenario.js";
37
38
  import { walkPackDirectory } from "./walk/pack-directory.js";
38
39
  import { formBody, sigv4Headers } from "./walk/journey-kit.js";
39
40
  import { consumerClientProof, consumerRecipe, runConsumerCommand } from "./consumer.js";
@@ -479,7 +480,7 @@ async function consumerAuthorization(pack, scratch, root, server, descriptor, ou
479
480
  }
480
481
  }
481
482
  /** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
482
- * runs it: once plainly and once with a scenario loaded (an empty one, as a World that scripts nothing loads it). Each
483
+ * runs it: once plainly and once with a scenario played (an empty one, as a World that places nothing plays it). Each
483
484
  * answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
484
485
  * bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
485
486
  * refresh list a fresh account can read (one with no parameter in its path) succeeds or remains unread under the
@@ -521,11 +522,12 @@ async function surfaceSwept(pack, scratch, runtime, consumer) {
521
522
  return { failures: out, note: 'no unit serves an operation on a path of its own' };
522
523
  const tally = { calls: 0, ok: 0, refused: 0, unread: 0, removed: 0 };
523
524
  const replayedHeaders = new Set();
524
- const scenarioPath = join(scratch, 'surface-scenario.json');
525
- writeFileSync(scenarioPath, '{"handlers":[]}');
525
+ // the scenario mode plays an empty timeline as the World's, as a World that places nothing on the vendor plays it
526
+ const empty = resolvedScenario('surface', { start: '2000-01-01T00:00:00.000Z', timeline: [] });
526
527
  for (const mode of ['plain', 'scenario']) {
527
528
  const root = join(scratch, `surface-${mode}`);
528
- const server = await serve(pack, { root, ...(mode === 'scenario' ? { scenarioPath } : {}) });
529
+ const restoreScenario = playScenario(join(scratch, 'surface-scenario.json'), mode === 'scenario' ? empty : undefined);
530
+ const server = await serve(pack, { root }).catch((error) => { restoreScenario(); throw error; });
529
531
  const http = servedHttpFetch(server.inspectPort ?? server.port);
530
532
  const send = async (request) => {
531
533
  await server.managedDatabaseUrl?.();
@@ -768,7 +770,12 @@ async function surfaceSwept(pack, scratch, runtime, consumer) {
768
770
  }
769
771
  }
770
772
  finally {
771
- await server.stop();
773
+ try {
774
+ await server.stop();
775
+ }
776
+ finally {
777
+ restoreScenario();
778
+ }
772
779
  }
773
780
  }
774
781
  // one defect repeated over a surface reads as one: the first 40 findings, and how many more
@@ -805,7 +812,7 @@ function lineConnection(port) {
805
812
  };
806
813
  }
807
814
  /** The life walked as an application calls the pack: its server.ts served as a World runs it (boot id set, the World's
808
- * root and the life's scenario its options), every step over HTTP with the vendor's host, every check of the walk held
815
+ * root its option, the life's scenario played as the World's), every step over HTTP with the vendor's host, every check of the walk held
809
816
  * (status, expectations, captures, SHAPE, LEAK over answers, events and frames). Slack's events once carried the
810
817
  * kernel's updatedAt; a request the served pack does not answer in 10 s fails its step. */
811
818
  async function lifeServed(pack) {
@@ -814,8 +821,8 @@ async function lifeServed(pack) {
814
821
  return ['journeys/customer-life.json is missing'];
815
822
  const { inMemoryWorld, usePacks, walk } = await import("./walk/behavior-journey.js");
816
823
  usePacks(dirname(pack), { dependenciesFrom: pack });
817
- const served = async (root, options) => {
818
- const server = await serve(pack, { root, ...options });
824
+ const served = async (root) => {
825
+ const server = await serve(pack, { root });
819
826
  const port = server.inspectPort ?? server.port;
820
827
  const http = servedHttpFetch(port);
821
828
  return {
@@ -868,9 +875,11 @@ async function clientCases(pack, scratch, consumer) {
868
875
  const host = descriptor?.hosts?.flatMap((h) => 'host' in h && h.exclude !== true ? [h.host] : [])[0] ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
869
876
  if (!host)
870
877
  return { failures: ['the descriptor names no host for its client to address'] };
871
- const scenarioPath = join(scratch, 'client-scenario.json');
872
- writeFileSync(scenarioPath, '{"handlers":[]}');
873
- const server = await serve(pack, { root: join(scratch, 'client-world'), scenarioPath });
878
+ // the World's scenario, empty until a case plays the turns it scripts (the harness writes the file this process's pack
879
+ // reads at each request)
880
+ const scenarioFile = join(scratch, 'client-scenario.json');
881
+ const restoreScenario = playScenario(scenarioFile, resolvedScenario('client-case', { start: '2000-01-01T00:00:00.000Z', timeline: [] }));
882
+ const server = await serve(pack, { root: join(scratch, 'client-world') }).catch((error) => { restoreScenario(); throw error; });
874
883
  try {
875
884
  // the World's credentials: what the pack's credential door issues, by the variable a World sets each in
876
885
  const issued = {};
@@ -890,7 +899,7 @@ async function clientCases(pack, scratch, consumer) {
890
899
  packFacts = join(scratch, 'pack-facts.json');
891
900
  writeFileSync(packFacts, renderPackFacts(compiled));
892
901
  }
893
- writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued, scenarioPath, packFacts, ...(descriptor?.hostsNone ? { raw: true } : {}) }));
902
+ writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued, scenarioFile, packFacts, ...(descriptor?.hostsNone ? { raw: true } : {}) }));
894
903
  // Node, as an application runs its SDK (asynchronous: this process serves the pack meanwhile)
895
904
  const out = await new Promise((resolve) => {
896
905
  const child = spawn('node', ['--experimental-strip-types', '--no-warnings', join(clients, `run${extension}`), job], { stdio: ['ignore', 'pipe', 'pipe'] });
@@ -907,7 +916,12 @@ async function clientCases(pack, scratch, consumer) {
907
916
  return { failures: results.flatMap((r) => r.failures.map((f) => `${r.id} (${r.asks}): ${f}`)), note: `${results.length} case(s) through the vendor's client` };
908
917
  }
909
918
  finally {
910
- await server.stop();
919
+ try {
920
+ await server.stop();
921
+ }
922
+ finally {
923
+ restoreScenario();
924
+ }
911
925
  }
912
926
  }
913
927
  /** The gate over every unit of the pack: the form section of the grade, which holds the rules an author's code keeps
@@ -372,7 +372,7 @@ export function cliFile(vendor, live = false) {
372
372
  const Name = pascal(vendor);
373
373
  const ingest = live ? `// the RTMP ingest's side port, which the World allocates (the manifest's sidePorts)\nconst ingestPort = Number(optionValue(rest, '--ingest-port', '0')) || undefined;\n` : '';
374
374
  const served = live ? ` process.stdout.write(s.ingestPort ? \`${vendor} twin ingest at rtmp://127.0.0.1:\${s.ingestPort}\\n\` : '${vendor} twin: no RTMP ingest\\n');\n` : '';
375
- return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the selected twin's private Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n// the World's scenario file, for a model vendor's turns\nconst scenarioPath = optionValue(rest, '--scenario') || undefined;\n${ingest}\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {})${live ? ', ...(ingestPort ? { ingestPort } : {})' : ''} });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n${served} await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]${live ? ' [--ingest-port N]' : ''}\\n');\n}\n`;
375
+ return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the selected twin's private Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n${ingest}\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {})${live ? ', ...(ingestPort ? { ingestPort } : {})' : ''} });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n${served} await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL]${live ? ' [--ingest-port N]' : ''}\\n');\n}\n`;
376
376
  }
377
377
  /** A vendor id as a name in code: `npm-registry` is `NpmRegistry`. */
378
378
  const pascal = (vendor) => vendor.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
@@ -42,11 +42,11 @@ export type StepHooks = {
42
42
  before?: (step: JourneyStep | LineStep | GitStep | RespStep, path: string, body: unknown) => void;
43
43
  after?: (step: JourneyStep | LineStep | GitStep | RespStep, path: string, answer: StepAnswer, failures: Failure[]) => void;
44
44
  };
45
- /** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root and scenario its options):
45
+ /** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root its option, its scenario
46
+ * the World's file):
46
47
  * the walk's requests go to it over HTTP, the vendor's host in their Host header, instead of to the fetch in process;
47
48
  * its sockets are WebSockets to that port, and a line connection (`stream`) is a TCP connection to its protocol port. */
48
49
  export type ServedTwin = (root: string, options: {
49
- scenarioPath?: string;
50
50
  application?: import('@volter/world-core').PackFetchOptions['application'];
51
51
  }) => Promise<{
52
52
  scheme?: 'http' | 'https';
@@ -123,6 +123,7 @@ export function inMemoryWorld(label) {
123
123
  };
124
124
  }
125
125
  import { vendorAnswerViews, vendorFetch as vendorFetchFrom, vendorStream } from "./vendor-fetch.js";
126
+ import { playScenario, resolvedScenario } from "./world-scenario.js";
126
127
  import { gitClone, gitPush } from "./git-client.js";
127
128
  /** The type a step's body goes with when it names none, as fetch labels it: JSON for a value or a JSON text, plain text
128
129
  * for any other string (an upload's bytes). */
@@ -287,14 +288,21 @@ export async function walk(journey, root, collect, opts) {
287
288
  const ownKeys = routingKeys(journey.vendor);
288
289
  /** The companion that serves a step's URL: one whose host rules claim it where the life's own vendor's do not. */
289
290
  const companionOf = (url) => (claims(ownKeys, url) ? undefined : companions.find((c) => claims(c.keys, url)));
290
- // the life's scenario is the World's: written into the walk's World store, which the pack reads it from as a World's
291
- // handlers/<vendor>.json is read
292
- const scenarioPath = journey.scenario ? join(directoryFor(journey.vendor), 'journeys', journey.scenario) : undefined;
293
- if (scenarioPath)
294
- getActiveWorldStore().write(scenarioPath, readFileSync(scenarioPath, 'utf8'));
291
+ // the life's scenario is the World's: resolved here, and played (written beside the walk's root in its World store and
292
+ // named by TWIN_WORLD_SCENARIO_FILE, which every pack of the walk reads at each request as a World's twins do) from its
293
+ // first step to its end; a life naming none plays none, whatever World the walk itself runs in
294
+ let scenario;
295
+ try {
296
+ if (journey.scenario)
297
+ scenario = resolvedScenario(journey.scenario, parseExactJson(readFileSync(join(directoryFor(journey.vendor), 'journeys', journey.scenario), 'utf8')));
298
+ }
299
+ catch (error) {
300
+ collect.push({ step: '(setup)', beat: 'SCENARIO', detail: `journeys/${journey.scenario}: ${error.message}` });
301
+ return {};
302
+ }
295
303
  const shape = await shapeOf(journey.vendor);
296
- const served = opts.served ? await opts.served(root, { ...(scenarioPath ? { scenarioPath } : {}), application }) : undefined;
297
- const twin = served ? served.fetch : factory(root, { ...(scenarioPath ? { scenarioPath } : {}), application });
304
+ const served = opts.served ? await opts.served(root, { application }) : undefined;
305
+ const twin = served ? served.fetch : factory(root, { application });
298
306
  const host = journey.host ?? 'twin.local';
299
307
  const origin = `${served?.scheme ?? 'http'}://${host}`;
300
308
  // a capture's JSON value where it is not text (a number, a boolean, an object such as database-js's `session`)
@@ -1046,6 +1054,7 @@ export async function walk(journey, root, collect, opts) {
1046
1054
  ...ownKeys.map((key) => [key, twin]),
1047
1055
  ]));
1048
1056
  const previousMailServers = setInProcessMailServers(mailServers);
1057
+ const restoreScenario = playScenario(`${root}.scenario.json`, scenario);
1049
1058
  try {
1050
1059
  for (const step of journey.steps) {
1051
1060
  await settleEventDeliveries(root);
@@ -1380,6 +1389,7 @@ export async function walk(journey, root, collect, opts) {
1380
1389
  finally {
1381
1390
  if (clockFile && startedAt !== undefined)
1382
1391
  store.write(clockFile, startedAt);
1392
+ restoreScenario();
1383
1393
  }
1384
1394
  }
1385
1395
  }
@@ -193,8 +193,9 @@ export type Journey = {
193
193
  /** The instant the story begins, where it is before the World's day one (2026-01-01): an account whose story starts
194
194
  * while an API it used still made what it no longer makes. Its waits move the clock on from there. */
195
195
  startsAt?: string;
196
- /** The World's scenario for a generative pack's turns (a file beside the life, `journeys/<name>.json`, as a World
197
- * loads `handlers/<vendor>.json`): the walk loads it, so the scripted turns and the pack's scenario code are walked. */
196
+ /** The World's scenario the life is walked under (a scenario document beside it, `journeys/<name>.json`: ADR 0036,
197
+ * `{ start, timeline }`): the walk plays it as the World's, so the decisions and faults it places, and the pack's code
198
+ * that consumes them, are walked. */
198
199
  scenario?: string;
199
200
  /** The packs of the same catalog, by vendor, that the vendor calls as its own behaviour (`ctx.vendorFetch`: a sign-in
200
201
  * trading a code at its provider): the walk loads each over the walk's own World and root, the vendor's calls reach
@@ -7,5 +7,4 @@ export type ServedPack = {
7
7
  } & Omit<ManagedDatabaseWalk, 'stopDatabase'>;
8
8
  export declare function servePack(pack: string, options: {
9
9
  root?: string;
10
- scenarioPath?: string;
11
10
  }): Promise<ServedPack>;
@@ -2,10 +2,9 @@ import { type ManagedDatabaseWalk } from './walk-database.js';
2
2
  import { type TwinStream } from '@volter/world-core/runtime';
3
3
  import type { PackFetchOptions } from '@volter/world-core';
4
4
  type ApplicationStandIn = NonNullable<PackFetchOptions['application']>;
5
- /** A pack's fetch over a World's root; a walk whose life names a scenario loads it as the World's (`scenarioPath`), and a
5
+ /** A pack's fetch over a World's root (the World's scenario it reads itself, TWIN_WORLD_SCENARIO_FILE), and a
6
6
  * life's application answers stand in for the application the vendor asks (`application`, this fetch's alone). */
7
7
  export type VendorFetchFactory = ((root: string, options?: {
8
- scenarioPath?: string;
9
8
  application?: ApplicationStandIn;
10
9
  }) => ((request: Request) => Response | Promise<Response>) & ManagedDatabaseWalk) & {
11
10
  managedDatabaseDoor?: string;
@@ -0,0 +1,8 @@
1
+ import { type ResolvedScenario } from '@volter/world-core';
2
+ export type { ResolvedScenario };
3
+ /** A scenario document as a World plays it; one the grammar refuses throws its ScenarioTimelineError, which names the
4
+ * entry and the vocabulary it expected. */
5
+ export declare function resolvedScenario(name: string, document: unknown): ResolvedScenario;
6
+ /** Play `scenario` as the World's at `file` until the returned restore, which puts the variable back as it was. Playing
7
+ * none clears the variable, so what the standard serves inside a World plays nothing of that World's. */
8
+ export declare function playScenario(file: string, scenario: ResolvedScenario | undefined): () => void;
@@ -0,0 +1,26 @@
1
+ // The World's scenario as the standard plays it for a walk or a served pack (ADR 0036; architecture, "Scenarios"): the
2
+ // document parsed and resolved as a World resolves it, written through the active World store, and named by
3
+ // TWIN_WORLD_SCENARIO_FILE, where every twin in this process reads it at each request as it reads the clock. A pack takes
4
+ // no scenario option: what it plays is the World's one scenario, so the standard sets the World's.
5
+ import { parseScenario, resolveScenario, WORLD_SCENARIO_ENV } from '@volter/world-core';
6
+ import { getActiveWorldStore } from '@volter/world-core/runtime';
7
+ /** A scenario document as a World plays it; one the grammar refuses throws its ScenarioTimelineError, which names the
8
+ * entry and the vocabulary it expected. */
9
+ export function resolvedScenario(name, document) {
10
+ return resolveScenario(name, parseScenario(document));
11
+ }
12
+ /** Play `scenario` as the World's at `file` until the returned restore, which puts the variable back as it was. Playing
13
+ * none clears the variable, so what the standard serves inside a World plays nothing of that World's. */
14
+ export function playScenario(file, scenario) {
15
+ if (scenario)
16
+ getActiveWorldStore().write(file, JSON.stringify(scenario));
17
+ const previous = process.env[WORLD_SCENARIO_ENV];
18
+ if (scenario)
19
+ process.env[WORLD_SCENARIO_ENV] = file;
20
+ else
21
+ delete process.env[WORLD_SCENARIO_ENV];
22
+ return () => { if (previous === undefined)
23
+ delete process.env[WORLD_SCENARIO_ENV];
24
+ else
25
+ process.env[WORLD_SCENARIO_ENV] = previous; };
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-standard",
3
- "version": "1.0.161",
3
+ "version": "1.0.162",
4
4
  "description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
5
5
  "keywords": [
6
6
  "twin",
@@ -77,8 +77,8 @@
77
77
  "yaml": "2.9.0"
78
78
  },
79
79
  "peerDependencies": {
80
- "@volter/world-core": "3.0.166",
81
- "@volter/world-runtime": "^3.0.191",
80
+ "@volter/world-core": "3.0.167",
81
+ "@volter/world-runtime": "^3.0.192",
82
82
  "playwright": "^1.63.0"
83
83
  },
84
84
  "peerDependenciesMeta": {
@@ -11,10 +11,10 @@ const TOOLS = [{ name: 'lookup', input_schema: { type: 'object' as const, proper
11
11
  export type MessagesVendor = { key: string; base: (host: string) => string; model: string; toolTurn: Record<string, unknown> };
12
12
 
13
13
  export function messagesCases(v: MessagesVendor): ClientCase[] {
14
- const SCRIPT = { handlers: [
15
- { id: 'calls', on: { hasTool: 'lookup' }, respond: v.toolTurn },
16
- { id: 'words', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words' } },
17
- ] };
14
+ const SCRIPT = [
15
+ { on: { hasTool: 'lookup' }, outcome: v.toolTurn },
16
+ { on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
17
+ ];
18
18
  const ask = async (c: ClientContext, extra: Record<string, unknown> = {}) => {
19
19
  const { default: Anthropic } = await import('@anthropic-ai/sdk');
20
20
  const client = new Anthropic({ apiKey: c.env[v.key] ?? '', baseURL: v.base(c.host), fetch: c.fetch, maxRetries: 0, timeout: 10_000 });
@@ -49,9 +49,9 @@ export function messagesCases(v: MessagesVendor): ClientCase[] {
49
49
  asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
50
50
  source: 'architecture "The scenario is read from the World\'s store at every turn"',
51
51
  async run(c) {
52
- c.scenario({ handlers: [{ id: 'one', on: { userTextIncludes: 'hello' }, respond: { text: 'first script' } }] });
52
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'first script' } }]);
53
53
  const first = await ask(c).then(text, (e: Error) => `error ${e.message}`);
54
- c.scenario({ handlers: [{ id: 'two', on: { userTextIncludes: 'hello' }, respond: { text: 'second script' } }] });
54
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'second script' } }]);
55
55
  const second = await ask(c).then(text, (e: Error) => `error ${e.message}`);
56
56
  return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
57
57
  },
@@ -25,7 +25,7 @@ export const cases: ClientCase[] = [
25
25
  source: `${REFERENCE} "a stream of GenerateContentResponse instances"`,
26
26
  async run(c) {
27
27
  return routed(c, async () => {
28
- c.scenario({ handlers: [{ id: 'words', on: { userTextIncludes: 'workshop' }, respond: { text: 'A scripted workshop reply.' } }] });
28
+ c.scenario([{ on: { userTextIncludes: 'workshop' }, outcome: { text: 'A scripted workshop reply.' } }]);
29
29
  const result = await (await chat(c)).generateContentStream('Plan the workshop.');
30
30
  let text = '';
31
31
  for await (const chunk of result.stream) text += chunk.text();
@@ -42,10 +42,10 @@ export const cases: ClientCase[] = [
42
42
  async run(c) {
43
43
  return routed(c, async () => {
44
44
  const { SchemaType } = await import('@google/generative-ai');
45
- c.scenario({ handlers: [
46
- { id: 'returned', on: { toolResultFor: 'lookup_company', lastMessageIsToolResult: true }, respond: { text: 'Tidewater renewal is in October.' } },
47
- { id: 'lookup', on: { hasTool: 'lookup_company' }, respond: { functionCalls: [{ name: 'lookup_company', args: { company: 'Tidewater' } }] } },
48
- ] });
45
+ c.scenario([
46
+ { on: { toolResultFor: 'lookup_company', lastMessageIsToolResult: true }, outcome: { text: 'Tidewater renewal is in October.' } },
47
+ { on: { hasTool: 'lookup_company' }, outcome: { functionCalls: [{ name: 'lookup_company', args: { company: 'Tidewater' } }] } },
48
+ ]);
49
49
  const session = (await chat(c)).startChat({ tools: [{ functionDeclarations: [{ name: 'lookup_company', parameters: { type: SchemaType.OBJECT, properties: { company: { type: SchemaType.STRING } }, required: ['company'] } }] }] });
50
50
  const first = await session.sendMessage('Find the company renewal.');
51
51
  const call = first.response.functionCalls()?.[0];
@@ -75,9 +75,9 @@ export const cases: ClientCase[] = [
75
75
  async run(c) {
76
76
  return routed(c, async () => {
77
77
  const { GoogleGenAI } = await import('@google/genai');
78
- c.scenario({ handlers: [{ id: 'picture', on: { userTextIncludes: 'square illustration' }, respond: {
78
+ c.scenario([{ on: { userTextIncludes: 'square illustration' }, outcome: {
79
79
  text: 'A synthetic square illustration.', parts: [{ inlineData: { mimeType: 'image/png', data: PNG } }],
80
- } }] });
80
+ } }]);
81
81
  const ai = new GoogleGenAI({ apiKey: c.env.GEMINI_API_KEY ?? '', httpOptions: { baseUrl: `https://${c.host}` } });
82
82
  const response = await ai.models.generateContent({ model: 'gemini-3.1-flash-image', contents: 'Make a square illustration.', config: { responseModalities: ['TEXT', 'IMAGE'] } });
83
83
  const image = response.candidates?.[0]?.content?.parts?.find((p) => p.inlineData)?.inlineData;
@@ -8,6 +8,7 @@ import { createServer, request as httpRequest, type ClientRequest } from 'node:h
8
8
  import { Agent as HttpsAgent } from 'node:https';
9
9
  import { connect, type AddressInfo, type Socket } from 'node:net';
10
10
  import { writeFileSync } from 'node:fs';
11
+ import type { ResolvedScenario } from '@volter/world-core';
11
12
 
12
13
  /** What a case is given. */
13
14
  export type ClientContext = {
@@ -23,10 +24,18 @@ export type ClientContext = {
23
24
  /** an HTTPS agent as a World routes it, for a client that takes an agent (Slack's, Stripe's): its connections reach the
24
25
  * served port, the client's URL and Host unchanged */
25
26
  agent: HttpsAgent;
26
- /** writes the World's scenario file (read at the pack's next turn) */
27
- scenario: (doc: unknown) => void;
27
+ /** plays the World's scenario (read at the pack's next request): the model vendor's turns, each a decision at its
28
+ * `turn` point keyed by `on` (the pack's turn matchers) with `outcome` its answer, holding from the scenario's start;
29
+ * playing another replaces it */
30
+ scenario: (turns: ScriptedTurn[]) => void;
28
31
  };
29
32
 
33
+ /** A model vendor's scripted turn: a decision `{ point: 'turn', on, outcome, once? }` (ADR 0036) of the case's vendor. */
34
+ export type ScriptedTurn = { on: Record<string, unknown>; outcome: unknown; once?: boolean };
35
+
36
+ /** Where a case's scenario starts, every entry with it: long before any clock the served pack keeps, so each holds at once. */
37
+ const HELD_FROM = '2000-01-01T00:00:00.000Z';
38
+
30
39
  /** One expectation of the vendor's documented behaviour, driven through its client. */
31
40
  export type ClientCase = {
32
41
  id: string;
@@ -231,13 +240,18 @@ export function servedHttpFetch(port: number): typeof fetch {
231
240
  /** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
232
241
  /** `raw`: a raw protocol's served port (SMTP's listener), which a connection reaches directly, as a World passes a raw TCP
233
242
  * connection through untouched; the proxy speaks HTTP only. */
234
- export function clientContext(port: number, host: string, env: Record<string, string>, scenarioPath: string, raw?: number): ClientContext {
243
+ export function clientContext(port: number, host: string, env: Record<string, string>, scenario: { vendor: string; file: string }, raw?: number): ClientContext {
235
244
  const agent = new (class extends HttpsAgent {
236
245
  createConnection(): Socket { return connect(port, '127.0.0.1'); }
237
246
  })({ keepAlive: false });
238
247
  return {
239
248
  host, env, fetch: servedHttpFetch(port), agent,
240
249
  connect: () => connect(raw ?? port, '127.0.0.1'),
241
- scenario: (doc) => writeFileSync(scenarioPath, JSON.stringify(doc)),
250
+ // written as a World resolves a document whose every entry is at `0s`: the cases run in Node, whose type stripping
251
+ // does not load the kernel's source entry (its parameter properties), so they write the resolved form it reads
252
+ scenario: (turns) => writeFileSync(scenario.file, JSON.stringify({
253
+ name: 'client-case', start: HELD_FROM, random: 'client-case',
254
+ timeline: turns.map((t, i) => ({ at: HELD_FROM, decision: { vendor: scenario.vendor, point: 'turn', on: t.on, outcome: t.outcome, ...(t.once === undefined ? {} : { once: t.once }) }, index: i + 1 })),
255
+ } satisfies ResolvedScenario)),
242
256
  };
243
257
  }
@@ -5,10 +5,10 @@ import type { ClientCase, ClientContext } from './harness.ts';
5
5
 
6
6
  const CHAT = 'https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create';
7
7
  const TOOLS = [{ type: 'function' as const, function: { name: 'lookup', parameters: { type: 'object', properties: {} } } }];
8
- const SCRIPT = { handlers: [
9
- { id: 'calls', on: { hasTool: 'lookup' }, respond: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
10
- { id: 'words', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words' } },
11
- ] };
8
+ const SCRIPT = [
9
+ { on: { hasTool: 'lookup' }, outcome: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
10
+ { on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
11
+ ];
12
12
 
13
13
  /** A vendor's chat as openai-node reaches it: the variable its key is issued in, the base URL on its host, a model it serves. */
14
14
  export type Compatible = { key: string; base: (host: string) => string; model: string };
@@ -48,9 +48,9 @@ export function chatCases(v: Compatible): ClientCase[] {
48
48
  asks: "the World's scenario rewritten between two turns decides the second (no reboot)",
49
49
  source: 'architecture "The scenario is read from the World\'s store at every turn"',
50
50
  async run(c) {
51
- c.scenario({ handlers: [{ id: 'one', on: { userTextIncludes: 'hello' }, respond: { text: 'first script' } }] });
51
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'first script' } }]);
52
52
  const first = await ask(c).then((a) => a.choices?.[0]?.message?.content, (e: Error) => `error ${e.message}`);
53
- c.scenario({ handlers: [{ id: 'two', on: { userTextIncludes: 'hello' }, respond: { text: 'second script' } }] });
53
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'second script' } }]);
54
54
  const second = await ask(c).then((a) => a.choices?.[0]?.message?.content, (e: Error) => `error ${e.message}`);
55
55
  return first === 'first script' && second === 'second script' ? [] : [`the turns answered "${first}" then "${second}"`];
56
56
  },
@@ -13,10 +13,10 @@ async function client(c: ClientContext) {
13
13
  }
14
14
  const TOOLS = [{ type: 'function' as const, function: { name: 'lookup', parameters: { type: 'object', properties: {} } } }];
15
15
  // a turn that calls the tool wherever one is offered, and one that answers in words
16
- const SCRIPT = { handlers: [
17
- { id: 'calls', on: { hasTool: 'lookup' }, respond: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
18
- { id: 'words', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words' } },
19
- ] };
16
+ const SCRIPT = [
17
+ { on: { hasTool: 'lookup' }, outcome: { toolCalls: [{ name: 'lookup', arguments: {} }] } },
18
+ { on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words' } },
19
+ ];
20
20
 
21
21
  /** A Realtime session through the client (openai-node's server-side Realtime client, on the ws library, as a Node
22
22
  * application opens it), until its response is done: its first event and the output items' types, or what went wrong. */
@@ -1,5 +1,5 @@
1
1
  // The vendor's client cases, run in Node as an application runs its SDK: `node run.ts <job.json>`. The job names the
2
- // vendor, the served port, the host, the issued credentials and the scenario file; each case's failures are printed as
2
+ // vendor, the served port, the host, the issued credentials and the World's scenario file; each case's failures are printed as
3
3
  // one JSON line, `{ id, asks, failures }`.
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { createRequire } from 'node:module';
@@ -7,7 +7,7 @@ import { extname } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { clientContext, worldProxy, type ClientCase } from './harness.ts';
9
9
 
10
- const job = JSON.parse(readFileSync(process.argv[2]!, 'utf8')) as { vendor: string; port: number; host: string; env: Record<string, string>; scenarioPath: string; packFacts: string; raw?: boolean };
10
+ const job = JSON.parse(readFileSync(process.argv[2]!, 'utf8')) as { vendor: string; port: number; host: string; env: Record<string, string>; scenarioFile: string; packFacts: string; raw?: boolean };
11
11
  const { cases } = (await import(`./${job.vendor}${extname(fileURLToPath(import.meta.url))}`)) as { cases: ClientCase[] };
12
12
  if (!Array.isArray(cases) || cases.length === 0) throw new Error(`${job.vendor} client module declares no cases; there is no SDK execution evidence`);
13
13
  // every connection a case makes goes through the World's proxy, as an application's does in a World
@@ -20,7 +20,7 @@ try {
20
20
  const facts = JSON.parse(readFileSync(job.packFacts, 'utf8')).packs[job.vendor];
21
21
  const keys: string[] = [...new Set<string>((facts.hosts ?? []).map((rule: { key?: string }) => rule.key ?? job.vendor))];
22
22
  require('@volter/world-core/inject').install(Object.fromEntries(keys.map((key) => [key, `http://127.0.0.1:${proxy.port}`])));
23
- const context = clientContext(proxy.port, job.host, job.env, job.scenarioPath, job.raw ? job.port : undefined);
23
+ const context = clientContext(proxy.port, job.host, job.env, { vendor: job.vendor, file: job.scenarioFile }, job.raw ? job.port : undefined);
24
24
  for (const c of cases) {
25
25
  const failures = await Promise.race([
26
26
  c.run(context).catch((e: Error) => [`the case threw: ${e.message}`]),
@@ -12,7 +12,7 @@ export const cases: ClientCase[] = [
12
12
  asks: "a reasoning turn streamed through the SDK's Responses stream completes with its reasoning item and its message",
13
13
  source: 'https://developers.openai.com/api/reference/resources/responses/streaming-events "response.reasoning_summary_part.added"',
14
14
  async run(c) {
15
- c.scenario({ handlers: [{ id: 'reasons', on: { userTextIncludes: 'hello' }, respond: { text: 'scripted words', reasoning: 'thinking it through' } }] });
15
+ c.scenario([{ on: { userTextIncludes: 'hello' }, outcome: { text: 'scripted words', reasoning: 'thinking it through' } }]);
16
16
  const client = await openaiClient(c, XAI);
17
17
  try {
18
18
  const stream = client.responses.stream({ model: XAI.model, input: 'hello', reasoning: { summary: 'auto' } } as never);
@@ -28,6 +28,7 @@ import { fileURLToPath } from 'node:url';
28
28
  import { parseEnv } from 'node:util';
29
29
  import { openWebSocket, readHttpResponse, servedHttpFetch, worldProxy } from './clients/harness.ts';
30
30
  import { servePack as serve, type ServedPack as Served } from './walk/serve-pack.ts';
31
+ import { playScenario, resolvedScenario } from './walk/world-scenario.ts';
31
32
  import { walkPackDirectory } from './walk/pack-directory.ts';
32
33
  import { formBody, sigv4Headers } from './walk/journey-kit.ts';
33
34
  import { consumerClientProof, consumerRecipe, runConsumerCommand, type ConsumerResult } from './consumer.ts';
@@ -420,7 +421,7 @@ async function consumerAuthorization(pack: string, scratch: string, root: string
420
421
  }
421
422
 
422
423
  /** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
423
- * runs it: once plainly and once with a scenario loaded (an empty one, as a World that scripts nothing loads it). Each
424
+ * runs it: once plainly and once with a scenario played (an empty one, as a World that places nothing plays it). Each
424
425
  * answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
425
426
  * bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
426
427
  * refresh list a fresh account can read (one with no parameter in its path) succeeds or remains unread under the
@@ -458,11 +459,12 @@ async function surfaceSwept(pack: string, scratch: string, runtime: Runtime, con
458
459
  if (!units.length) return { failures: out, note: 'no unit serves an operation on a path of its own' };
459
460
  const tally = { calls: 0, ok: 0, refused: 0, unread: 0, removed: 0 };
460
461
  const replayedHeaders = new Set<string>();
461
- const scenarioPath = join(scratch, 'surface-scenario.json');
462
- writeFileSync(scenarioPath, '{"handlers":[]}');
462
+ // the scenario mode plays an empty timeline as the World's, as a World that places nothing on the vendor plays it
463
+ const empty = resolvedScenario('surface', { start: '2000-01-01T00:00:00.000Z', timeline: [] });
463
464
  for (const mode of ['plain', 'scenario'] as const) {
464
465
  const root = join(scratch, `surface-${mode}`);
465
- const server = await serve(pack, { root, ...(mode === 'scenario' ? { scenarioPath } : {}) });
466
+ const restoreScenario = playScenario(join(scratch, 'surface-scenario.json'), mode === 'scenario' ? empty : undefined);
467
+ const server = await serve(pack, { root }).catch((error: unknown) => { restoreScenario(); throw error; });
466
468
  const http = servedHttpFetch(server.inspectPort ?? server.port);
467
469
  const send = async (request: Request): Promise<Awaited<ReturnType<typeof readHttpResponse>> | Error> => {
468
470
  await server.managedDatabaseUrl?.();
@@ -626,7 +628,7 @@ async function surfaceSwept(pack: string, scratch: string, runtime: Runtime, con
626
628
  if (status === 403 && readSomething) { tally.unread++; continue; }
627
629
  if (status !== undefined && (status < 200 || status >= 300)) out.push(`${u.name}: ${r.resource}'s refresh lists by ${r.id}, which answered a fresh account ${status}: a World rooted at this twin reads nothing back`);
628
630
  }
629
- } finally { await server.stop(); }
631
+ } finally { try { await server.stop(); } finally { restoreScenario(); } }
630
632
  }
631
633
  // one defect repeated over a surface reads as one: the first 40 findings, and how many more
632
634
  return { failures: out.length > 40 ? [...out.slice(0, 40), `… ${out.length - 40} more`] : out, note: `${tally.calls} served operations called, twice each: ${tally.ok} answered successfully, ${tally.refused} refused, unscripted; ${tally.unread} refresh resources unread (403), ${tally.removed} match declared native removals${replayedHeaders.size ? `; replayed application headers (values redacted): ${[...replayedHeaders].sort().join(', ')}` : ''}` };
@@ -661,7 +663,7 @@ function lineConnection(port: number): TwinStream {
661
663
  }
662
664
 
663
665
  /** The life walked as an application calls the pack: its server.ts served as a World runs it (boot id set, the World's
664
- * root and the life's scenario its options), every step over HTTP with the vendor's host, every check of the walk held
666
+ * root its option, the life's scenario played as the World's), every step over HTTP with the vendor's host, every check of the walk held
665
667
  * (status, expectations, captures, SHAPE, LEAK over answers, events and frames). Slack's events once carried the
666
668
  * kernel's updatedAt; a request the served pack does not answer in 10 s fails its step. */
667
669
  async function lifeServed(pack: string): Promise<string[]> {
@@ -669,8 +671,8 @@ async function lifeServed(pack: string): Promise<string[]> {
669
671
  if (!existsSync(lifePath)) return ['journeys/customer-life.json is missing'];
670
672
  const { inMemoryWorld, usePacks, walk } = await import('./walk/behavior-journey.ts');
671
673
  usePacks(dirname(pack), { dependenciesFrom: pack });
672
- const served = async (root: string, options: { scenarioPath?: string }) => {
673
- const server = await serve(pack, { root, ...options });
674
+ const served = async (root: string) => {
675
+ const server = await serve(pack, { root });
674
676
  const port = server.inspectPort ?? server.port;
675
677
  const http = servedHttpFetch(port);
676
678
  return {
@@ -718,9 +720,11 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
718
720
  // connection alone; its client names a stand-in host the connection never resolves
719
721
  const host = descriptor?.hosts?.flatMap((h) => 'host' in h && h.exclude !== true ? [h.host] : [])[0] ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
720
722
  if (!host) return { failures: ['the descriptor names no host for its client to address'] };
721
- const scenarioPath = join(scratch, 'client-scenario.json');
722
- writeFileSync(scenarioPath, '{"handlers":[]}');
723
- const server = await serve(pack, { root: join(scratch, 'client-world'), scenarioPath });
723
+ // the World's scenario, empty until a case plays the turns it scripts (the harness writes the file this process's pack
724
+ // reads at each request)
725
+ const scenarioFile = join(scratch, 'client-scenario.json');
726
+ const restoreScenario = playScenario(scenarioFile, resolvedScenario('client-case', { start: '2000-01-01T00:00:00.000Z', timeline: [] }));
727
+ const server = await serve(pack, { root: join(scratch, 'client-world') }).catch((error: unknown) => { restoreScenario(); throw error; });
724
728
  try {
725
729
  // the World's credentials: what the pack's credential door issues, by the variable a World sets each in
726
730
  const issued: Record<string, string> = {};
@@ -739,7 +743,7 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
739
743
  packFacts = join(scratch, 'pack-facts.json');
740
744
  writeFileSync(packFacts, renderPackFacts(compiled));
741
745
  }
742
- writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued, scenarioPath, packFacts, ...(descriptor?.hostsNone ? { raw: true } : {}) }));
746
+ writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued, scenarioFile, packFacts, ...(descriptor?.hostsNone ? { raw: true } : {}) }));
743
747
  // Node, as an application runs its SDK (asynchronous: this process serves the pack meanwhile)
744
748
  const out = await new Promise<string>((resolve) => {
745
749
  const child = spawn('node', ['--experimental-strip-types', '--no-warnings', join(clients, `run${extension}`), job], { stdio: ['ignore', 'pipe', 'pipe'] });
@@ -753,7 +757,7 @@ async function clientCases(pack: string, scratch: string, consumer?: ConsumerRes
753
757
  const results = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l) as { id: string; asks: string; failures: string[] });
754
758
  if (results.length === 0) return { failures: [`${vendor} client runner returned no case results; there is no SDK execution evidence`] };
755
759
  return { failures: results.flatMap((r) => r.failures.map((f) => `${r.id} (${r.asks}): ${f}`)), note: `${results.length} case(s) through the vendor's client` };
756
- } finally { await server.stop(); }
760
+ } finally { try { await server.stop(); } finally { restoreScenario(); } }
757
761
  }
758
762
 
759
763
  /** The gate over every unit of the pack: the form section of the grade, which holds the rules an author's code keeps
@@ -361,7 +361,7 @@ export function cliFile(vendor: string, live = false): string {
361
361
  const Name = pascal(vendor);
362
362
  const ingest = live ? `// the RTMP ingest's side port, which the World allocates (the manifest's sidePorts)\nconst ingestPort = Number(optionValue(rest, '--ingest-port', '0')) || undefined;\n` : '';
363
363
  const served = live ? ` process.stdout.write(s.ingestPort ? \`${vendor} twin ingest at rtmp://127.0.0.1:\${s.ingestPort}\\n\` : '${vendor} twin: no RTMP ingest\\n');\n` : '';
364
- return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the selected twin's private Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n// the World's scenario file, for a model vendor's turns\nconst scenarioPath = optionValue(rest, '--scenario') || undefined;\n${ingest}\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {}), ...(scenarioPath ? { scenarioPath } : {})${live ? ', ...(ingestPort ? { ingestPort } : {})' : ''} });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n${served} await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL] [--scenario FILE]${live ? ' [--ingest-port N]' : ''}\\n');\n}\n`;
364
+ return `#!/usr/bin/env node\n// world-${vendor}: serve the ${vendor} twin.\nimport { hasFlag, optionValue } from '@volter/world-core/args';\nimport { keepProcessAlive } from '@volter/world-core/lifecycle';\nimport { create${Name}TwinServer } from './server.ts';\n\nconst [cmd, ...rest] = process.argv.slice(2);\nconst port = Number(optionValue(rest, '--port', '0')) || undefined;\nconst root = optionValue(rest, '--root') || undefined;\nconst readOnly = hasFlag(rest, '--read-only');\n// the selected twin's private Postgres, which the runtime binds a pack declaring managedDatabase to\nconst database = optionValue(rest, '--database') || undefined;\n${ingest}\nif (cmd === 'serve' || cmd === undefined) {\n const s = await create${Name}TwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(database ? { database } : {})${live ? ', ...(ingestPort ? { ingestPort } : {})' : ''} });\n process.stdout.write(\`${vendor} twin\${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:\${s.port}\\n\`);\n${served} await keepProcessAlive();\n} else {\n process.stdout.write('Usage: world-${vendor} serve [--port N] [--root DIR] [--read-only] [--database URL]${live ? ' [--ingest-port N]' : ''}\\n');\n}\n`;
365
365
  }
366
366
 
367
367
  /** A vendor id as a name in code: `npm-registry` is `NpmRegistry`. */
@@ -110,6 +110,7 @@ export function inMemoryWorld(label: string): { root: () => string; restore: ()
110
110
  }
111
111
 
112
112
  import { vendorAnswerViews, vendorFetch as vendorFetchFrom, vendorStream, type VendorStreamFactory } from './vendor-fetch.ts';
113
+ import { playScenario, resolvedScenario, type ResolvedScenario } from './world-scenario.ts';
113
114
  import { gitClone, gitPush } from './git-client.ts';
114
115
 
115
116
  /** The type a step's body goes with when it names none, as fetch labels it: JSON for a value or a JSON text, plain text
@@ -188,10 +189,11 @@ function takeReplies(c: Connection): LineReply[] {
188
189
  return out;
189
190
  }
190
191
 
191
- /** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root and scenario its options):
192
+ /** A pack served for a walk the way a World runs it (its server.ts on a port, the World's root its option, its scenario
193
+ * the World's file):
192
194
  * the walk's requests go to it over HTTP, the vendor's host in their Host header, instead of to the fetch in process;
193
195
  * its sockets are WebSockets to that port, and a line connection (`stream`) is a TCP connection to its protocol port. */
194
- export type ServedTwin = (root: string, options: { scenarioPath?: string; application?: import('@volter/world-core').PackFetchOptions['application'] }) => Promise<{ scheme?: 'http' | 'https'; fetch: ((request: Request, options?: { credentials?: RequestCredentials }) => Promise<Response>) & ManagedDatabaseWalk; stop: () => void | Promise<void>; socket?: (url: string, headers: Record<string, string>) => WebSocket; stream?: TwinStream }>;
196
+ export type ServedTwin = (root: string, options: { application?: import('@volter/world-core').PackFetchOptions['application'] }) => Promise<{ scheme?: 'http' | 'https'; fetch: ((request: Request, options?: { credentials?: RequestCredentials }) => Promise<Response>) & ManagedDatabaseWalk; stop: () => void | Promise<void>; socket?: (url: string, headers: Record<string, string>) => WebSocket; stream?: TwinStream }>;
195
197
 
196
198
  export async function walk(journey: Journey, root: string, collect: Failure[], opts: { beats: boolean; verbose?: boolean; hooks?: StepHooks; captured?: Captured; served?: ServedTwin }): Promise<Captured> {
197
199
  attributionName(journey.author);
@@ -263,13 +265,16 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
263
265
  const ownKeys = routingKeys(journey.vendor);
264
266
  /** The companion that serves a step's URL: one whose host rules claim it where the life's own vendor's do not. */
265
267
  const companionOf = (url: string) => (claims(ownKeys, url) ? undefined : companions.find((c) => claims(c.keys, url)));
266
- // the life's scenario is the World's: written into the walk's World store, which the pack reads it from as a World's
267
- // handlers/<vendor>.json is read
268
- const scenarioPath = journey.scenario ? join(directoryFor(journey.vendor), 'journeys', journey.scenario) : undefined;
269
- if (scenarioPath) getActiveWorldStore().write(scenarioPath, readFileSync(scenarioPath, 'utf8'));
268
+ // the life's scenario is the World's: resolved here, and played (written beside the walk's root in its World store and
269
+ // named by TWIN_WORLD_SCENARIO_FILE, which every pack of the walk reads at each request as a World's twins do) from its
270
+ // first step to its end; a life naming none plays none, whatever World the walk itself runs in
271
+ let scenario: ResolvedScenario | undefined;
272
+ try {
273
+ if (journey.scenario) scenario = resolvedScenario(journey.scenario, parseExactJson(readFileSync(join(directoryFor(journey.vendor), 'journeys', journey.scenario), 'utf8')));
274
+ } catch (error) { collect.push({ step: '(setup)', beat: 'SCENARIO', detail: `journeys/${journey.scenario}: ${(error as Error).message}` }); return {}; }
270
275
  const shape = await shapeOf(journey.vendor);
271
- const served = opts.served ? await opts.served(root, { ...(scenarioPath ? { scenarioPath } : {}), application }) : undefined;
272
- const twin = served ? served.fetch : factory(root, { ...(scenarioPath ? { scenarioPath } : {}), application });
276
+ const served = opts.served ? await opts.served(root, { application }) : undefined;
277
+ const twin = served ? served.fetch : factory(root, { application });
273
278
  const host = journey.host ?? 'twin.local';
274
279
  const origin = `${served?.scheme ?? 'http'}://${host}`;
275
280
  // a capture's JSON value where it is not text (a number, a boolean, an object such as database-js's `session`)
@@ -747,6 +752,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
747
752
  ...ownKeys.map((key) => [key, twin] as const),
748
753
  ]));
749
754
  const previousMailServers = setInProcessMailServers(mailServers);
755
+ const restoreScenario = playScenario(`${root}.scenario.json`, scenario);
750
756
  try {
751
757
  for (const step of journey.steps) {
752
758
  await settleEventDeliveries(root);
@@ -965,6 +971,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
965
971
  if (failures.length) throw new AggregateError(failures, 'Walk native database retirement failed');
966
972
  } finally {
967
973
  if (clockFile && startedAt !== undefined) store.write(clockFile, startedAt);
974
+ restoreScenario();
968
975
  }
969
976
  }
970
977
  }
@@ -305,8 +305,9 @@ export type Journey = {
305
305
  /** The instant the story begins, where it is before the World's day one (2026-01-01): an account whose story starts
306
306
  * while an API it used still made what it no longer makes. Its waits move the clock on from there. */
307
307
  startsAt?: string;
308
- /** The World's scenario for a generative pack's turns (a file beside the life, `journeys/<name>.json`, as a World
309
- * loads `handlers/<vendor>.json`): the walk loads it, so the scripted turns and the pack's scenario code are walked. */
308
+ /** The World's scenario the life is walked under (a scenario document beside it, `journeys/<name>.json`: ADR 0036,
309
+ * `{ start, timeline }`): the walk plays it as the World's, so the decisions and faults it places, and the pack's code
310
+ * that consumes them, are walked. */
310
311
  scenario?: string;
311
312
  /** The packs of the same catalog, by vendor, that the vendor calls as its own behaviour (`ctx.vendorFetch`: a sign-in
312
313
  * trading a code at its provider): the walk loads each over the walk's own World and root, the vendor's calls reach
@@ -10,7 +10,7 @@ export type ServedPack = {
10
10
  stop: () => void | Promise<void>;
11
11
  } & Omit<ManagedDatabaseWalk, 'stopDatabase'>;
12
12
 
13
- export async function servePack(pack: string, options: { root?: string; scenarioPath?: string }): Promise<ServedPack> {
13
+ export async function servePack(pack: string, options: { root?: string }): Promise<ServedPack> {
14
14
  const previousBoot = process.env.VOLTER_WORLD_BOOT_ID;
15
15
  process.env.VOLTER_WORLD_BOOT_ID = 'conformance';
16
16
  try { return await serve(pack, options); }
@@ -20,7 +20,7 @@ export async function servePack(pack: string, options: { root?: string; scenario
20
20
  }
21
21
  }
22
22
 
23
- async function serve(pack: string, options: { root?: string; scenarioPath?: string }): Promise<ServedPack> {
23
+ async function serve(pack: string, options: { root?: string }): Promise<ServedPack> {
24
24
  const mod = await import(join(pack, 'src', 'server.ts')) as Record<string, unknown>;
25
25
  const create = Object.entries(mod).find(([name, value]) => /^create\w+TwinServer$/.test(name) && typeof value === 'function')?.[1] as ((options: Record<string, unknown>) => Promise<ServedPack>) | undefined;
26
26
  if (!create) throw new Error('src/server.ts exports no create<Vendor>TwinServer');
@@ -9,9 +9,9 @@ import { walkPackDirectory } from './pack-directory.ts';
9
9
 
10
10
  type ApplicationStandIn = NonNullable<PackFetchOptions['application']>;
11
11
 
12
- /** A pack's fetch over a World's root; a walk whose life names a scenario loads it as the World's (`scenarioPath`), and a
12
+ /** A pack's fetch over a World's root (the World's scenario it reads itself, TWIN_WORLD_SCENARIO_FILE), and a
13
13
  * life's application answers stand in for the application the vendor asks (`application`, this fetch's alone). */
14
- export type VendorFetchFactory = ((root: string, options?: { scenarioPath?: string; application?: ApplicationStandIn }) => ((request: Request) => Response | Promise<Response>) & ManagedDatabaseWalk) & { managedDatabaseDoor?: string };
14
+ export type VendorFetchFactory = ((root: string, options?: { application?: ApplicationStandIn }) => ((request: Request) => Response | Promise<Response>) & ManagedDatabaseWalk) & { managedDatabaseDoor?: string };
15
15
 
16
16
  export async function vendorFetch(twinDir: string, vendor: string, dependenciesFrom = twinDir): Promise<VendorFetchFactory | undefined> {
17
17
  const directory = walkPackDirectory(twinDir, vendor, dependenciesFrom);
@@ -26,7 +26,7 @@ export async function packFetch(directory: string): Promise<VendorFetchFactory |
26
26
  // a pack exports create<Name>Fetch (its generated fetch.ts)
27
27
  const name = Object.keys(mod).find((k) => /^create\w+?Fetch$/.test(k) && !/TwinFetch$/.test(k) && typeof mod[k] === 'function');
28
28
  if (!name) return undefined;
29
- const factory = mod[name] as (options: { root: string; database?: string; scenarioPath?: string; application?: ApplicationStandIn }) => ((request: Request) => Response | Promise<Response>) & Record<string, unknown>;
29
+ const factory = mod[name] as (options: { root: string; database?: string; application?: ApplicationStandIn }) => ((request: Request) => Response | Promise<Response>) & Record<string, unknown>;
30
30
  const descriptor = mod.pack as TwinPack | undefined;
31
31
  if (!descriptor?.managedDatabase) return (root: string, options = {}) => factory({ root, ...options });
32
32
  const managedDatabaseClock = descriptor.managedDatabase.clock;
@@ -0,0 +1,23 @@
1
+ // The World's scenario as the standard plays it for a walk or a served pack (ADR 0036; architecture, "Scenarios"): the
2
+ // document parsed and resolved as a World resolves it, written through the active World store, and named by
3
+ // TWIN_WORLD_SCENARIO_FILE, where every twin in this process reads it at each request as it reads the clock. A pack takes
4
+ // no scenario option: what it plays is the World's one scenario, so the standard sets the World's.
5
+ import { parseScenario, resolveScenario, WORLD_SCENARIO_ENV, type ResolvedScenario } from '@volter/world-core';
6
+ import { getActiveWorldStore } from '@volter/world-core/runtime';
7
+
8
+ export type { ResolvedScenario };
9
+
10
+ /** A scenario document as a World plays it; one the grammar refuses throws its ScenarioTimelineError, which names the
11
+ * entry and the vocabulary it expected. */
12
+ export function resolvedScenario(name: string, document: unknown): ResolvedScenario {
13
+ return resolveScenario(name, parseScenario(document));
14
+ }
15
+
16
+ /** Play `scenario` as the World's at `file` until the returned restore, which puts the variable back as it was. Playing
17
+ * none clears the variable, so what the standard serves inside a World plays nothing of that World's. */
18
+ export function playScenario(file: string, scenario: ResolvedScenario | undefined): () => void {
19
+ if (scenario) getActiveWorldStore().write(file, JSON.stringify(scenario));
20
+ const previous = process.env[WORLD_SCENARIO_ENV];
21
+ if (scenario) process.env[WORLD_SCENARIO_ENV] = file; else delete process.env[WORLD_SCENARIO_ENV];
22
+ return () => { if (previous === undefined) delete process.env[WORLD_SCENARIO_ENV]; else process.env[WORLD_SCENARIO_ENV] = previous; };
23
+ }