@volter/world-core 3.0.166 → 3.0.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/src/changeset.d.ts +9 -0
  2. package/dist/src/changeset.js +5 -2
  3. package/dist/src/completeness.d.ts +36 -0
  4. package/dist/src/completeness.js +46 -0
  5. package/dist/src/derived-core.d.ts +27 -0
  6. package/dist/src/derived-core.js +31 -2
  7. package/dist/src/events.d.ts +6 -1
  8. package/dist/src/events.js +58 -13
  9. package/dist/src/index.d.ts +4 -2
  10. package/dist/src/index.js +3 -2
  11. package/dist/src/openai-wire.js +0 -1
  12. package/dist/src/pack-fetch.d.ts +1 -3
  13. package/dist/src/pack-fetch.js +103 -43
  14. package/dist/src/runtime.d.ts +5 -2
  15. package/dist/src/runtime.js +3 -1
  16. package/dist/src/scenario-consume.d.ts +43 -0
  17. package/dist/src/scenario-consume.js +111 -0
  18. package/dist/src/scenario-serve.d.ts +16 -0
  19. package/dist/src/scenario-serve.js +47 -0
  20. package/dist/src/scenario-timeline.d.ts +131 -0
  21. package/dist/src/scenario-timeline.js +242 -0
  22. package/dist/src/scenario.d.ts +15 -93
  23. package/dist/src/scenario.js +34 -323
  24. package/dist/src/sockets.d.ts +8 -4
  25. package/dist/src/sockets.js +1 -1
  26. package/dist/src/world-clock.d.ts +3 -0
  27. package/dist/src/world-clock.js +14 -9
  28. package/package.json +1 -1
  29. package/src/changeset.ts +13 -3
  30. package/src/completeness.ts +57 -0
  31. package/src/derived-core.ts +41 -2
  32. package/src/events.ts +58 -11
  33. package/src/index.ts +5 -3
  34. package/src/openai-wire.ts +0 -1
  35. package/src/pack-fetch.ts +81 -39
  36. package/src/runtime.ts +5 -2
  37. package/src/scenario-consume.ts +123 -0
  38. package/src/scenario-serve.ts +47 -0
  39. package/src/scenario-timeline.ts +269 -0
  40. package/src/scenario.ts +43 -341
  41. package/src/sockets.ts +5 -5
  42. package/src/world-clock.ts +16 -9
@@ -45,6 +45,8 @@ import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from
45
45
  import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
46
46
  import { getPack, type PackDescriptor } from './packRegistry.ts';
47
47
  import type { ScenarioDecision, ScenarioHandler } from './scenario.ts';
48
+ import { decide, fact, faultAt, listLag, recordFault, serviceLedger, type ScenarioLedger } from './scenario-consume.ts';
49
+ import { durationMs, placedOn } from './scenario-timeline.ts';
48
50
  import { bodyTextOf, checkJsonBody, takeParsedBody } from './derived.ts';
49
51
 
50
52
  // ── the manifest ─────────────────────────────────────────────────────────────────────────────
@@ -1510,7 +1512,7 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
1510
1512
  const endpoint = (kind: import('./events.ts').EndpointsDecl): import('./events.ts').EndpointsDecl => ({ ...kind, storedAs: storedType(m, kind.storedAs), ...(kind.joins ? { joins: Object.fromEntries(Object.entries(kind.joins).map(([name, join]) => [name, { ...join, storedAs: storedType(m, join.storedAs) }])) } : {}) });
1511
1513
  const events = { ...m.events, ...(m.events.store ? { store: storedType(m, m.events.store) } : {}), endpoints: 'storedAs' in m.events.endpoints ? endpoint(m.events.endpoints) : m.events.endpoints.map(endpoint) };
1512
1514
  const write = { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() };
1513
- await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined, scopeOfCall.get(call)?.application);
1515
+ await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values!(await context(), write, type) : undefined, scopeOfCall.get(call)?.application, m.vendor);
1514
1516
  }
1515
1517
  if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() });
1516
1518
  // a read held until the World writes (held-reads.ts) hears it
@@ -1930,7 +1932,14 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
1930
1932
  }
