@volter/world-core 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +29 -0
  3. package/app-route.cjs +154 -0
  4. package/app-route.d.cts +7 -0
  5. package/attach.cjs +80 -0
  6. package/dist/app-route.cjs +154 -0
  7. package/dist/app-route.d.cts +7 -0
  8. package/dist/attach.cjs +80 -0
  9. package/dist/generated/pack-facts.json +4306 -0
  10. package/dist/inject.cjs +1097 -0
  11. package/dist/network-policy.cjs +92 -0
  12. package/dist/network-policy.d.cts +10 -0
  13. package/dist/src/actions.d.ts +276 -0
  14. package/dist/src/actions.js +436 -0
  15. package/dist/src/ancestry.d.ts +22 -0
  16. package/dist/src/ancestry.js +238 -0
  17. package/dist/src/args.d.ts +3 -0
  18. package/dist/src/args.js +12 -0
  19. package/dist/src/blob-store.d.ts +55 -0
  20. package/dist/src/blob-store.js +186 -0
  21. package/dist/src/brand-tokens.d.ts +2 -0
  22. package/dist/src/brand-tokens.js +17 -0
  23. package/dist/src/changeset.d.ts +431 -0
  24. package/dist/src/changeset.js +0 -0
  25. package/dist/src/client-bundle.d.ts +1 -0
  26. package/dist/src/client-bundle.js +28 -0
  27. package/dist/src/credential.d.ts +38 -0
  28. package/dist/src/credential.js +114 -0
  29. package/dist/src/derived-core.d.ts +452 -0
  30. package/dist/src/derived-core.js +782 -0
  31. package/dist/src/derived.d.ts +84 -0
  32. package/dist/src/derived.js +122 -0
  33. package/dist/src/emit.d.ts +106 -0
  34. package/dist/src/emit.js +157 -0
  35. package/dist/src/executor.d.ts +120 -0
  36. package/dist/src/executor.js +387 -0
  37. package/dist/src/file-response.d.ts +3 -0
  38. package/dist/src/file-response.js +22 -0
  39. package/dist/src/fork.d.ts +26 -0
  40. package/dist/src/fork.js +68 -0
  41. package/dist/src/git/history.d.ts +36 -0
  42. package/dist/src/git/history.js +298 -0
  43. package/dist/src/git/index.d.ts +6 -0
  44. package/dist/src/git/index.js +6 -0
  45. package/dist/src/git/inflate.d.ts +11 -0
  46. package/dist/src/git/inflate.js +194 -0
  47. package/dist/src/git/objects.d.ts +64 -0
  48. package/dist/src/git/objects.js +161 -0
  49. package/dist/src/git/pack.d.ts +14 -0
  50. package/dist/src/git/pack.js +199 -0
  51. package/dist/src/git/refs.d.ts +19 -0
  52. package/dist/src/git/refs.js +35 -0
  53. package/dist/src/git/smart-http.d.ts +45 -0
  54. package/dist/src/git/smart-http.js +223 -0
  55. package/dist/src/hash.d.ts +38 -0
  56. package/dist/src/hash.js +48 -0
  57. package/dist/src/head.d.ts +140 -0
  58. package/dist/src/head.js +313 -0
  59. package/dist/src/history.d.ts +76 -0
  60. package/dist/src/history.js +322 -0
  61. package/dist/src/index.d.ts +73 -0
  62. package/dist/src/index.js +98 -0
  63. package/dist/src/lifecycle.d.ts +1 -0
  64. package/dist/src/lifecycle.js +8 -0
  65. package/dist/src/log.d.ts +254 -0
  66. package/dist/src/log.js +801 -0
  67. package/dist/src/mirror-shell.d.ts +2 -0
  68. package/dist/src/mirror-shell.js +13 -0
  69. package/dist/src/observe.d.ts +49 -0
  70. package/dist/src/observe.js +148 -0
  71. package/dist/src/pack-assets.d.ts +30 -0
  72. package/dist/src/pack-assets.js +88 -0
  73. package/dist/src/packRegistry.d.ts +374 -0
  74. package/dist/src/packRegistry.js +142 -0
  75. package/dist/src/placeholder-remote.d.ts +22 -0
  76. package/dist/src/placeholder-remote.js +86 -0
  77. package/dist/src/proxy.d.ts +25 -0
  78. package/dist/src/proxy.js +155 -0
  79. package/dist/src/rateBudget.d.ts +367 -0
  80. package/dist/src/rateBudget.js +925 -0
  81. package/dist/src/references.d.ts +18 -0
  82. package/dist/src/references.js +27 -0
  83. package/dist/src/remote-execute.d.ts +22 -0
  84. package/dist/src/remote-execute.js +1 -0
  85. package/dist/src/resource-blob.d.ts +10 -0
  86. package/dist/src/resource-blob.js +56 -0
  87. package/dist/src/scenario.d.ts +197 -0
  88. package/dist/src/scenario.js +425 -0
  89. package/dist/src/schemas.d.ts +78 -0
  90. package/dist/src/schemas.js +50 -0
  91. package/dist/src/serve-http.d.ts +48 -0
  92. package/dist/src/serve-http.js +340 -0
  93. package/dist/src/serve.d.ts +147 -0
  94. package/dist/src/serve.js +507 -0
  95. package/dist/src/shared-blob-index.d.ts +4 -0
  96. package/dist/src/shared-blob-index.js +126 -0
  97. package/dist/src/state-system.d.ts +70 -0
  98. package/dist/src/state-system.js +90 -0
  99. package/dist/src/storage.d.ts +101 -0
  100. package/dist/src/storage.js +337 -0
  101. package/dist/src/twin-fetch.d.ts +64 -0
  102. package/dist/src/twin-fetch.js +91 -0
  103. package/dist/src/types.d.ts +40 -0
  104. package/dist/src/types.js +1 -0
  105. package/dist/src/v1-removed.d.ts +159 -0
  106. package/dist/src/v1-removed.js +124 -0
  107. package/dist/src/volter-home.d.ts +5 -0
  108. package/dist/src/volter-home.js +10 -0
  109. package/dist/src/world-clock.d.ts +4 -0
  110. package/dist/src/world-clock.js +32 -0
  111. package/dist/src/world-env.d.ts +3 -0
  112. package/dist/src/world-env.js +22 -0
  113. package/dist/src/world-store-sql.d.ts +27 -0
  114. package/dist/src/world-store-sql.js +86 -0
  115. package/dist/src/world-store.d.ts +168 -0
  116. package/dist/src/world-store.js +475 -0
  117. package/dist/src/worldConfig.d.ts +9 -0
  118. package/dist/src/worldConfig.js +17 -0
  119. package/dist/stream-bridge.cjs +80 -0
  120. package/dist/vendor-hosts.cjs +200 -0
  121. package/generated/pack-facts.json +4306 -0
  122. package/inject.cjs +1097 -0
  123. package/network-policy.cjs +92 -0
  124. package/network-policy.d.cts +10 -0
  125. package/package.json +103 -0
  126. package/src/actions.ts +564 -0
  127. package/src/ancestry.ts +213 -0
  128. package/src/args.ts +14 -0
  129. package/src/blob-store.ts +185 -0
  130. package/src/brand-tokens.ts +17 -0
  131. package/src/changeset.ts +1032 -0
  132. package/src/client-bundle.ts +29 -0
  133. package/src/credential.ts +140 -0
  134. package/src/derived-core.ts +1004 -0
  135. package/src/derived.ts +176 -0
  136. package/src/emit.ts +242 -0
  137. package/src/executor.ts +431 -0
  138. package/src/file-response.ts +22 -0
  139. package/src/fork.ts +89 -0
  140. package/src/git/history.ts +177 -0
  141. package/src/git/index.ts +6 -0
  142. package/src/git/inflate.ts +125 -0
  143. package/src/git/objects.ts +110 -0
  144. package/src/git/pack.ts +105 -0
  145. package/src/git/refs.ts +25 -0
  146. package/src/git/smart-http.ts +149 -0
  147. package/src/hash.ts +66 -0
  148. package/src/head.ts +318 -0
  149. package/src/history.ts +246 -0
  150. package/src/index.ts +323 -0
  151. package/src/lifecycle.ts +8 -0
  152. package/src/log.ts +793 -0
  153. package/src/mirror-shell.ts +15 -0
  154. package/src/observe.ts +130 -0
  155. package/src/pack-assets.ts +81 -0
  156. package/src/packRegistry.ts +408 -0
  157. package/src/placeholder-remote.ts +81 -0
  158. package/src/proxy.ts +183 -0
  159. package/src/rateBudget.ts +1115 -0
  160. package/src/references.ts +46 -0
  161. package/src/remote-execute.ts +26 -0
  162. package/src/resource-blob.ts +57 -0
  163. package/src/scenario.ts +479 -0
  164. package/src/schemas.ts +56 -0
  165. package/src/serve-http.ts +299 -0
  166. package/src/serve.ts +618 -0
  167. package/src/shared-blob-index.ts +108 -0
  168. package/src/state-system.ts +115 -0
  169. package/src/storage.ts +407 -0
  170. package/src/twin-fetch.ts +147 -0
  171. package/src/types.ts +50 -0
  172. package/src/v1-removed.ts +172 -0
  173. package/src/volter-home.ts +11 -0
  174. package/src/world-clock.ts +33 -0
  175. package/src/world-env.ts +18 -0
  176. package/src/world-store-sql.ts +118 -0
  177. package/src/world-store.ts +572 -0
  178. package/src/worldConfig.ts +27 -0
  179. package/stream-bridge.cjs +80 -0
  180. package/vendor-hosts.cjs +200 -0