1931
1933
  case 'list': {
1932
1934
  if (!m.list) return { unmodeled: 'no manifest list configuration' };
1933
- const rows = stored(m, resource, root).filter(underParent).map((r) => view(m, resource, r));
1935
+ // a `lag` the World's scenario placed: a write younger than it is missing from lists, as an eventually consistent
1936
+ // index misses it (architecture, "Scenarios"); recorded when it withholds one
1937
+ const ledger = serviceLedger({ vendor: m.vendor, service: m.service, root, at, recording: !(scope.readOnly === true || isReadOnlyRequest(call.request)) });
1938
+ const lag = listLag(ledger, op.id);
1939
+ const settled = (r: TwinResource): boolean => !lag || Date.parse(r.updatedAt) <= Date.parse(at) - durationMs(lag.by!);
1940
+ const listed = stored(m, resource, root).filter(underParent);
1941
+ if (lag && listed.some((r) => !settled(r))) await recordFault(ledger, lag, { operation: op.id, withheld: listed.filter((r) => !settled(r)).map((r) => r.id) });
1942
+ const rows = listed.filter(settled).map((r) => view(m, resource, r));
1934
1943
  const arranged = arrangeRows(m, resource, params, rows);
1935
1944
  if (!Array.isArray(arranged)) return { served: vendorError(m, arranged) };
1936
1945
  return { served: pageRows(m, op, call, params, arranged, resource, root, paths) };
@@ -2030,6 +2039,16 @@ export type SemanticsContext = {
2030
2039
  /** What the World's scenario decided for this turn (a model vendor's answer: the handler matched, or the miss), on an
2031
2040
  * operation the pack's `semantics/scenario.ts` names; undefined elsewhere, or when the World carries no scenario. */
2032
2041
  scenario: ScenarioDecision | undefined;
2042
+ /** The vendor's judgement at one of its decision points (a fraud score, an identity check, deliverability), for the
2043
+ * subject it is keyed to: what the World's scenario decided, else `byDefault`, the vendor's normal outcome; recorded in
2044
+ * the twin's log either way (architecture, "Scenarios"). The handler follows it with what the vendor does next. */
2045
+ decide<T>(point: string, subject: string, byDefault: T): Promise<T>;
2046
+ /** A fault the World's scenario placed at a point of the pack's own (one item of a batch) for `subject`, recorded as
2047
+ * landed; undefined when none is placed. */
2048
+ fault(point: string, subject: string): Promise<{ kind: string; status?: number; message?: string; errorType?: string } | undefined>;
2049
+ /** A World fact (an exchange rate, a holiday, a place) the twin answers with in its own shape, recorded as answered;
2050
+ * undefined when the World holds none. */
2051
+ fact(kind: string, key: string): Promise<unknown>;
2033
2052
  /** A message the vendor sends the application's server and decides by its answer, waited on for at most `within`
2034
2053
  * milliseconds (a real-time authorization request): its status and body, or why there was none. */
2035
2054
  ask(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer>;
@@ -2245,6 +2264,21 @@ const coreReadsBody = (type: string | null): boolean => {
2245
2264
  const media = (type ?? '').split(';')[0]!.trim().toLowerCase();
2246
2265
  return media === '' || media === 'application/json' || media.endsWith('+json') || media === 'application/x-www-form-urlencoded' || media.startsWith('multipart/') || media.startsWith('text/');
2247
2266
  };
2267
+ const ledgers = new WeakMap<object, () => ScenarioLedger>();
2268
+ /** What a context consumes the World's scenario through (scenario-consume.ts): its twin's bookkeeping, at its time. */
2269
+ export function scenarioLedgerOf(ctx: object): ScenarioLedger {
2270
+ const ledger = ledgers.get(ctx);
2271
+ if (!ledger) throw new Error('a scenario ledger is a context the kernel opened');
2272
+ return ledger();
2273
+ }
2274
+
2275
+ /** `GET /twin/scenario`: what the World's scenario placed on this vendor, and what the twin consumed of it. */
2276
+ export function scenarioRecords(m: DerivedManifest, root?: string): { vendor: string; placed: unknown[]; consumed: { decisions: unknown[]; faults: unknown[] } } {
2277
+ const placed = placedOn(m.vendor);
2278
+ const kept = (type: string) => resourcesOfType(m.service, storedType(m, type), root).map((r) => ownFields(r));
2279
+ return { vendor: m.vendor, placed, consumed: { decisions: kept('_decision'), faults: kept('_fault') } };
2280
+ }
2281
+
2248
2282
  export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScope): Promise<SemanticsContext> {
2249
2283
  const root = scope.root;
2250
2284
  const asAsked = call;
@@ -2301,6 +2335,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2301
2335
  const row = context.row(link.resource, link.id);
2302
2336
  return row && row.deleted !== true ? row : undefined;
2303
2337
  };
2338
+ const ledger = (): ScenarioLedger => ({ vendor: m.vendor, service: m.service, root, at, recording: !(scope.readOnly === true || isReadOnlyRequest(call.request)), rows: (type) => context.rowsRaw(type), record: (type, fields, id) => context.record(type, fields, id) });
2304
2339
  const context: SemanticsContext = {
2305
2340
  call,
2306
2341
  params,
@@ -2310,6 +2345,9 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2310
2345
  root,
2311
2346
  publicBase: twinPublicBase(call.request),
2312
2347
  scenario: scenarioDecisionOf(call.request),
2348
+ decide: (point, subject, byDefault) => decide(ledger(), point, subject, byDefault),
2349
+ fault: (point, subject) => faultAt(ledger(), point, subject),
2350
+ fact: (kind, key) => fact(ledger(), kind, key),
2313
2351
  ask: (url, init, within) => askApplication(url, init, within, scope.application),
2314
2352
  worldEnv: (name) => worldEnvValue(name),
2315
2353
  asVendor: (fn) => runAsVendorMove(fn),
@@ -2570,6 +2608,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2570
2608
  batch: (fn) => fn(),
2571
2609
  };
2572
2610
  if (batchable(m, root)) bindBatch(m, call, context, params, root, at, rawRow);
2611
+ ledgers.set(context, ledger);
2573
2612
  return context;
2574
2613
  }
2575
2614
 
package/src/events.ts CHANGED
@@ -14,6 +14,9 @@ import { deliveryTraceHeaders } from './trace-context.ts';
14
14
  import { sendRecordedEvent } from './event-delivery.ts';
15
15
  import { getActiveWorldStore, withWorldStore, type WorldStore } from './world-store.ts';
16
16
  import { worldPaths } from './storage.ts';
17
+ import { worldNow } from './world-clock.ts';
18
+ import { recordFault, serviceLedger, webhookFault } from './scenario-consume.ts';
19
+ import { durationMs } from './scenario-timeline.ts';
17
20
 
18
21
  /** How the vendor signs a delivery. */
19
22
  export type EventScheme =
@@ -390,6 +393,7 @@ export async function deliverEvents(
390
393
  transport: EventTransport = worldTransport,
391
394
  values?: (type: string) => Promise<Record<string, unknown>>,
392
395
  application?: ApplicationStandIn,
396
+ vendor?: string,
393
397
  ): Promise<EventDelivery[]> {
394
398
  if (write.storedType === decl.store) return [];
395
399
  const dispatch: EventTransport = application ? async (url, body, headers) => {
@@ -397,7 +401,7 @@ export async function deliverEvents(
397
401
  return answer === undefined ? transport(url, body, headers) : answer === 'unreachable' ? 0 : answer.status;
398
402
  } : transport;
399
403
  const sent: EventDelivery[] = [];
400
- for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, dispatch, values, type)));
404
+ for (const type of eventTypesOf(decl, write.operation)) sent.push(...(await deliverEventType(service, decl, write, data, dispatch, values, type, vendor)));
401
405
  return sent;
402
406
  }
403
407
 
@@ -410,6 +414,7 @@ async function deliverEventType(
410
414
  transport: EventTransport,
411
415
  values: ((type: string) => Promise<Record<string, unknown>>) | undefined,
412
416
  type: string,
417
+ vendor: string | undefined,
413
418
  ): Promise<EventDelivery[]> {
414
419
  const all = twinResources(service, write.root) as unknown as Array<Record<string, unknown>>;
415
420
  const kinds = Array.isArray(decl.endpoints) ? decl.endpoints : [decl.endpoints as EndpointsDecl];
@@ -474,16 +479,33 @@ async function deliverEventType(
474
479
  // kept with the write, by an id of the delivery itself (two made at once never take the same one)
475
480
  const record = decl.record ?? '_world_event_delivery';
476
481
  const id = `${record.slice(1)}_v1_${seq}`;
477
- await applyTwinWrite(service, {
478
- operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id,
479
- fields: { url, body, headers, event_type: type, sent_at: millis,
480
- delivery_protocol: 1, event_id: String(filled.$id),
481
- endpoint_type: kind.storedAs, endpoint_id: String(row.id),
482
- delivery_state: 'queued', attempt_count: 0, current_attempt: null,
483
- transport_status: null, finished_at: null, accepted_at: null,
484
- }, occurredAt: write.occurredAt, actor: { kind: 'system' },
485
- }, write.root);
482
+ const packet = { url, body, headers, event_type: type, sent_at: millis,
483
+ delivery_protocol: 1, event_id: String(filled.$id),
484
+ endpoint_type: kind.storedAs, endpoint_id: String(row.id),
485
+ delivery_state: 'queued', attempt_count: 0, current_attempt: null,
486
+ transport_status: null, finished_at: null, accepted_at: null,
487
+ };
488
+ await applyTwinWrite(service, { operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id, fields: packet, occurredAt: write.occurredAt, actor: { kind: 'system' } }, write.root);
489
+ // a webhook fault the World's scenario placed (architecture, "Scenarios"): dropped, held until the World's clock
490
+ // reaches its delay (sent when the clock's mover reaches it, or by a running clock's timer: releaseHeldDeliveries),
491
+ // or sent twice, recorded as landed
492
+ const ledger = vendor ? serviceLedger({ vendor, service, root: write.root, at: write.occurredAt, recording: true }) : undefined;
493
+ const fault = ledger ? webhookFault(ledger, type) : undefined;
494
+ if (ledger && fault) await recordFault(ledger, fault, { event: type, delivery: id });
495
+ if (fault?.kind === 'webhook-drop' || fault?.kind === 'webhook-delay') {
496
+ const held = fault.kind === 'webhook-delay' ? { delivery_state: 'held', due_at: millis + durationMs(fault.by!) } : { delivery_state: 'dropped' };
497
+ await applyTwinWrite(service, { operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id, fields: held, occurredAt: write.occurredAt, actor: { kind: 'system' } }, write.root);
498
+ sent.pop();
499
+ if (fault.kind === 'webhook-delay') heldListeners.get(worldPaths(service, write.root).root)?.();
500
+ continue;
501
+ }
486
502
  recorded.push({ type: record, id });
503
+ if (fault?.kind === 'webhook-duplicate') {
504
+ const twin = `${id}_duplicate`;
505
+ await applyTwinWrite(service, { operation: `${record.slice(1)}.record`, subjectType: record, subjectId: twin, fields: packet, occurredAt: write.occurredAt, actor: { kind: 'system' } }, write.root);
506
+ sent.push(sent.at(-1)!);
507
+ recorded.push({ type: record, id: twin });
508
+ }
487
509
  }
488
510
  // sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
489
511
  // slow or down fails no request
@@ -493,6 +515,12 @@ async function deliverEventType(
493
515
  // active before it runs. Transport completion writes to the same retained state.
494
516
  const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
495
517
  for (const d of sent) deliveryObserver?.({ ...d, service });
518
+ dispatchRecorded(service, store, deliveryRoot, recorded, transport, trace);
519
+ return sent;
520
+ }
521
+
522
+ /** Each recorded packet sent after the write's answer, keeping its request's store and root. */
523
+ function dispatchRecorded(service: string, store: WorldStore, deliveryRoot: string, recorded: ReadonlyArray<{ type: string; id: string }>, transport: EventTransport, trace: Record<string, string>): void {
496
524
  const roots = pendingDeliveries.get(store) ?? new Map<string, Set<Promise<void>>>();
497
525
  pendingDeliveries.set(store, roots);
498
526
  const pending = roots.get(deliveryRoot) ?? new Set<Promise<void>>();
@@ -516,5 +544,24 @@ async function deliverEventType(
516
544
  }
517
545
  }, 0);
518
546
  }
519
- return sent;
547
+ }
548
+
549
+ const heldListeners = new Map<string, () => void>();
550
+ /** Who hears that a twin held a packet for later (its pack's fetch, which sets the timer a running clock needs). */
551
+ export function onHeldDelivery(service: string, root: string | undefined, listener: () => void): void {
552
+ heldListeners.set(worldPaths(service, root).root, listener);
553
+ }
554
+
555
+ /** Send what a `webhook-delay` held that the World's clock has reached, in the order it fell due, and wait until it is
556
+ * sent; answer the instant the next held packet falls due, if any (ADR 0036 §4: the mover of the clock waits on it). */
557
+ export async function releaseHeldDeliveries(service: string, root: string | undefined, record = '_world_event_delivery', transport: EventTransport = worldTransport): Promise<string | null> {
558
+ const now = Date.parse(worldNow());
559
+ const held = twinResources(service, root).filter((r) => r.type === record && r.deleted !== true && r.delivery_state === 'held').sort((a, b) => Number(a.due_at) - Number(b.due_at));
560
+ const due = held.filter((r) => Number(r.due_at) <= now);
561
+ for (const r of due) await applyTwinWrite(service, { operation: `${record.slice(1)}.release`, subjectType: record, subjectId: r.id, fields: { delivery_state: 'queued' }, occurredAt: worldNow(), actor: { kind: 'system' } }, root);
562
+ const deliveryRoot = worldPaths(service, root).root;
563
+ dispatchRecorded(service, getActiveWorldStore(), deliveryRoot, due.map((r) => ({ type: record, id: r.id })), transport, {});
564
+ await settleEventDeliveries(deliveryRoot);
565
+ const next = held.find((r) => Number(r.due_at) > now);
566
+ return next ? new Date(Number(next.due_at)).toISOString() : null;
520
567
  }