@@ -0,0 +1,46 @@
1
+ // REFERENCES — docs/contributing/architecture.md#alias-aware-lookup-at-the-request-boundary: an id is adopted once and every declared
2
+ // reference follows it. Adoption is an alias (`aliasOf` on the landed copy; the fold moves the
3
+ // subject). A pack DECLARES which fields of which subject types hold another subject's id; the
4
+ // kernel resolves those fields through the alias map — in the tree, whatever order the entries fold
5
+ // in, and at the perform, so the vendor is asked about #7, never the local #3.
6
+ //
7
+ // One registry per process (a pack installed beside a world carries its own copy of the kernel).
8
+ import type { SubjectFields } from './hash.ts';
9
+
10
+ export type ReferenceDeclaration = {
11
+ /** the referencing subject type */
12
+ type: string;
13
+ /** the referenced subject type */
14
+ to: string;
15
+ /** the referenced subject's id, read from the referencing subject's fields (undefined: no reference) */
16
+ key: (fields: SubjectFields) => string | undefined;
17
+ /** the fields to overlay once the referenced subject's id is `vendorId` */
18
+ adopt: (fields: SubjectFields, vendorId: string) => SubjectFields;
19
+ };
20
+
21
+ /** The common case: one field holds the referenced subject's id as it is. */
22
+ export function referenceField(type: string, field: string, to: string): ReferenceDeclaration {
23
+ return { type, to, key: (f) => (f[field] === undefined || f[field] === null ? undefined : String(f[field])), adopt: (_f, vendorId) => ({ [field]: vendorId }) };
24
+ }
25
+
26
+ const REGISTRY = Symbol.for('volter.references');
27
+ const registry = (): Map<string, ReferenceDeclaration[]> => ((globalThis as Record<symbol, unknown>)[REGISTRY] ??= new Map<string, ReferenceDeclaration[]>()) as Map<string, ReferenceDeclaration[]>;
28
+
29
+ export function registerReferences(vendor: string, references: ReferenceDeclaration[]): void { registry().set(vendor, references); }
30
+ export function packReferences(vendor: string): ReferenceDeclaration[] { return registry().get(vendor) ?? []; }
31
+
32
+ /** `fields` of a `type` subject with every declared reference resolved through `aliases`
33
+ * (`${type}:${oldId}` → newId, as `aliasesFrom` builds it). The same object when nothing applies. */
34
+ export function resolveReferences(vendor: string, type: string, fields: SubjectFields, aliases: Map<string, string>): SubjectFields {
35
+ if (aliases.size === 0) return fields;
36
+ let out = fields;
37
+ for (const ref of packReferences(vendor)) {
38
+ if (ref.type !== type) continue;
39
+ const key = ref.key(out);
40
+ if (key === undefined) continue;
41
+ const adopted = aliases.get(`${ref.to}:${key}`);
42
+ if (adopted === undefined || adopted === key) continue;
43
+ out = { ...out, ...ref.adopt(out, adopted) };
44
+ }
45
+ return out;
46
+ }
@@ -0,0 +1,26 @@
1
+ // THE ONE PULL EXECUTOR SHAPE (runtime contract R14, scheduled pull): what every pack's
2
+ // `sync<Name>FromRemote` adapter receives. TYPES ONLY — the kernel defines the seam so
3
+ // packs can adapt their vendor-specific executors to it; BUILDING one (origin +
4
+ // sealed-credential egress) is the twins service's job, never a pack's.
5
+ export type RemoteExecuteRequest = {
6
+ method: string;
7
+ path: string;
8
+ headers?: Record<string, string>;
9
+ /** A JSON text, or raw bytes (a multipart upload) — the host hands either to fetch as is. */
10
+ body?: string | Uint8Array;
11
+ /** Opt into exact bytes for object/attachment downloads; JSON clients keep body text. */
12
+ responseType?: 'bytes';
13
+ /** A URL the vendor itself returned for an upload, already carrying its own authorization
14
+ * (LinkedIn's video parts on www.linkedin.com/dms-uploads, Slack's upload_url, TikTok's
15
+ * upload.<region>.tiktokapis.com): `path` is then that absolute URL, often on another host than
16
+ * the API, and the request goes WITHOUT the sealed credential. An upload the vendor authorizes
17
+ * with the API token (LinkedIn's images) is not presigned and cannot use this. */
18
+ presigned?: true;
19
+ };
20
+ export type RemoteExecuteResponse = {
21
+ status: number;
22
+ headers: Record<string, string>;
23
+ body: string;
24
+ bodyBytes?: Uint8Array;
25
+ };
26
+ export type RemoteExecute = (request: RemoteExecuteRequest) => Promise<RemoteExecuteResponse>;
@@ -0,0 +1,57 @@
1
+ import { isAbsolute, join } from 'node:path';
2
+ import { getActiveBlobStore, readBlobRange } from './blob-store.ts';
3
+ import { readBranchMeta } from './log.ts';
4
+ import { worldPaths } from './storage.ts';
5
+
6
+ /** Read a resource payload at this branch, then its retained ancestors. */
7
+ export async function readResourceBlob(service: string, key: string, root?: string): Promise<Uint8Array | null> {
8
+ assertResourceKey(key);
9
+ for (const dir of resourceChain(service, root)) {
10
+ const bytes = await getActiveBlobStore().get(join(dir, key));
11
+ if (bytes !== null) return bytes;
12
+ }
13
+ return null;
14
+ }
15
+
16
+ /** A service's resources dir at this branch, then at each retained ancestor's: where a read resolves a payload, and
17
+ * what a listing that mints ids covers, so a branch serves bytes its parent received and never re-mints a parent's id. */
18
+ export function resourceChain(service: string, root?: string): string[] {
19
+ const out: string[] = [];
20
+ const seen = new Set<string>();
21
+ let current = worldPaths(service, root).root;
22
+ for (;;) {
23
+ if (seen.has(current)) throw new Error('Cyclic resource blob ancestry');
24
+ seen.add(current);
25
+ out.push(worldPaths(service, current).resources);
26
+ const parent = readBranchMeta(service, current)?.parent;
27
+ if (!parent) return out;
28
+ current = parent.at;
29
+ }
30
+ }
31
+
32
+ function assertResourceKey(key: string): void {
33
+ if (isAbsolute(key) || key.includes('\0') || key.split(/[\\/]/).includes('..')) throw new Error('Invalid relative resource blob key');
34
+ }
35
+
36
+ /** The key's full path at the first of this branch and its retained ancestors that holds it. */
37
+ async function resolveResourceBlob(service: string, key: string, root?: string): Promise<string | null> {
38
+ assertResourceKey(key);
39
+ for (const dir of resourceChain(service, root)) {
40
+ const path = join(dir, key);
41
+ if (await getActiveBlobStore().exists(path)) return path;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /** Size of a resource payload at this branch or its ancestors, or null. */
47
+ export async function resourceBlobSize(service: string, key: string, root?: string): Promise<number | null> {
48
+ const path = await resolveResourceBlob(service, key, root);
49
+ return path === null ? null : getActiveBlobStore().size(path);
50
+ }
51
+
52
+ /** A byte range of a resource payload at this branch or its ancestors (a ranged read where the
53
+ * store has one), or null when absent. */
54
+ export async function readResourceBlobRange(service: string, key: string, start: number, endInclusive: number, root?: string): Promise<Uint8Array | null> {
55
+ const path = await resolveResourceBlob(service, key, root);
56
+ return path === null ? null : readBlobRange(path, start, endInclusive);
57
+ }
@@ -0,0 +1,479 @@
1
+ // THE scenario engine — System 2 of the twin programming model (one grammar, per-pack
2
+ // vocabulary). See company-repo BRIEFS/TWIN-PROGRAMMING-MODEL.md (LOCKED, 2026-08-27).
3
+ //
4
+ // A HANDLER is an MSW-shaped data rule: { on, respond, once?, scope?, phase?, advancePhase? }.
5
+ // Handlers are evaluated IN ORDER; the FIRST handler whose `on` conditions ALL hold fires.
6
+ // No match → the caller serves its labeled deterministic stub and records the MISS (with the
7
+ // request's extracted features — the authoring signal). The handler FILE in the world dir is
8
+ // the only write surface; `engine.use(...)` exists for in-process tests only (LIFO over the
9
+ // baseline, removable). There are NO runtime write doors — a running world is never mutated.
10
+ //
11
+ // The GRAMMAR (structure, ordering, once/scope/phase, strict validation, extractors,
12
+ // placeholders, miss records) is this module's and identical for every vendor. The
13
+ // VOCABULARY (which `on` keys exist and how each matches; what `respond` may contain; which
14
+ // routes are stateful and therefore refuse success-shaped handlers) is the pack's, declared
15
+ // through a PackScenarioAdapter. Determinism: the engine is a pure state machine — same
16
+ // handler list + same request sequence → same decisions, byte for byte.
17
+ //
18
+ // STRICT EVERYWHERE (the gemini discipline): unknown top-level keys, unknown `on` keys,
19
+ // unknown placeholder names, malformed extractors — all THROW with the valid vocabulary in
20
+ // the message. A typo must fail loudly at load, never silently mis-match at serve.
21
+
22
+ /** A vendor-agnostic bag of facts about one request, produced by the pack's adapter. Powers
23
+ * matching context, miss records (the authoring signal), and the self-teaching stub text. */
24
+ export type ScenarioFeatures = Record<string, string | number | boolean | readonly string[]>;
25
+
26
+ /** One matcher: does THIS request satisfy `condition`? Pure — no state, no IO. */
27
+ export type ScenarioMatcher<Req> = (req: Req, condition: unknown) => boolean;
28
+
29
+ /** The pack's declaration of its vocabulary — the ONLY vendor-specific surface. */
30
+ export type PackScenarioAdapter<Req> = {
31
+ /** Vendor key, e.g. "anthropic" — used in errors and the manifest. */
32
+ vendor: string;
33
+ /** Extract the feature bag for miss records / stub teaching. Pure. */
34
+ features: (req: Req) => ScenarioFeatures;
35
+ /** The legal `on` keys and their per-request semantics. Pure. */
36
+ matchers: Record<string, ScenarioMatcher<Req>>;
37
+ /** Validate `on` CONDITION VALUES at load (key membership is the kernel's; VALUE typing is
38
+ * the pack's — "a string nthCall silently never matches" is exactly the misfire strict
39
+ * loading exists to prevent). Return an error string to refuse. */
40
+ validateOn?: (on: Record<string, unknown>) => string | null;
41
+ /** Validate a handler's `respond` payload at load; return an error string to refuse.
42
+ * This is ALSO where a pack refuses success-shaped handlers on stateful routes
43
+ * ("seed that through the vendor's API instead"). */
44
+ validateRespond?: (respond: unknown, handler: ScenarioHandler) => string | null;
45
+ /** The request's text corpus for `textPattern` extractors (packs with text requests). */
46
+ text?: (req: Req) => string;
47
+ /** Pack-defined extractor KINDS beyond the builtins (feature, textPattern). A pack kind with
48
+ * a builtin's name OVERRIDES the builtin (e.g. a richer textPattern with flags/group).
49
+ * validate returns an error string to refuse the spec at load; extract runs at serve time
50
+ * and throws ScenarioError when the request cannot supply the value. */
51
+ extractorKinds?: Record<string, {
52
+ validate: (spec: Record<string, unknown>, name: string) => string | null;
53
+ extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number;
54
+ }>;
55
+ /** The per-session scope discriminator; omitted → all requests share one "world" scope. */
56
+ scopeKey?: (req: Req) => string;
57
+ /** The VENDOR'S error envelope for a `status` fault — a 429 from this vendor looks like this
58
+ * vendor's 429. Omitted → the kernel's envelope `{ error: { message, type: "twin_fault" } }`. */
59
+ renderFault?: (fault: ScenarioStatusFault, req: Req) => { body: unknown; headers?: Record<string, string> };
60
+ };
61
+
62
+ /** THE FAULT VOCABULARY (runtime contract R15 — outage rehearsal as a world-level value). One
63
+ * grammar every pack's engine honors, so a 429 or a hung request is authored the same way for
64
+ * every vendor instead of improvised per realizer:
65
+ * slow — hold the answer `ms` before serving it (the response is whatever the handler's
66
+ * `respond` or the twin's own path produces; timing is not content, R9 holds);
67
+ * status — answer this HTTP status instead of serving: a 4xx/5xx the vendor's own envelope
68
+ * (the pack's adapter renders it; a kernel envelope when it declares none), with
69
+ * `Retry-After` when `retryAfterSeconds` is set;
70
+ * drop — never answer: the request hangs until the client's own timeout ends it (what an
71
+ * outage looks like from the client; a TCP reset is not something a fetch can do),
72
+ * released after `holdMs` (default 300 000) so a socket is not held forever. */
73
+ export type ScenarioFault =
74
+ | { kind: "slow"; ms: number }
75
+ | { kind: "status"; status: number; retryAfterSeconds?: number; message?: string }
76
+ | { kind: "drop"; holdMs?: number };
77
+ export type ScenarioStatusFault = Extract<ScenarioFault, { kind: "status" }>;
78
+ /** What a `status` fault serves: the pack's rendered envelope, or the kernel's. */
79
+ export type ScenarioFaultResult = { status: number; headers: Record<string, string>; body: unknown };
80
+
81
+ export type ScenarioHandler = {
82
+ /** Stable id for status/telemetry; defaults to `handler-<1-based index>`. */
83
+ id?: string;
84
+ /** Conditions — ALL must hold. `{}` matches every request (an ordered catch-all). */
85
+ on: Record<string, unknown>;
86
+ /** Pack-realized response content (the pack's realizer builds the faithful envelope).
87
+ * Optional only on a handler that carries a `fault` (a `slow` may still carry one; a
88
+ * `status` or `drop` serves no content, so it must not). */
89
+ respond?: unknown;
90
+ /** A fault to inject when this handler fires (ScenarioFault). Same once/phase/scope
91
+ * semantics as any handler — a mid-story outage is `{on, fault, once}` or `{phase}`. */
92
+ fault?: ScenarioFault;
93
+ /** Fire at most once per scope. */
94
+ once?: boolean;
95
+ /** Reserved: "world" (default) | "session" — with "session", once/phase state is per
96
+ * scopeKey instead of shared. */
97
+ scope?: "world" | "session";
98
+ /** Fires only while the scope's phase equals this. Handlers without `phase` fire in any. */
99
+ phase?: string;
100
+ /** On fire, move the scope's phase — the tiny sequencing primitive that replaces
101
+ * linear scripts and nthCall arithmetic. */
102
+ advancePhase?: string;
103
+ };
104
+
105
+ /** Request-derived values a `respond` payload may reference as "{{name}}" placeholders —
106
+ * builtin kinds, or any kind the pack's adapter declares. Payload placeholders support the
107
+ * numeric transforms `{{name|min:N}}` / `{{name|max:N}}`; a whole-string placeholder yields
108
+ * the TYPED value (numbers stay numbers). */
109
+ export type ScenarioExtractorSpec =
110
+ | { kind: "feature"; feature: string }
111
+ | { kind: "textPattern"; pattern: string; as?: "string" | "number" }
112
+ | ({ kind: string } & Record<string, unknown>);
113
+
114
+ export type ScenarioDocument = {
115
+ extractors?: Record<string, ScenarioExtractorSpec>;
116
+ handlers: ScenarioHandler[];
117
+ };
118
+
119
+ export type ScenarioMissRecord = { features: ScenarioFeatures; phase: string | undefined };
120
+
121
+ export type ScenarioDecision =
122
+ | { kind: "handler"; handler: ScenarioHandler; respond: unknown; ruleId: string; fault?: ScenarioFault }
123
+ | { kind: "miss"; miss: ScenarioMissRecord };
124
+
125
+ export type ScenarioStatus = {
126
+ vendor: string;
127
+ handlers: Array<{ id: string; phase?: string; once?: boolean; scope?: string; matches: number; source: "file" | "use" }>;
128
+ misses: number;
129
+ recentMisses: ScenarioMissRecord[];
130
+ };
131
+
132
+ export class ScenarioError extends Error {}
133
+
134
+ // `$comment` is allowed (and ignored) at document and handler level — JSON has no comments
135
+ // and scenario files are hand-authored story documents.
136
+ const HANDLER_KEYS = new Set(["id", "on", "respond", "fault", "once", "scope", "phase", "advancePhase", "$comment"]);
137
+ const DOCUMENT_KEYS = new Set(["extractors", "handlers", "$comment"]);
138
+
139
+ /** Strict-loud parse of a scenario DOCUMENT (the per-vendor handlers/<vendor>.json content).
140
+ * The caller does file IO; this validates. Every refusal names the valid vocabulary. */
141
+ export function parseScenarioDocument<Req>(raw: unknown, adapter: PackScenarioAdapter<Req>): ScenarioDocument {
142
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ScenarioError(`${adapter.vendor} scenario: the document is an object { extractors?, handlers }`);
143
+ const doc = raw as Record<string, unknown>;
144
+ for (const key of Object.keys(doc)) {
145
+ if (!DOCUMENT_KEYS.has(key)) throw new ScenarioError(`${adapter.vendor} scenario: unknown key "${key}" (valid: ${[...DOCUMENT_KEYS].join(", ")})`);
146
+ }
147
+ const extractors: Record<string, ScenarioExtractorSpec> = {};
148
+ if (doc.extractors !== undefined) {
149
+ if (typeof doc.extractors !== "object" || doc.extractors === null || Array.isArray(doc.extractors)) throw new ScenarioError(`${adapter.vendor} scenario: extractors is an object of named specs`);
150
+ for (const [name, spec] of Object.entries(doc.extractors as Record<string, unknown>)) {
151
+ extractors[name] = parseExtractor(name, spec, adapter);
152
+ }
153
+ }
154
+ if (!Array.isArray(doc.handlers)) throw new ScenarioError(`${adapter.vendor} scenario: handlers is an array`);
155
+ const handlers = (doc.handlers as unknown[]).map((h, i) => parseHandler(h, i, adapter, extractors));
156
+ return { ...(doc.extractors !== undefined ? { extractors } : {}), handlers };
157
+ }
158
+
159
+ function parseExtractor<Req>(name: string, raw: unknown, adapter: PackScenarioAdapter<Req>): ScenarioExtractorSpec {
160
+ if (typeof raw !== "object" || raw === null) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" is an object`);
161
+ const spec = raw as Record<string, unknown>;
162
+ const packKind = typeof spec.kind === "string" ? adapter.extractorKinds?.[spec.kind] : undefined;
163
+ if (packKind !== undefined) {
164
+ const refusal = packKind.validate(spec, name);
165
+ if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": ${refusal}`);
166
+ return spec as ScenarioExtractorSpec;
167
+ }
168
+ if (spec.kind === "feature") {
169
+ if (typeof spec.feature !== "string" || spec.feature.length === 0) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (feature) needs a feature name`);
170
+ for (const key of Object.keys(spec)) if (key !== "kind" && key !== "feature") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
171
+ return { kind: "feature", feature: spec.feature };
172
+ }
173
+ if (spec.kind === "textPattern") {
174
+ if (adapter.text === undefined) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" uses textPattern but this pack exposes no request text`);
175
+ if (typeof spec.pattern !== "string") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}" (textPattern) needs a pattern`);
176
+ try { new RegExp(spec.pattern); } catch (e) { throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": invalid pattern: ${e instanceof Error ? e.message : String(e)}`); }
177
+ if (spec.as !== undefined && spec.as !== "string" && spec.as !== "number") throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": as is "string" or "number"`);
178
+ for (const key of Object.keys(spec)) if (!["kind", "pattern", "as"].includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": unknown key "${key}"`);
179
+ return { kind: "textPattern", pattern: spec.pattern, ...(spec.as !== undefined ? { as: spec.as as "string" | "number" } : {}) };
180
+ }
181
+ throw new ScenarioError(`${adapter.vendor} scenario: extractor "${name}": kind is one of ${[...new Set(["feature", "textPattern", ...Object.keys(adapter.extractorKinds ?? {})])].join(", ")}`);
182
+ }
183
+
184
+ function parseHandler<Req>(raw: unknown, index: number, adapter: PackScenarioAdapter<Req>, extractors: Record<string, ScenarioExtractorSpec>): ScenarioHandler {
185
+ const at = `handler ${index + 1}`;
186
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new ScenarioError(`${adapter.vendor} scenario: ${at} is an object { on, respond, ... }`);
187
+ const h = raw as Record<string, unknown>;
188
+ for (const key of Object.keys(h)) {
189
+ if (!HANDLER_KEYS.has(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown key "${key}" (valid: ${[...HANDLER_KEYS].join(", ")})`);
190
+ }
191
+ if (typeof h.on !== "object" || h.on === null || Array.isArray(h.on)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`on\` is an object of conditions ({} matches all)`);
192
+ // `nthCall` is the one kernel-builtin condition (1-based per-scope call index); every other
193
+ // `on` key is the pack's declared vocabulary.
194
+ const validOn = ["nthCall", ...Object.keys(adapter.matchers)];
195
+ for (const [key, value] of Object.entries(h.on as Record<string, unknown>)) {
196
+ if (!validOn.includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: unknown \`on\` key "${key}" (valid here: ${validOn.join(", ")})`);
197
+ if (key === "nthCall" && (!Number.isInteger(value) || (value as number) < 1)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: nthCall is a positive integer`);
198
+ }
199
+ const onRefusal = adapter.validateOn?.(h.on as Record<string, unknown>);
200
+ if (onRefusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${onRefusal}`);
201
+ if (h.fault !== undefined) {
202
+ const f = h.fault as Record<string, unknown>;
203
+ if (typeof f !== "object" || f === null || Array.isArray(f)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault is an object { kind: "slow" | "status" | "drop", ... }`);
204
+ const kinds = ["slow", "status", "drop"];
205
+ if (!kinds.includes(f.kind as string)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.kind is one of ${kinds.join(", ")}, got ${JSON.stringify(f.kind)}`);
206
+ const keysFor: Record<string, string[]> = { slow: ["kind", "ms"], status: ["kind", "status", "retryAfterSeconds", "message"], drop: ["kind", "holdMs"] };
207
+ for (const key of Object.keys(f)) if (!keysFor[f.kind as string]!.includes(key)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.${key} is not a key of a ${String(f.kind)} fault (valid: ${keysFor[f.kind as string]!.join(", ")})`);
208
+ if (f.kind === "slow" && !(Number.isInteger(f.ms) && (f.ms as number) >= 0 && (f.ms as number) <= 600_000)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.ms is an integer 0..600000 (milliseconds)`);
209
+ if (f.kind === "status" && !(Number.isInteger(f.status) && (f.status as number) >= 400 && (f.status as number) <= 599)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.status is an integer 400..599 — a fault is a refusal or a failure, never a success`);
210
+ if (f.kind === "status" && f.retryAfterSeconds !== undefined && !(Number.isInteger(f.retryAfterSeconds) && (f.retryAfterSeconds as number) >= 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.retryAfterSeconds is a non-negative integer`);
211
+ if (f.kind === "status" && f.message !== undefined && typeof f.message !== "string") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.message is a string`);
212
+ if (f.kind === "drop" && f.holdMs !== undefined && !(Number.isInteger(f.holdMs) && (f.holdMs as number) >= 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: fault.holdMs is a non-negative integer`);
213
+ if (f.kind !== "slow" && "respond" in h) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: a ${String(f.kind)} fault serves no content — drop \`respond\` (only a slow fault may carry one)`);
214
+ }
215
+ if (!("respond" in h) && h.fault === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: \`respond\` is required (or a \`fault\`)`);
216
+ if (h.id !== undefined && (typeof h.id !== "string" || h.id.length === 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: id is a non-empty string`);
217
+ if (h.once !== undefined && typeof h.once !== "boolean") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: once is boolean`);
218
+ if (h.scope !== undefined && h.scope !== "world" && h.scope !== "session") throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope is "world" or "session"`);
219
+ if (h.scope === "session" && adapter.scopeKey === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: scope "session" but this pack derives no session key`);
220
+ for (const key of ["phase", "advancePhase"] as const) {
221
+ if (h[key] !== undefined && (typeof h[key] !== "string" || (h[key] as string).length === 0)) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${key} is a non-empty string`);
222
+ }
223
+ for (const name of "respond" in h ? placeholderNames(h.respond) : []) {
224
+ if (extractors[name] === undefined) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: respond references "{{${name}}}" but no extractor "${name}" is declared`);
225
+ }
226
+ const refusal = "respond" in h ? adapter.validateRespond?.(h.respond, h as ScenarioHandler) : null;
227
+ if (refusal) throw new ScenarioError(`${adapter.vendor} scenario: ${at}: ${refusal}`);
228
+ return h as ScenarioHandler;
229
+ }
230
+
231
+ const PLACEHOLDER_SRC = "\\{\\{([a-zA-Z0-9_.-]+)(\\|(?:min|max):-?[\\d.]+)?\\}\\}";
232
+
233
+ function placeholderNames(value: unknown, out: Set<string> = new Set()): Set<string> {
234
+ if (typeof value === "string") {
235
+ for (const m of value.matchAll(new RegExp(PLACEHOLDER_SRC, "g"))) out.add(m[1]!);
236
+ // A lone unparseable {{...}} is almost certainly a typo'd placeholder — fail loudly.
237
+ const braces = /\{\{[^}]*\}\}/g.exec(value);
238
+ if (braces && !new RegExp(`^${PLACEHOLDER_SRC}$`).test(braces[0])) {
239
+ throw new ScenarioError(`malformed placeholder ${JSON.stringify(braces[0])} (expected {{name}} or {{name|min:N}}/{{name|max:N}})`);
240
+ }
241
+ } else if (Array.isArray(value)) {
242
+ for (const v of value) placeholderNames(v, out);
243
+ } else if (typeof value === "object" && value !== null) {
244
+ for (const v of Object.values(value)) placeholderNames(v, out);
245
+ }
246
+ return out;
247
+ }
248
+
249
+ function adapterKind<Req>(adapter: PackScenarioAdapter<Req>, kind: string): { validate: (spec: Record<string, unknown>, name: string) => string | null; extract: (spec: Record<string, unknown>, req: Req, name: string) => string | number } | undefined {
250
+ return adapter.extractorKinds?.[kind];
251
+ }
252
+
253
+ /** `{{name|min:N}}` / `{{name|max:N}}` — numeric clamps on an extracted value. */
254
+ function applyTransform(value: string | number, transform: string | undefined, vendor: string, where: string): string | number {
255
+ if (!transform) return value;
256
+ const [op, rawN] = transform.slice(1).split(":") as [string, string];
257
+ if (typeof value !== "number") throw new ScenarioError(`${vendor} scenario: ${where} applies |${op}:${rawN} to a non-numeric extractor value ${JSON.stringify(value)}`);
258
+ const n = Number(rawN);
259
+ return op === "min" ? Math.min(value, n) : Math.max(value, n);
260
+ }
261
+
262
+ type ScopeState = { calls: number; phase: string | undefined; onceFired: Set<string> };
263
+ type Registered = { handler: ScenarioHandler; id: string; source: "file" | "use"; matches: number };
264
+
265
+ const MISS_KEEP = 20;
266
+
267
+ /** The engine: pure state machine over registered handlers. One instance per twin server. */
268
+ export class ScenarioEngine<Req> {
269
+ private readonly adapter: PackScenarioAdapter<Req>;
270
+ private readonly extractors: Record<string, ScenarioExtractorSpec>;
271
+ private baseline: Registered[] = [];
272
+ private overrides: Registered[] = [];
273
+ private scopes = new Map<string, ScopeState>();
274
+ private missCount = 0;
275
+ private recentMisses: ScenarioMissRecord[] = [];
276
+
277
+ constructor(adapter: PackScenarioAdapter<Req>, document?: ScenarioDocument) {
278
+ this.adapter = adapter;
279
+ this.extractors = document?.extractors ?? {};
280
+ if (document) {
281
+ this.baseline = document.handlers.map((handler, i) => ({ handler, id: handler.id ?? `handler-${i + 1}`, source: "file", matches: 0 }));
282
+ }
283
+ }
284
+
285
+ /** In-process test overrides: LIFO over the baseline; returns a remover. NOT a runtime
286
+ * door — nothing outside this process can reach it, by design. */
287
+ use(...handlers: ScenarioHandler[]): () => void {
288
+ const parsed = handlers.map((h, i) => parseHandler(h, i, this.adapter, this.extractors));
289
+ const registered: Registered[] = parsed.map((handler, i) => ({ handler, id: handler.id ?? `use-${this.overrides.length + i + 1}`, source: "use", matches: 0 }));
290
+ this.overrides = [...registered, ...this.overrides]; // LIFO: newest first
291
+ return () => { this.overrides = this.overrides.filter((r) => !registered.includes(r)); };
292
+ }
293
+
294
+ /** Decide one request. Mutates scope state (calls, once, phase) exactly like serving. */
295
+ next(req: Req): ScenarioDecision {
296
+ const scopeOf = (h: ScenarioHandler): string => (h.scope === "session" ? `session:${this.adapter.scopeKey!(req)}` : "world");
297
+ // The call counter ticks once per request on the WORLD scope (and the session scope when
298
+ // one exists) — before matching, so nthCall-style matchers see 1-based "this call".
299
+ this.scope("world").calls += 1;
300
+ if (this.adapter.scopeKey) this.scope(`session:${this.adapter.scopeKey(req)}`).calls += 1;
301
+ for (const r of [...this.overrides, ...this.baseline]) {
302
+ const scope = this.scope(scopeOf(r.handler));
303
+ if (r.handler.phase !== undefined && scope.phase !== r.handler.phase) continue;
304
+ if (r.handler.once && scope.onceFired.has(r.id)) continue;
305
+ if (!this.matches(r.handler, req, scope)) continue;
306
+ if (r.handler.once) scope.onceFired.add(r.id);
307
+ if (r.handler.advancePhase !== undefined) scope.phase = r.handler.advancePhase;
308
+ r.matches += 1;
309
+ // `ruleId` is the row's STABLE id — the authored `id` or the parser's `handler-<n>`
310
+ // default — the same name the status door reports, so a pack stamping the fired rule
311
+ // (deepgram's `scenario_rule`) and the operator reading /twin/scenario see one vocabulary.
312
+ try {
313
+ return { kind: "handler", handler: r.handler, respond: "respond" in r.handler ? this.substitute(r.handler.respond, req) : undefined, ruleId: r.id, ...(r.handler.fault !== undefined ? { fault: r.handler.fault } : {}) };
314
+ } catch (e) {
315
+ // The handler that demanded the value is named: an authoring fault is fixed by finding it.
316
+ if (e instanceof ScenarioError) throw new ScenarioError(`${e.message} [${r.id}${typeof (r.handler as { $comment?: unknown }).$comment === "string" ? `: ${String((r.handler as { $comment?: string }).$comment).slice(0, 80)}` : ""}]`);
317
+ throw e;
318
+ }
319
+ }
320
+ const miss: ScenarioMissRecord = { features: this.adapter.features(req), phase: this.scope("world").phase };
321
+ this.missCount += 1;
322
+ this.recentMisses = [...this.recentMisses.slice(-(MISS_KEEP - 1)), miss];
323
+ return { kind: "miss", miss };
324
+ }
325
+
326
+ /** Decide one request AND honor its fault: a `slow` is awaited here and the decision is
327
+ * returned for the pack to serve; a `status` returns the rendered refusal to serve as-is; a
328
+ * `drop` never resolves within its hold. The one call a realizer makes so every vendor's
329
+ * outage reads the same. */
330
+ async serve(req: Req, signal?: AbortSignal): Promise<ScenarioDecision | { kind: "fault"; result: ScenarioFaultResult; ruleId: string }> {
331
+ throwIfScenarioAborted(signal);
332
+ const decision = this.next(req);
333
+ throwIfScenarioAborted(signal);
334
+ if (decision.kind !== "handler" || decision.fault === undefined) return decision;
335
+ const result = await scenarioFaultResult(decision.fault, () => this.adapter.renderFault?.(decision.fault as ScenarioStatusFault, req), signal);
336
+ throwIfScenarioAborted(signal);
337
+ return result === null ? decision : { kind: "fault", result, ruleId: decision.ruleId };
338
+ }
339
+
340
+ /** For GET /twin/scenario — active handlers with match counts, and the recent misses. */
341
+ status(): ScenarioStatus {
342
+ const row = (r: Registered) => ({ id: r.id, ...(r.handler.phase !== undefined ? { phase: r.handler.phase } : {}), ...(r.handler.once !== undefined ? { once: r.handler.once } : {}), ...(r.handler.scope !== undefined ? { scope: r.handler.scope } : {}), matches: r.matches, source: r.source });
343
+ return { vendor: this.adapter.vendor, handlers: [...this.overrides, ...this.baseline].map(row), misses: this.missCount, recentMisses: [...this.recentMisses] };
344
+ }
345
+
346
+ private scope(key: string): ScopeState {
347
+ let s = this.scopes.get(key);
348
+ if (!s) { s = { calls: 0, phase: undefined, onceFired: new Set() }; this.scopes.set(key, s); }
349
+ return s;
350
+ }
351
+
352
+ private matches(handler: ScenarioHandler, req: Req, scope: ScopeState): boolean {
353
+ for (const [key, condition] of Object.entries(handler.on)) {
354
+ if (key === "nthCall") { if (scope.calls !== condition) return false; continue; }
355
+ const matcher = this.adapter.matchers[key];
356
+ if (!matcher) return false; // unreachable post-parse; belt over braces
357
+ if (!matcher(req, condition)) return false;
358
+ }
359
+ return true;
360
+ }
361
+
362
+ private extractValue(name: string, req: Req, cache: Map<string, string | number>): string | number {
363
+ const cached = cache.get(name);
364
+ if (cached !== undefined) return cached;
365
+ const spec = this.extractors[name]!;
366
+ const packKind = adapterKind(this.adapter, spec.kind);
367
+ let value: string | number;
368
+ if (packKind !== undefined) {
369
+ value = packKind.extract(spec as Record<string, unknown>, req, name);
370
+ } else if (spec.kind === "feature") {
371
+ const v = this.adapter.features(req)[(spec as { feature: string }).feature];
372
+ if (v === undefined) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": the request has no feature "${(spec as { feature: string }).feature}"`);
373
+ value = typeof v === "number" || typeof v === "string" ? v : String(v);
374
+ } else {
375
+ const text = this.adapter.text!(req);
376
+ const tp = spec as { pattern: string; as?: string };
377
+ const m = new RegExp(tp.pattern).exec(text);
378
+ if (!m) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": pattern did not match the request (the handler demanded a value the request never stated)`);
379
+ const captured = m[1] ?? m[0]!;
380
+ if (tp.as === "number") {
381
+ const n = Number(captured);
382
+ if (Number.isNaN(n)) throw new ScenarioError(`${this.adapter.vendor} scenario: placeholder "{{${name}}}": captured "${captured}" is not a number`);
383
+ value = n;
384
+ } else value = captured;
385
+ }
386
+ cache.set(name, value);
387
+ return value;
388
+ }
389
+
390
+ private substitute(value: unknown, req: Req, cache: Map<string, string | number> = new Map()): unknown {
391
+ if (typeof value === "string") {
392
+ // A WHOLE-string placeholder yields the typed value (numbers stay numbers).
393
+ const whole = new RegExp(`^${PLACEHOLDER_SRC}$`).exec(value);
394
+ if (whole) return applyTransform(this.extractValue(whole[1]!, req, cache), whole[2], this.adapter.vendor, value);
395
+ return value.replace(new RegExp(PLACEHOLDER_SRC, "g"), (all, name: string, transform: string | undefined) =>
396
+ String(applyTransform(this.extractValue(name, req, cache), transform, this.adapter.vendor, all)));
397
+ }
398
+ if (Array.isArray(value)) return value.map((v) => this.substitute(v, req, cache));
399
+ if (typeof value === "object" && value !== null) {
400
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.substitute(v, req, cache)]));
401
+ }
402
+ return value;
403
+ }
404
+ }
405
+
406
+ /** The GET /twin manifest for a STATEFUL twin (no scenario engine): what it stores, how
407
+ * state and identity work, where behavior scripting actually lives. Education ships
408
+ * INSIDE the twin — in-world agents have no repos or skills, only HTTP. */
409
+ /** Honor a fault outside the engine (hand-written serve paths): `slow` waits and returns null
410
+ * (serve normally now); `status` returns what to serve; `drop` holds the request for `holdMs`
411
+ * (default 300 000) and then throws, so the socket is released and nothing is ever answered. */
412
+ function throwIfScenarioAborted(signal?: AbortSignal): void {
413
+ signal?.throwIfAborted();
414
+ }
415
+
416
+ function waitForScenarioFault(ms: number, signal?: AbortSignal): Promise<void> {
417
+ throwIfScenarioAborted(signal);
418
+ return new Promise<void>((resolve, reject) => {
419
+ const cleanup = () => signal?.removeEventListener('abort', onAbort);
420
+ const onAbort = () => {
421
+ clearTimeout(timer);
422
+ cleanup();
423
+ reject(signal?.reason);
424
+ };
425
+ const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
426
+ signal?.addEventListener('abort', onAbort, { once: true });
427
+ if (signal?.aborted) onAbort();
428
+ });
429
+ }
430
+
431
+ export async function scenarioFaultResult(fault: ScenarioFault, render?: () => { body: unknown; headers?: Record<string, string> } | undefined, signal?: AbortSignal): Promise<ScenarioFaultResult | null> {
432
+ throwIfScenarioAborted(signal);
433
+ if (fault.kind === "slow") { await waitForScenarioFault(fault.ms, signal); throwIfScenarioAborted(signal); return null; }
434
+ if (fault.kind === "drop") {
435
+ await waitForScenarioFault(fault.holdMs ?? 300_000, signal);
436
+ throwIfScenarioAborted(signal);
437
+ throw new ScenarioError("scenario drop: the request was held unanswered for its hold and is now released without a response");
438
+ }
439
+ const rendered = render?.();
440
+ throwIfScenarioAborted(signal);
441
+ const headers: Record<string, string> = { ...(rendered?.headers ?? {}) };
442
+ if (fault.retryAfterSeconds !== undefined) headers["retry-after"] = String(fault.retryAfterSeconds);
443
+ return { status: fault.status, headers, body: rendered?.body ?? { error: { message: fault.message ?? `twin fault: ${fault.status}`, type: "twin_fault" } } };
444
+ }
445
+
446
+ export function statefulTwinManifest(input: { vendor: string; twinOf: string; stores: string; identity?: string; notes?: string }): Record<string, unknown> {
447
+ return {
448
+ twin: true,
449
+ vendor: input.vendor,
450
+ twinOf: input.twinOf,
451
+ program: {
452
+ state: `Stateful: it stores ${input.stores}. Create state through the vendor's OWN API with the real SDK or plain fetch pointed here — there is no fixture language and no write door besides the vendor's.`,
453
+ identity: input.identity ?? 'Authenticate as the vendor does; the twin accepts any non-sentinel credential.',
454
+ time: 'Writes are stamped from the WORLD CLOCK (volter-world clock <world> set/advance) — deterministic history is clock-set, seed, clock-advance.',
455
+ behavior: 'This twin is state, not scripting — answers are functions of what you seeded. Judgment/fault scripting lives on the scripted vendor twins (their GET /twin explains).',
456
+ ...(input.notes ? { notes: input.notes } : {}),
457
+ },
458
+ doors: { manifest: 'GET /twin' },
459
+ };
460
+ }
461
+
462
+ /** The GET /twin manifest — the discovery door's body. Education ships INSIDE the twin:
463
+ * in-world agents have no repos or skills, only HTTP. */
464
+ export function twinManifest(input: { vendor: string; twinOf: string; stateSentence: string; behaviorSentence: string; exampleHandler: ScenarioHandler | null; engine?: ScenarioEngine<never> }): Record<string, unknown> {
465
+ const status = input.engine?.status();
466
+ return {
467
+ twin: true,
468
+ vendor: input.vendor,
469
+ twinOf: input.twinOf,
470
+ program: {
471
+ state: input.stateSentence,
472
+ behavior: input.behaviorSentence,
473
+ invariant: "A handler never fakes a SUCCESS on a route whose data this twin stores — seed that through the vendor's own API instead. Serving is deterministic.",
474
+ ...(input.exampleHandler ? { exampleHandler: input.exampleHandler } : {}),
475
+ },
476
+ doors: { manifest: "GET /twin", scenario: "GET /twin/scenario (read-only: active handlers + match/miss counts)" },
477
+ ...(status ? { activeHandlers: status.handlers.length, misses: status.misses } : {}),
478
+ };
479
+ }