package/src/index.ts CHANGED
@@ -100,9 +100,11 @@ export * as live from './live/index.ts';
100
100
  export * as mongodb from './mongodb/index.ts';
101
101
  export * as git from './git/index.ts';
102
102
 
103
- // ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
104
- export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from './scenario.ts';
105
- export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioExtractorSpec, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord, ScenarioStatus } from './scenario.ts';
103
+ // ── the scenario grammar (architecture, "Scenarios") ───────────────────────────────────────────
104
+ export { scenarioFaultResult, ScenarioError } from './scenario.ts';
105
+ export { parseScenario, resolveScenario, WORLD_SCENARIO_ENV } from './scenario-timeline.ts';
106
+ export type { ResolvedScenario, Scenario } from './scenario-timeline.ts';
107
+ export type { PackScenarioAdapter, ScenarioDecision, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord } from './scenario.ts';
106
108
 
107
109
  export { verifyAttestationBundle } from './attestation.ts';
108
110
 
@@ -350,7 +350,6 @@ export function scenario(vendor: string, operations: string[], faultBody: (statu
350
350
  toolResultFor: (r, c) => nonEmpty(c) && r.results.includes(c),
351
351
  hasTool: (r, c) => nonEmpty(c) && r.tools.includes(c),
352
352
  },
353
- text: (r) => r.text,
354
353
  // source: https://platform.openai.com/docs/api-reference/chat/create "none means the model will not call any tool and instead generates a message"
355
354
  admits: (r, respond) => !(r.toolsForbidden && obj(respond).toolCalls !== undefined),
356
355
  validateOn: (on) => {
package/src/pack-fetch.ts CHANGED
@@ -13,11 +13,16 @@ import { ORIGINAL_PATH_HEADER } from './sigv4.ts';
13
13
  import { placedCookies, TWIN_PREFIX_HEADER } from './twin-fetch.ts';
14
14
  import { PREFIX_PARAMS_HEADER } from './derived.ts';
15
15
  import { withHeaders } from './with-headers.ts';
16
- import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, storedType, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type ScreenDecl, type VendorManifest } from './derived-core.ts';
16
+ import { authRefusal, bindSemantics, contextFor, scenarioLedgerOf, scenarioRecords, LANE_HEADER, SCENARIO_DECISION_HEADER, scenarioDecisionHeader, storedType, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type ScreenDecl, type VendorManifest } from './derived-core.ts';
17
17
  import { compileSurface, createDerivedFetch, matchOperation, pathParams, type DerivedFetch, type DerivedOperation, type DerivedOwner, type DerivedSurface, type Workspace, type BoardFrame } from './derived.ts';
18
18
  import { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, runAsVendorMove } from './request-scope.ts';
19
- import { parseScenarioDocument, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest, type PackScenarioAdapter } from './scenario.ts';
20
- import { getActiveWorldStore } from './world-store.ts';
19
+ import { ScenarioError, statefulTwinManifest, twinManifest, type PackScenarioAdapter, type ScenarioStatusFault } from './scenario.ts';
20
+ import { placedOn, type PlacedFault } from './scenario-timeline.ts';
21
+ import { rateLimited, recordFault, requestFaults, type ScenarioLedger } from './scenario-consume.ts';
22
+ import { faultAnswer, kernelFault, serveTurn } from './scenario-serve.ts';
23
+ import { onHeldDelivery, releaseHeldDeliveries } from './events.ts';
24
+ import { getActiveWorldStore, withWorldStore } from './world-store.ts';
25
+ import { noteCompleteness } from './completeness.ts';
21
26
  import { runWithCorrelationId } from './actions.ts';
22
27
  import { deferPerform, deployableEntries, performRequestGroup, RefusedWriteError, VendorWriteError } from './head.ts';
23
28
  import { FLUSH, ZERO_SHA, pktLine, readPktLines } from './git/smart-http.ts';
@@ -35,7 +40,7 @@ import { liveIngest } from './live/ingest.ts';
35
40
  import type { LiveEngine } from './live/types.ts';
36
41
  import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
37
42
  import { gitRepositoryOf, resourcesOfType } from './derived-core.ts';
38
- import { worldNow } from './world-clock.ts';
43
+ import { worldClockRunning, worldNow } from './world-clock.ts';
39
44
  import type { WebSocketUpgrade, WebSocketPeer } from './serve-http.ts';
40
45
  import { MACHINE_POOL, POOL_KINDS, resetFakePool } from './machines.ts';
41
46
  import { applyTwinWrite } from './serve.ts';
@@ -103,10 +108,9 @@ export type LanePart = {
103
108
  };
104
109
 
105
110
  /** Where a pack serves: its World's root, whether it was started read-only, the World instant when a caller pins it, the
106
- * World's managed Postgres the runtime binds a pack declaring `managedDatabase` to (a handler's `ctx.engine`), and the
107
- * World's scenario file for a model vendor's turns (`scenarioPath`, the runtime's `--scenario`). */
111
+ * and the World's managed Postgres the runtime binds a pack declaring `managedDatabase` to (a handler's `ctx.engine`). */
108
112
  export type PackFetchOptions = {
109
- root?: string; readOnly?: boolean; clock?: () => string; database?: string; scenarioPath?: string;
113
+ root?: string; readOnly?: boolean; clock?: () => string; database?: string;
110
114
  /** the application a walk stands in for: this fetch's requests and declared events are answered by it */
111
115
  application?: ApplicationStandIn;
112
116
  };
@@ -320,6 +324,8 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
320
324
  gap: async (request, operation) => {
321
325
  const refused = m.auth?.beforeRouting ? authRefusal(m, request, options.root) : undefined;
322
326
  if (refused) return refused;
327
+ // an operation this twin does not implement is what the World is missing at this vendor (ADR 0036 §9)
328
+ noteCompleteness(process.env, { kind: 'unimplemented', vendor: m.vendor, method: request.method, path: new URL(request.url).pathname, ...(operation ? { operation: operation.id } : {}) });
323
329
  if (parts.gap) {
324
330
  const url = new URL(request.url);
325
331
  const matched = operation ? matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers) : undefined;
@@ -343,23 +349,30 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
343
349
  if (graphql && gql && request.method === 'POST' && gql.paths.includes(new URL(request.url).pathname.replace(/\/+$/, ''))) return graphql(request);
344
350
  return rest(request);
345
351
  }, { owners: rest.owners });
346
- // the World's scenario, read from the World's store at every turn, HTTP or socket alike: a file the World rewrites takes
347
- // effect at the next turn, with no reboot; the engine (its handlers' counts) is kept while the file is unchanged
348
- let engine: ScenarioEngine<unknown> | undefined;
349
- let engineRaw: string | undefined;
350
- const engineOf = (): ScenarioEngine<unknown> | undefined => {
351
- if (!parts.scenario || options.scenarioPath === undefined) return undefined;
352
- const raw = getActiveWorldStore().read(options.scenarioPath);
353
- if (raw === null) throw new ScenarioError(`${m.vendor} scenario: no file at ${options.scenarioPath}`);
354
- if (!engine || raw !== engineRaw) {
355
- engine = new ScenarioEngine(parts.scenario.adapter, parseScenarioDocument(parseExactJson(raw), parts.scenario.adapter));
356
- engineRaw = raw;
352
+ // the World's scenario (scenario-timeline.ts), read through the World's store at every request as the clock is: a
353
+ // model vendor's turns, and the faults placed on this vendor's requests, consumed and recorded in its own log
354
+ const placesThis = (): boolean => placedOn(m.vendor).length > 0;
355
+ // the work the World's time makes due (ADR 0036 §4): done when the clock's mover asks (`POST /twin/due`) and waits, and
356
+ // under a running clock by one timer set for the instant the next work falls due
357
+ let dueTimer: ReturnType<typeof setTimeout> | undefined;
358
+ const due = async (): Promise<string | null> => {
359
+ if (!m.events) return null;
360
+ const next = await releaseHeldDeliveries(m.service, options.root, m.events.record);
361
+ if (dueTimer !== undefined) { clearTimeout(dueTimer); dueTimer = undefined; }
362
+ const store = getActiveWorldStore();
363
+ if (next !== null && worldClockRunning()) {
364
+ dueTimer = setTimeout(() => { dueTimer = undefined; void withWorldStore(store, due).catch(() => undefined); }, Math.max(0, Date.parse(next) - Date.parse(worldNow())));
365
+ (dueTimer as { unref?: () => void }).unref?.();
357
366
  }
358
- return engine;
367
+ return next;
359
368
  };
360
- /** The API, a turn the scenario decides handed its decision (ctx.scenario), or answered as the fault it names. */
369
+ // a packet held under a running clock sets the timer for when it falls due; so does a twin that starts with held work
370
+ // in its store (what time crossed while it was down is sent at once)
371
+ const arm = (): void => { if (worldClockRunning()) void due().catch(() => undefined); };
372
+ if (m.events) { onHeldDelivery(m.service, options.root, arm); const store = getActiveWorldStore(); queueMicrotask(() => { void withWorldStore(store, async () => arm()); }); }
373
+ /** The API, a turn handed its decision (ctx.scenario), or answered as the fault the World's scenario placed on it. */
361
374
  const decided = async (arrived: Request): Promise<Response> => {
362
- if (!parts.scenario) return api(arrived);
375
+ if (!parts.scenario && !placesThis()) return api(arrived);
363
376
  // the body is read once and the request made again from its bytes, so the turn's context and the API each read their
364
377
  // own. Merge served with --scenario once answered no turn in 8 s (t_c0279b69), and this change came with the cure;
365
378
  // putting the earlier read back (the served request cloned for the context, then forwarded) does not reproduce the
@@ -369,25 +382,52 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
369
382
  const request = new Request(arrived.url, { method: arrived.method, headers: arrived.headers, signal: arrived.signal, ...(bytes ? { body: bytes } : {}) });
370
383
  const url = new URL(request.url);
371
384
  const matched = matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers);
372
- if (!matched || !parts.scenario.operations.includes(matched.operation.id)) return api(request);
373
- // the credential is the vendor's first question: a request it refuses takes no scripted turn
374
- if (!m.auth?.exempt?.includes(matched.operation.id)) {
375
- const refused = authRefusal(m, request, options.root);
376
- if (refused) return refused;
377
- }
378
- let decision;
385
+ if (!matched) return api(request);
386
+ const turn = parts.scenario?.operations.includes(matched.operation.id) === true;
387
+ if (!turn && !placesThis()) return api(request);
388
+ const ctx = await contextFor(m, { request: request.clone(), operation: matched.operation, params: matched.params }, scope);
389
+ const ledger = scenarioLedgerOf(ctx);
390
+ const asked = turn ? parts.scenario!.request(ctx) : undefined;
391
+ /** A fault's answer in the vendor's own envelope: the model wire's, where the pack renders a turn's, else the manifest's
392
+ * error; null when it holds the request (`slow`) and the answer follows. */
393
+ const answerFault = async (fault: PlacedFault): Promise<Response | null> => {
394
+ const placed = kernelFault(fault);
395
+ const adapter = parts.scenario?.adapter;
396
+ if (placed.kind !== 'status' || (asked !== undefined && adapter?.renderFault)) return faultAnswer(fault, asked !== undefined && adapter?.renderFault ? () => adapter.renderFault!(placed as ScenarioStatusFault, asked) : undefined, request.signal);
397
+ const refused = vendorError(m, { status: placed.status, code: placed.errorType ?? 'twin_fault', message: placed.message! });
398
+ if (placed.retryAfterSeconds !== undefined) refused.headers.set('retry-after', String(placed.retryAfterSeconds));
399
+ return refused;
400
+ };
379
401
  try {
380
- const turns = engineOf();
381
- if (!turns) return api(request);
382
- const asked = parts.scenario.request(await contextFor(m, { request: request.clone(), operation: matched.operation, params: matched.params }, scope));
383
- if (asked === undefined) return api(request);
384
- decision = await turns.serve(asked);
402
+ // a rejection at the vendor's edge comes before its credential is read; a rate limit counts every request
403
+ const fault = requestFaults(ledger).find((f) => f.on === undefined && f.point === undefined && (f.operation === undefined || f.operation === matched.operation.id));
404
+ if (fault?.kind === 'rejected') { await recordFault(ledger, fault, { operation: matched.operation.id }); return (await answerFault(fault))!; }
405
+ const limited = await rateLimited(ledger, matched.operation.id);
406
+ if (limited) return (await answerFault(limited))!;
407
+ // the credential is the vendor's first question: a request it refuses takes no turn and meets no other fault
408
+ if ((turn || fault) && !m.auth?.exempt?.includes(matched.operation.id)) {
409
+ const refused = authRefusal(m, request, options.root);
410
+ if (refused) return refused;
411
+ }
412
+ if (fault) {
413
+ await recordFault(ledger, fault, { operation: matched.operation.id });
414
+ // committed, and its answer lost: the write lands, and the app is told it failed
415
+ if (fault.kind === 'lost') { await (await answer(request, asked, ledger)).arrayBuffer(); return (await answerFault({ ...fault, kind: 'failed' }))!; }
416
+ const answered = await answerFault(fault);
417
+ if (answered) return answered;
418
+ }
419
+ return await answer(request, asked, ledger);
385
420
  } catch (error) {
386
421
  if (!(error instanceof ScenarioError)) throw error;
387
422
  return vendorError(m, { status: 500, code: 'scenario_error', message: `the World's scenario: ${error.message}` });
388
423
  }
389
- if (decision.kind === 'fault') return Response.json(decision.result.body, { status: decision.result.status, headers: decision.result.headers ?? {} });
390
- return api(withHeaders(request, new Headers([...request.headers, [SCENARIO_DECISION_HEADER, scenarioDecisionHeader(decision)]])));
424
+ };
425
+ /** The request answered by the API, a turn first decided by the World's scenario. */
426
+ const answer = async (request: Request, asked: unknown, ledger: ScenarioLedger): Promise<Response> => {
427
+ if (asked === undefined) return api(request);
428
+ const served = await serveTurn(asked, ledger, parts.scenario!.adapter, request.signal);
429
+ if (served.kind === 'fault') return Response.json(served.result.body, { status: served.result.status, headers: served.result.headers ?? {} });
430
+ return api(withHeaders(request, new Headers([...request.headers, [SCENARIO_DECISION_HEADER, scenarioDecisionHeader(served)]])));
391
431
  };
392
432
  for (const d of m.doors ?? []) if (!parts.doors?.[d.id]) throw new Error(`${m.vendor}: the manifest declares the door ${d.id}, and semantics/doors.ts exports no ${d.id}`);
393
433
  const door = worldDoorMatcher(m.doors ?? []);
@@ -540,17 +580,19 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
540
580
  const body = twinManifest({
541
581
  vendor: m.vendor, twinOf: d.twinOf,
542
582
  stateSentence: `Stateful: it stores ${d.stores}. Create state through the vendor's OWN API with the real SDK or plain fetch pointed here${discoveryDoors.length ? "; what the vendor does on its own pages (not its API) is a door of the World, listed under doors" : ''}.`,
543
- behaviorSentence: d.behavior, exampleHandler: d.exampleHandler ?? null, ...(engineOf() ? { engine: engineOf() as never } : {}),
583
+ behaviorSentence: d.behavior, exampleHandler: d.exampleHandler ?? null,
544
584
  });
545
585
  const declared = Object.fromEntries(discoveryDoors.map((door) => [`${door.method} ${doorPath(door.path)}`, door.note ?? 'a door of the World']));
546
586
  return Response.json({ ...shown, ...body, program: { ...(body.program as Record<string, unknown>), ...(identity ? { identity } : {}) }, doors: { ...(body.doors as Record<string, string>), ...declared } });
547
587
  }
548
588
  return Response.json({ ...statefulTwinManifest({ vendor: m.vendor, twinOf: d.twinOf, stores: d.stores, ...(identity ? { identity } : {}), ...(d.behavior ? { behavior: d.behavior } : {}), ...(d.notes ? { notes: d.notes } : {}), ...(discoveryDoors.length ? { doors: discoveryDoors.map((door) => ({ ...door, path: doorPath(door.path) })) } : {}) }), ...shown });
549
589
  }
550
- // the scenario's handlers with their match counts, and the recent misses
590
+ // the World's clock has moved: do what it made due, and answer once it is done (ADR 0036 §4)
591
+ if (request.method === 'POST' && path === '/twin/due') return Response.json({ next: await due() });
592
+ // what the World's scenario placed on this vendor, and what the twin consumed of it (its `_decision` and `_fault` records)
551
593
  if (request.method === 'GET' && path === '/twin/scenario') {
552
594
  const lanes = await laneDoor(request, '/twin/scenario');
553
- if (parts.scenario || Object.keys(lanes).length) return Response.json({ ...(engineOf()?.status() ?? { vendor: m.vendor, handlers: [], misses: 0, recentMisses: [] }), ...(Object.keys(lanes).length ? { lanes } : {}) });
595
+ return Response.json({ ...scenarioRecords(m, options.root), ...(Object.keys(lanes).length ? { lanes } : {}) });
554
596
  }
555
597
  // the vendor's own moves, not the caller's: its context carries none of the request's headers (a Stripe-Account
556
598
  // header would make a payout time makes the caller's), but a tenant's, when the vendor keeps tenants apart
@@ -723,7 +765,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
723
765
  const ownUpgrade = m.sockets?.length ? socketUpgrade(m.service, options.root, m.sockets, parts.sockets!,
724
766
  (request, d, params) => open(request, `socket:${d.id}`, 'GET', d.path, params), hostOf, (request) => new URL(routed(request).url).pathname,
725
767
  // a turn a session speaks (a voice agent's reply) is the World's scenario's, as a turn over HTTP is
726
- async (asked) => engineOf()?.serve(asked)) : undefined;
768
+ async (asked, context) => (parts.scenario ? serveTurn(asked, scenarioLedgerOf(await context()), parts.scenario.adapter) : undefined)) : undefined;
727
769
  const rawUpgrade = laneUpgrades(parts.lanes ?? {}, ownUpgrade);
728
770
  const opening = new WeakMap<WebSocketPeer, Promise<void>>();
729
771
  const upgrade: WebSocketUpgrade | undefined = rawUpgrade && m.tenant?.storage === 'namespaced' && parts.tenant ? {
package/src/runtime.ts CHANGED
@@ -9,7 +9,10 @@ export { clearRegistry, getPack, hasPack, listPacks, packOf, registerPack, pullP
9
9
  export type { PackTransport, PullPosture, PullTrigger, PullVendor, RoundTripWrite, TwinPack } from './packRegistry.ts';
10
10
  // The DELIVER verb (`emit`) — vendor-agnostic engine + CLI glue; packs declare a TwinEmitter
11
11
  // (their event catalog, endpoints-from-state, and signed synthesis) on `TwinPack.emitter`.
12
- export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.ts';
12
+ export { scenarioFaultResult, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.ts';
13
+ export { durationMs, MACHINE_LAYERS, parseScenario, resolveScenario, ScenarioTimelineError } from './scenario-timeline.ts';
14
+ export type { ResolvedScenario, ScenarioAction, ScenarioCall, ScenarioFaultInput } from './scenario-timeline.ts';
15
+ export { completenessMark, completenessSince, noteCompleteness, type Completeness } from './completeness.ts';
13
16
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.ts';
14
17
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
15
18
  export { ORIGINAL_PATH_HEADER } from './sigv4.ts';
@@ -28,7 +31,7 @@ export type { EventDelivery, EventRender, EventScheme, EventsDecl, EventTranspor
28
31
  export type { Actor, CoreScope, DerivedManifest, ErrorSpec, FieldRule, Handler, HandlerContext, ResourceDecl, WriteHookContext, ScreenDecl, Semantics, SemanticsContext, StateField, Transition, TransitionObserver, SeedCall } from './derived-core.ts';
29
32
  export type { RemoteExecute, RemoteExecuteRequest, RemoteExecuteResponse } from './remote-execute.ts';
30
33
  export type { TwinFetchAdapterConfig, TwinFetchHandlerRequest, TwinFetchHandlerResult, TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
31
- export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioExtractorSpec, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord, ScenarioStatus } from './scenario.ts';
34
+ export type { PackScenarioAdapter, ScenarioDecision, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord } from './scenario.ts';
32
35
  export { emitTwinEvent, eventSubscriptionMatches, listEmittable, runEmitCli } from './emit.ts';
33
36
  export type { EmitDeliveryResult, EmitEndpoint, EmitReport, EmittableEvent, SynthesizedDelivery, TwinEmitter } from './emit.ts';
34
37
  // The vendor-agnostic CLIENT-SIDE RATE BUDGET — the fail-closed backstop a pack's guarded client
@@ -0,0 +1,123 @@
1
+ // How a twin consumes its World's scenario (architecture, "Scenarios"; ADR 0036 §2-4, §10): a decision where its vendor
2
+ // decides, a fault where it lands, a World fact where the vendor answers with one. Every consumption is recorded in the
3
+ // twin's own log as bookkeeping (`_decision`, `_fault`, `_fact`), so the state a run reaches holds what was decided and
4
+ // what failed, a `once` decision or a counted fault is used up by the log rather than by a process's memory, and a branch
5
+ // or a replay agrees. A twin whose head is real consults no scenario: its vendor decides and fails on its own.
6
+ import { headOf } from './head.ts';
7
+ import { applyTwinWriteAtomic, twinResources } from './serve.ts';
8
+ import { durationMs, placedDecisions, placedFaults, worldJsonFile, type PlacedDecision, type PlacedFault } from './scenario-timeline.ts';
9
+
10
+ const WORLD_FACTS_ENV = 'TWIN_WORLD_FACTS_FILE';
11
+
12
+ /** What a consumption reads and writes: the twin's own bookkeeping rows, at the request's World time. `recording` is
13
+ * false for a read-only request, which consumes nothing. */
14
+ export type ScenarioLedger = {
15
+ vendor: string;
16
+ service: string;
17
+ root?: string;
18
+ at: string;
19
+ recording: boolean;
20
+ rows(type: '_decision' | '_fault' | '_fact' | '_rate'): Array<Record<string, unknown>>;
21
+ record(type: '_decision' | '_fault' | '_fact' | '_rate', fields: Record<string, unknown>, id?: string): Promise<unknown>;
22
+ };
23
+
24
+ const simulated = (ledger: ScenarioLedger): boolean => headOf(ledger.service, ledger.root).kind !== 'real';
25
+ const usedTimes = (ledger: ScenarioLedger, type: '_decision' | '_fault', index: number): number => ledger.rows(type).filter((r) => r.entry === index).length;
26
+ const available = (ledger: ScenarioLedger, type: '_decision' | '_fault', index: number, limit: number | undefined): boolean => limit === undefined || usedTimes(ledger, type, index) < limit;
27
+ const decisionLeft = (ledger: ScenarioLedger, d: PlacedDecision): boolean => available(ledger, '_decision', d.index, d.once ? 1 : undefined);
28
+ const faultLeft = (ledger: ScenarioLedger, f: PlacedFault): boolean => available(ledger, '_fault', f.index, f.times);
29
+
30
+ /** The vendor's judgement at `point` for `subject`: the first decision the timeline placed for it, else `byDefault`;
31
+ * recorded either way. */
32
+ export async function decide<T>(ledger: ScenarioLedger, point: string, subject: string, byDefault: T): Promise<T> {
33
+ if (!simulated(ledger)) return byDefault;
34
+ const chosen = placedDecisions(ledger.vendor, point, ledger.at).find((d) => d.on === undefined && (d.subject === undefined || d.subject === subject) && decisionLeft(ledger, d));
35
+ const outcome = (chosen ? chosen.outcome : byDefault) as T;
36
+ if (ledger.recording) await ledger.record('_decision', { point, subject, outcome, entry: chosen?.index ?? 'default', decidedAt: ledger.at });
37
+ return outcome;
38
+ }
39
+
40
+ /** The model turns the timeline placed for this vendor and not yet used up, in order: what the kernel matches a turn
41
+ * against with the pack's matchers. */
42
+ export function turnDecisions(ledger: ScenarioLedger): PlacedDecision[] {
43
+ if (!simulated(ledger)) return [];
44
+ return placedDecisions(ledger.vendor, 'turn', ledger.at).filter((d) => decisionLeft(ledger, d));
45
+ }
46
+
47
+ /** Record a turn's decision: the placed one that answered it, or the labelled default. */
48
+ export async function recordTurn(ledger: ScenarioLedger, chosen: PlacedDecision | undefined, outcome: unknown): Promise<void> {
49
+ if (ledger.recording) await ledger.record('_decision', { point: 'turn', outcome, entry: chosen?.index ?? 'default', decidedAt: ledger.at });
50
+ }
51
+
52
+ /** The request faults placed on this vendor that are not used up, in order. */
53
+ export function requestFaults(ledger: ScenarioLedger): PlacedFault[] {
54
+ if (!simulated(ledger)) return [];
55
+ return placedFaults(ledger.vendor, 'request', ledger.at).filter((f) => faultLeft(ledger, f));
56
+ }
57
+
58
+ /** Record a fault that landed. */
59
+ export async function recordFault(ledger: ScenarioLedger, fault: PlacedFault, where: Record<string, unknown>): Promise<void> {
60
+ if (ledger.recording) await ledger.record('_fault', { ...where, layer: fault.layer, kind: fault.kind, entry: fault.index, landedAt: ledger.at });
61
+ }
62
+
63
+ /** A fault the timeline placed at a point of the pack's own (one item of a batch), for `subject`; recorded when it lands. */
64
+ export async function faultAt(ledger: ScenarioLedger, point: string, subject: string): Promise<PlacedFault | undefined> {
65
+ const fault = requestFaults(ledger).find((f) => f.point === point && (f.subject === undefined || f.subject === subject));
66
+ if (fault) await recordFault(ledger, fault, { point, subject });
67
+ return fault;
68
+ }
69
+
70
+ /** A ledger over a twin's store alone, for the kernel's own paths that open no context (an event's delivery, a list the
71
+ * core answers). */
72
+ export function serviceLedger(input: { vendor: string; service: string; root?: string; at: string; recording: boolean }): ScenarioLedger {
73
+ const rows = (type: string) => twinResources(input.service, input.root).filter((r) => r.type === type && r.deleted !== true) as Array<Record<string, unknown>>;
74
+ return {
75
+ ...input,
76
+ rows,
77
+ record: async (type, fields, id) => {
78
+ await applyTwinWriteAtomic(input.service, (resources) => {
79
+ const prefix = `${type.slice(1)}_`;
80
+ const next = id ?? `${prefix}${1 + Math.max(0, ...resources.filter((r) => r.type === type && r.id.startsWith(prefix)).map((r) => Number(r.id.slice(prefix.length)) || 0))}`;
81
+ return { kind: 'write', value: next, write: { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: next, fields, occurredAt: input.at, actor: { kind: 'system' } } };
82
+ }, input.root);
83
+ },
84
+ };
85
+ }
86
+
87
+ /** The first webhook fault placed on this vendor for an event type, not used up. */
88
+ export function webhookFault(ledger: ScenarioLedger, type: string): PlacedFault | undefined {
89
+ return overTimeFaults(ledger).find((f) => f.kind.startsWith('webhook-') && (f.event === undefined || f.event === type) && faultLeft(ledger, f));
90
+ }
91
+
92
+ /** The `lag` the timeline placed on this vendor's lists (an `operation` names one list), if any. */
93
+ export function listLag(ledger: ScenarioLedger, operation: string): PlacedFault | undefined {
94
+ return overTimeFaults(ledger).find((f) => f.kind === 'lag' && (f.operation === undefined || f.operation === operation));
95
+ }
96
+
97
+ /** The over-time faults placed on this vendor, in order. */
98
+ function overTimeFaults(ledger: Pick<ScenarioLedger, 'vendor' | 'service' | 'root' | 'at'>): PlacedFault[] {
99
+ if (headOf(ledger.service, ledger.root).kind === 'real') return [];
100
+ return placedFaults(ledger.vendor, 'over-time', ledger.at);
101
+ }
102
+
103
+ /** A rate limit the timeline placed, counted: this request's count in its window, and whether it is past the limit. */
104
+ export async function rateLimited(ledger: ScenarioLedger, operation: string): Promise<PlacedFault | undefined> {
105
+ if (!ledger.recording) return undefined;
106
+ for (const fault of overTimeFaults(ledger)) {
107
+ if (fault.kind !== 'rate-limit' || (fault.operation !== undefined && fault.operation !== operation)) continue;
108
+ const per = durationMs(fault.per!);
109
+ const window = Math.floor((Date.parse(ledger.at) - Date.parse(fault.at)) / per);
110
+ const id = `rate_${fault.index}_${window}`;
111
+ const count = Number(ledger.rows('_rate').find((r) => r.id === id)?.count ?? 0) + 1;
112
+ await ledger.record('_rate', { count, entry: fault.index, window }, id);
113
+ if (count > fault.limit!) { await recordFault(ledger, fault, { operation, count }); return fault; }
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ /** A World fact the twin answers with, recorded as answered (undefined when the World holds none). */
119
+ export async function fact(ledger: ScenarioLedger, kind: string, key: string): Promise<unknown> {
120
+ const value = worldJsonFile<Record<string, Record<string, unknown>>>(WORLD_FACTS_ENV)?.[kind]?.[key];
121
+ if (ledger.recording) await ledger.record('_fact', { kind, key, value: value ?? null, answeredAt: ledger.at });
122
+ return value;
123
+ }