@volter/twin 0.1.0 → 0.1.1

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 (84) hide show
  1. package/README.md +16 -2
  2. package/inject.cjs +453 -59
  3. package/package.json +12 -22
  4. package/src/actions.ts +234 -49
  5. package/src/blob-store.ts +136 -0
  6. package/src/changeset.ts +807 -0
  7. package/src/cli.ts +60 -10
  8. package/src/connector.ts +30 -7
  9. package/src/control-plane.ts +17 -1
  10. package/src/emit.ts +242 -0
  11. package/src/fork.ts +19 -7
  12. package/src/index.ts +139 -6
  13. package/src/lease.ts +4 -6
  14. package/src/lifecycle.ts +8 -0
  15. package/src/packRegistry.ts +248 -2
  16. package/src/plan.ts +131 -23
  17. package/src/proxy.ts +5 -2
  18. package/src/pushLedger.ts +116 -11
  19. package/src/queueLifecycle.ts +3 -4
  20. package/src/rateBudget.ts +1115 -0
  21. package/src/refs.ts +9 -10
  22. package/src/remote-execute.ts +16 -0
  23. package/src/scenario.ts +387 -0
  24. package/src/serve.ts +397 -15
  25. package/src/shadow.ts +86 -7
  26. package/src/storage.ts +76 -147
  27. package/src/sync.ts +63 -17
  28. package/src/twin-fetch.ts +115 -0
  29. package/src/validate.ts +6 -5
  30. package/src/world-clock.ts +33 -0
  31. package/src/world-store.ts +482 -0
  32. package/src/worldConfig.ts +4 -3
  33. package/dist/src/actions.d.ts +0 -138
  34. package/dist/src/actions.js +0 -201
  35. package/dist/src/args.d.ts +0 -3
  36. package/dist/src/args.js +0 -12
  37. package/dist/src/cli.d.ts +0 -2
  38. package/dist/src/cli.js +0 -425
  39. package/dist/src/connector.d.ts +0 -106
  40. package/dist/src/connector.js +0 -129
  41. package/dist/src/control-plane.d.ts +0 -21
  42. package/dist/src/control-plane.js +0 -40
  43. package/dist/src/egress.d.ts +0 -93
  44. package/dist/src/egress.js +0 -264
  45. package/dist/src/fork.d.ts +0 -126
  46. package/dist/src/fork.js +0 -206
  47. package/dist/src/index.d.ts +0 -42
  48. package/dist/src/index.js +0 -52
  49. package/dist/src/lease.d.ts +0 -50
  50. package/dist/src/lease.js +0 -80
  51. package/dist/src/packRegistry.d.ts +0 -34
  52. package/dist/src/packRegistry.js +0 -22
  53. package/dist/src/plan.d.ts +0 -97
  54. package/dist/src/plan.js +0 -151
  55. package/dist/src/proxy.d.ts +0 -25
  56. package/dist/src/proxy.js +0 -152
  57. package/dist/src/pushLedger.d.ts +0 -81
  58. package/dist/src/pushLedger.js +0 -130
  59. package/dist/src/queueLifecycle.d.ts +0 -62
  60. package/dist/src/queueLifecycle.js +0 -95
  61. package/dist/src/reconcile.d.ts +0 -58
  62. package/dist/src/reconcile.js +0 -137
  63. package/dist/src/refs.d.ts +0 -29
  64. package/dist/src/refs.js +0 -68
  65. package/dist/src/schemas.d.ts +0 -78
  66. package/dist/src/schemas.js +0 -50
  67. package/dist/src/serve.d.ts +0 -44
  68. package/dist/src/serve.js +0 -93
  69. package/dist/src/shadow.d.ts +0 -77
  70. package/dist/src/shadow.js +0 -138
  71. package/dist/src/status.d.ts +0 -31
  72. package/dist/src/status.js +0 -42
  73. package/dist/src/storage.d.ts +0 -119
  74. package/dist/src/storage.js +0 -535
  75. package/dist/src/sync.d.ts +0 -91
  76. package/dist/src/sync.js +0 -121
  77. package/dist/src/types.d.ts +0 -40
  78. package/dist/src/types.js +0 -1
  79. package/dist/src/validate.d.ts +0 -27
  80. package/dist/src/validate.js +0 -68
  81. package/dist/src/visualizer.d.ts +0 -13
  82. package/dist/src/visualizer.js +0 -133
  83. package/dist/src/worldConfig.d.ts +0 -9
  84. package/dist/src/worldConfig.js +0 -16
package/src/index.ts CHANGED
@@ -17,9 +17,51 @@ export * as controlPlane from './control-plane.ts';
17
17
  // the spec derivers) is NOT part of the runtime kernel — it lives in @volter/twin-tooling,
18
18
  // a dev dependency. A twin runs without it; only tests and the conformance scripts use it.
19
19
  // Pack registry — vendor twins self-describe (TwinPack) so tooling discovers them.
20
- export { clearRegistry, getPack, hasPack, listPacks, registerPack } from './packRegistry.ts';
21
- export type { PackTransport, TwinPack } from './packRegistry.ts';
22
-
20
+ export { clearRegistry, getPack, hasPack, listPacks, registerPack, pullPosture, assertContinuousPullAllowed, pullOnSchedule, resolvePullVendor, DEFAULT_PULL_POSTURE, DEFAULT_PULL_TRIGGER } from './packRegistry.ts';
21
+ export type { PackTransport, PullPosture, PullTrigger, PullVendor, TwinPack } from './packRegistry.ts';
22
+ // The DELIVER verb (`emit`) — vendor-agnostic engine + CLI glue; packs declare a TwinEmitter
23
+ // (their event catalog, endpoints-from-state, and signed synthesis) on `TwinPack.emitter`.
24
+ export { parseScenarioDocument, ScenarioEngine, ScenarioError, statefulTwinManifest, twinManifest } from './scenario.ts';
25
+ export { WORLD_CLOCK_ENV, worldNow } from './world-clock.ts';
26
+ export { createTwinFetchFromHandler } from './twin-fetch.ts';
27
+ export type { RemoteExecute, RemoteExecuteRequest, RemoteExecuteResponse } from './remote-execute.ts';
28
+ export type { TwinFetchAdapterConfig, TwinFetchHandlerRequest, TwinFetchHandlerResult } from './twin-fetch.ts';
29
+ export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioExtractorSpec, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord, ScenarioStatus } from './scenario.ts';
30
+ export { emitTwinEvent, eventSubscriptionMatches, listEmittable, runEmitCli } from './emit.ts';
31
+ export type { EmitDeliveryResult, EmitEndpoint, EmitReport, EmittableEvent, SynthesizedDelivery, TwinEmitter } from './emit.ts';
32
+ // The vendor-agnostic CLIENT-SIDE RATE BUDGET — the fail-closed backstop a pack's guarded client
33
+ // routes every live vendor call through. The MECHANISM is here; the per-vendor ceiling/window/
34
+ // weights are DATA the pack declares (`TwinPack.rateBudget` / `declareRateBudget`). Exported so an
35
+ // operator can inspect spend (`snapshot`) and a caller can catch `RateBudgetError` by type; there
36
+ // is deliberately no export that disables the guard, and a vendor with no declaration falls back
37
+ // to `DEFAULT_RATE_BUDGET` rather than to no limit at all.
38
+ export {
39
+ DEFAULT_RATE_BUDGET,
40
+ MAX_RATE_BUDGET_CEILING,
41
+ MAX_RATE_BUDGET_WINDOW_MS,
42
+ MIN_RATE_BUDGET_WEIGHT,
43
+ MIN_RATE_BUDGET_WINDOW_MS,
44
+ RateBudget,
45
+ RateBudgetError,
46
+ declareRateBudget,
47
+ hasRateBudgetDeclaration,
48
+ listRateBudgets,
49
+ priceCall,
50
+ rateBudgetPath,
51
+ rateBudgetPolicy,
52
+ rateBudgetWeight,
53
+ resetToSeconds,
54
+ assertBudgetGuardIntact,
55
+ } from './rateBudget.ts';
56
+ export type {
57
+ RateBudgetDeclaration,
58
+ RateBudgetErrorKind,
59
+ RateBudgetOptions,
60
+ RateBudgetPolicy,
61
+ RateBudgetReservation,
62
+ RateBudgetSnapshot,
63
+ RateBudgetWeightRule,
64
+ } from './rateBudget.ts';
23
65
  export {
24
66
  GenericWorldStateSchema,
25
67
  WorldActorSchema,
@@ -61,6 +103,8 @@ export {
61
103
  diffSubjectFields,
62
104
  hashFieldValue,
63
105
  recordObservedDelta,
106
+ remoteRefs,
107
+ subjectKey,
64
108
  } from './shadow.ts';
65
109
  export type {
66
110
  DeltaObservation,
@@ -82,6 +126,7 @@ export {
82
126
  listEvents,
83
127
  listQueuedEvents,
84
128
  loadState,
129
+ projectionLockPath,
85
130
  readJsonFile,
86
131
  rebuildGenericState,
87
132
  rebuildState,
@@ -90,6 +135,10 @@ export {
90
135
  scrubService,
91
136
  scrubWorld,
92
137
  stateDirName,
138
+ // Opt-in structured stderr logging (VOLTER_TWIN_LOG=1) for the audit trail. Public so a
139
+ // CONNECTOR can say on the same stream when the vendor handed it a truncated page — a
140
+ // silent shortfall is the one thing a pull must never pass off as the whole resource.
141
+ twinLog,
93
142
  // The kernel's cross-process mutual-exclusion primitive (exclusive-create lockfile with
94
143
  // stale-holder reclaim). Public so world-runtime can guard concurrent `upWorld` claims of
95
144
  // one instance dir with the SAME lock semantics the event log uses (TWIN-36).
@@ -108,6 +157,30 @@ export type {
108
157
  WorldServiceEvent,
109
158
  } from './types.ts';
110
159
  export type { ScrubResult } from './storage.ts';
160
+ // The pluggable persistence seam: the sync WorldStore interface, its fs (default) and
161
+ // in-memory implementations, the active-store injection point, and the async
162
+ // hydrate/flush boundary a serverless (Durable Object / KV / redis) entry uses.
163
+ export {
164
+ FsWorldStore,
165
+ MemoryWorldStore,
166
+ getActiveWorldStore,
167
+ setActiveWorldStore,
168
+ withWorldStore,
169
+ hydrateInto,
170
+ flushFrom,
171
+ } from './world-store.ts';
172
+ export type { WorldStore, WorldStat, HydrationSource, HydrationSink } from './world-store.ts';
173
+ // The blob seam (runtime contract R11): byte storage behind byte-carrying handlers, so a
174
+ // serverless namespace puts bytes in object storage while local worlds keep today's layout.
175
+ export {
176
+ FsBlobStore,
177
+ MemoryBlobStore,
178
+ blobDigest,
179
+ getActiveBlobStore,
180
+ setActiveBlobStore,
181
+ withBlobStore,
182
+ } from './blob-store.ts';
183
+ export type { BlobStore } from './blob-store.ts';
111
184
  export {
112
185
  loadPollCursor,
113
186
  pollCursorPath,
@@ -135,12 +208,20 @@ export type {
135
208
  } from './validate.ts';
136
209
  export {
137
210
  applyTwinWrite,
211
+ applyTwinWriteAtomic,
138
212
  createTwinServer,
213
+ journalTwinRequest,
139
214
  resolveTwinRead,
215
+ twinRequestJournalEnabled,
216
+ twinRequestJournalPath,
140
217
  twinResources,
141
218
  } from './serve.ts';
142
219
  export type {
220
+ AtomicTwinWriteDecision,
221
+ TwinRequestJournalEntry,
143
222
  TwinResource,
223
+ TwinWriteInput,
224
+ TwinWriteResult,
144
225
  } from './serve.ts';
145
226
  export { createTwinProxy } from './proxy.ts';
146
227
  export type { TwinProxy, TwinProxyOptions, VendorRoute } from './proxy.ts';
@@ -170,6 +251,7 @@ export { createVisualizerServer, renderTwinHtml } from './visualizer.ts';
170
251
  export {
171
252
  appendAction,
172
253
  appendTransactionCommit,
254
+ checkPrecondition,
173
255
  confirmAction,
174
256
  listActions,
175
257
  listTransactionCommits,
@@ -177,6 +259,7 @@ export {
177
259
  pendingEmits,
178
260
  pendingTransactionCommits,
179
261
  projectResources,
262
+ revertAction,
180
263
  TwinActionPreconditionError,
181
264
  } from './actions.ts';
182
265
  export type {
@@ -212,11 +295,61 @@ export {
212
295
  supersedeQueueRow,
213
296
  } from './queueLifecycle.ts';
214
297
  export type { QueueCounts, QueueRowStatus, QueueRowWithStatus, QueueStatusTransition } from './queueLifecycle.ts';
215
- export { abandonPush, appendPushRecord, latestPushByActionId, listPushLedger, pushTransaction, unconfirmedPushes, UnreconciledPushError } from './pushLedger.ts';
298
+ export { abandonPush, appendPushRecord, assertFastForward, latestPushByActionId, listPushLedger, NonFastForwardPushError, pushTransaction, unconfirmedPushes, UnreconciledPushError } from './pushLedger.ts';
216
299
  export type { PushOutcome, PushStatus, WorldPushRecord } from './pushLedger.ts';
217
300
  export { acquireLease, activeLease, isLeaseActive, LeaseHeldError, listLeases, releaseLease } from './lease.ts';
218
301
  export type { WorldApplyLease } from './lease.ts';
219
- export { applyPlan, buildApplyPlan, listPlans, pendingConflicts, planRequiresApproval, readPlan, writePlan } from './plan.ts';
220
- export type { ActionMapper, ApplyResult, ProviderCall, WorldApplyPlan } from './plan.ts';
302
+ export { applyPlan, approvedPendingActionSet, assertPendingActionsApproved, buildApplyPlan, buildLocalActionPlan, latestPlanReview, listPlanReviews, listPlans, pendingConflicts, planRequiresApproval, readPlan, recordPlanReview, transactionSetId, writePlan } from './plan.ts';
303
+ export type { ActionMapper, ApplyResult, ApprovedPendingActionSet, LocalActionPlan, PlanReviewDecision, PlanReviewRecord, ProviderCall, WorldApplyPlan } from './plan.ts';
221
304
  export { formatStatus, worldStatus } from './status.ts';
222
305
  export type { WorldStatus } from './status.ts';
306
+ // The CHANGESET primitive + the ledger DIFF (docs/OPERATIONAL_VCS.md v0) — "commit" and
307
+ // "diff" for operational reality: a cross-service base marker, the ledger delta since it, and
308
+ // the content-addressed, replayable changeset over that delta. State-level by construction
309
+ // (it reads the same per-service action ledgers `actions.ts` writes); `volter-world` supplies
310
+ // world discovery and thin verbs on top.
311
+ export {
312
+ approveChangeset,
313
+ assertMarkerBelongsTo,
314
+ assertSafeChangesetName,
315
+ assertValidVerifiers,
316
+ buildChangeset,
317
+ captureMarker,
318
+ changesetContentHash,
319
+ changesetHashMatches,
320
+ changesetReadiness,
321
+ CHANGESET_KIND,
322
+ diffLedgers,
323
+ formatChangeset,
324
+ formatChangesetStatus,
325
+ formatLedgerDelta,
326
+ formatReplayReport,
327
+ formatVerification,
328
+ MARKER_KIND,
329
+ normalizeChangeset,
330
+ parseVerifierExpression,
331
+ replayChangeset,
332
+ runChangesetVerifiers,
333
+ summarizeByVendor,
334
+ twinWriteShape,
335
+ withVerification,
336
+ worldBootMarker,
337
+ WORLD_BOOT_MARKER_ID,
338
+ } from './changeset.ts';
339
+ export type {
340
+ Changeset,
341
+ ChangesetAction,
342
+ ChangesetApproval,
343
+ ChangesetReadiness,
344
+ ChangesetVerification,
345
+ ChangesetVerifier,
346
+ ChangesetVerifierResult,
347
+ LedgerDelta,
348
+ LedgerPosition,
349
+ LedgerRef,
350
+ ReplayActionResult,
351
+ ReplayReport,
352
+ ReplayTarget,
353
+ VendorSummary,
354
+ WorldMarker,
355
+ } from './changeset.ts';
package/src/lease.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  // divergent histories onto the same remote baseline. Invariant: at most one active
4
4
  // (unreleased, unexpired) lease per (provider, remoteRef.name). Deterministic:
5
5
  // caller supplies id/acquiredAt/expiresAt and `now` for expiry checks (no clock).
6
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
7
6
  import { join } from 'node:path';
8
7
  import type { WorldRemoteRef } from './refs.ts';
9
8
  import { readJsonFile, withFileLock, worldPaths } from './storage.ts';
9
+ import { getActiveWorldStore } from './world-store.ts';
10
10
 
11
11
  export type WorldApplyLease = {
12
12
  id: string;
@@ -44,8 +44,7 @@ function acquireLockPath(service: string, root?: string): string {
44
44
 
45
45
  export function listLeases(service: string, root?: string): WorldApplyLease[] {
46
46
  const dir = leasesDir(service, root);
47
- if (!existsSync(dir)) return [];
48
- return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile<WorldApplyLease>(join(dir, f)));
47
+ return getActiveWorldStore().list(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile<WorldApplyLease>(join(dir, f)));
49
48
  }
50
49
 
51
50
  export function isLeaseActive(lease: WorldApplyLease, now: string): boolean {
@@ -59,8 +58,7 @@ export function activeLease(service: string, provider: string, refName: string,
59
58
 
60
59
  function writeLease(lease: WorldApplyLease, root?: string): WorldApplyLease {
61
60
  const path = leasePath(lease.service, lease.id, root);
62
- mkdirSync(join(path, '..'), { recursive: true });
63
- writeFileSync(path, `${JSON.stringify(lease, null, 2)}\n`);
61
+ getActiveWorldStore().write(path, `${JSON.stringify(lease, null, 2)}\n`);
64
62
  return lease;
65
63
  }
66
64
 
@@ -91,7 +89,7 @@ export function acquireLease(
91
89
 
92
90
  export function releaseLease(service: string, id: string, opts: { root?: string; at?: string } = {}): WorldApplyLease {
93
91
  const path = leasePath(service, id, opts.root);
94
- if (!existsSync(path)) throw new Error(`no such lease: ${id}`);
92
+ if (!getActiveWorldStore().exists(path)) throw new Error(`no such lease: ${id}`);
95
93
  const lease = readJsonFile<WorldApplyLease>(path);
96
94
  return writeLease({ ...lease, releasedAt: opts.at ?? new Date().toISOString() }, opts.root);
97
95
  }
@@ -0,0 +1,8 @@
1
+ // Bun busy-spins on a top-level promise that can never settle when no other event-loop handle
2
+ // remains. Twin CLIs use this timer-backed hold after starting their servers so an unexpectedly
3
+ // closed server cannot turn an otherwise idle process into a full-core loop.
4
+ const IDLE_SLEEP_MS = 86_400_000;
5
+
6
+ export async function keepProcessAlive(): Promise<never> {
7
+ while (true) await Bun.sleep(IDLE_SLEEP_MS);
8
+ }
@@ -7,12 +7,170 @@
7
7
  //
8
8
  // This is an optional convenience, NOT a kernel that defines the twin: a pack can
9
9
  // ignore the registry entirely. Pure + deterministic.
10
- export type PackTransport = 'rest' | 'graphql' | 'web-api';
10
+ import { declareRateBudget, type RateBudgetDeclaration } from './rateBudget.ts';
11
+ import type { TwinEmitter } from './emit.ts';
12
+
13
+ /**
14
+ * How a pack's clients ADDRESS it. The first three are HTTP API styles; `raw-tcp` is the
15
+ * RAW-PROTOCOL class — a pack whose clients speak a line protocol directly over a TCP socket
16
+ * rather than HTTP, so none of the HTTP machinery applies: no `browserRouting`, and no entry in
17
+ * the injector's `VENDOR_HOSTS` host map is possible (the injector patches http/fetch and never
18
+ * sees the traffic). Such a pack is wired into a world through app-read host/port env instead,
19
+ * and this value is what tells a reader that "no injector entry" is STRUCTURAL rather than a
20
+ * missing wiring point. `packages/twin/smtp` is the first.
21
+ *
22
+ * Deliberately the transport CLASS, not the protocol name: naming the wire protocol would put a
23
+ * vendor id in the kernel the moment a pack is named after its protocol, which is exactly what
24
+ * `scripts/architecture.test.ts`'s "kernel does not branch on vendor identity" forbids. The
25
+ * specific protocol belongs on the pack's own `specSource`/`description`.
26
+ */
27
+ export type PackTransport = 'rest' | 'graphql' | 'web-api' | 'raw-tcp';
28
+
29
+ /**
30
+ * The pack's SERVE-FAMILY — the axis the invariant matrix keys strictness off, DECLARED
31
+ * because it is the pack's own claim about what kind of thing it is (a heuristic over file
32
+ * shapes would be the loose-scan disease). Orthogonal facts stay derived: transport is its
33
+ * own field, a mirror is mirrorMutations in gate.ts, webhooks are the events module.
34
+ * 'crud' — stateful resource CRUD behind the vendor's API (the default family;
35
+ * includes ingestion→grouping packs — the ingest door is a trait).
36
+ * 'generative' — model-shaped surface serving deterministic stubs/scenarios; "cannot
37
+ * run the model" is the invariant, never a gap.
38
+ * 'signed-protocol' — the wire is a signature/format protocol (SigV4 + XML/AWS-JSON …):
39
+ * request authentication IS the fidelity surface.
40
+ * 'engine-control' — a control plane driving a REAL execution engine behind an injected
41
+ * seam (fly's runtime, github's git plane, supabase's external stack).
42
+ * 'proxy' — the vendor itself is a forwarding plane (tunnel).
43
+ */
44
+ export const PACK_ARCHETYPES = ['crud', 'generative', 'signed-protocol', 'engine-control', 'proxy'] as const;
45
+ export type PackArchetype = (typeof PACK_ARCHETYPES)[number];
46
+
47
+ /**
48
+ * How often this vendor's REAL API may be pulled — a property of the VENDOR's rate limits, so it
49
+ * belongs to the pack that knows them, not to every world that might sync one (same argument as
50
+ * `browserRouting`: vendor knowledge in the descriptor, kernel stays vendor-agnostic).
51
+ *
52
+ * - `'continuous'` — limits are generous and well-documented, so a scheduled/shadow world may sync
53
+ * this vendor on a cadence (slack, github: thousands/hour, per-method tiers, clear headers).
54
+ * - `'on-demand'` — limits are tight, opaque, cost-based, or punish bursts with long lockouts, so a
55
+ * real pull happens ONLY when a human/agent explicitly asks for one. Never on a schedule, never
56
+ * as a side effect of booting a world. Figma is the cautionary case: undocumented cost-based
57
+ * limits, and a burst of renders cost this project a ~4.5-DAY token lockout (2026-07-25).
58
+ *
59
+ * DEFAULT IS `'on-demand'` when omitted — deliberately the safe direction. A pack that hasn't
60
+ * thought about its limits must not be assumed schedulable; being wrong that way costs a slower
61
+ * sync, while being wrong the other way costs a lockout.
62
+ */
63
+ export type PullPosture = 'continuous' | 'on-demand';
64
+
65
+ /** The posture to assume for a pack that doesn't declare one. Conservative on purpose. */
66
+ export const DEFAULT_PULL_POSTURE: PullPosture = 'on-demand';
67
+
68
+ /** A pack's effective posture — its declaration, or the conservative default. */
69
+ export function pullPosture(pack: Pick<TwinPack, 'pullPosture'>): PullPosture {
70
+ return pack.pullPosture ?? DEFAULT_PULL_POSTURE;
71
+ }
72
+
73
+ /**
74
+ * What may name a vendor at a pull seam: the pack descriptor itself (the pack knows its own
75
+ * posture — no registration needed) or a vendor id resolved through the registry. The id form is
76
+ * the convenient one; it is also the one that FAILS CLOSED, because an id nothing registered
77
+ * resolves to a posture-less descriptor, i.e. the conservative default.
78
+ */
79
+ export type PullVendor = string | Pick<TwinPack, 'vendor' | 'pullPosture'>;
80
+
81
+ /**
82
+ * Why a pull is happening — the distinction the posture rule actually turns on.
83
+ *
84
+ * - `'scheduled'` — a cadence, a poll loop, a shadow tick, or any pull that happens as a side
85
+ * effect of something else (booting a world, a cron). Allowed for `'continuous'` vendors ONLY.
86
+ * - `'explicit'` — a human/agent asked for THIS pull, now. Sanctioned for any vendor; it is the
87
+ * only way an on-demand vendor is ever supposed to be pulled.
88
+ *
89
+ * Omitted ⇒ `'scheduled'`, matching the posture default: an unlabeled pull is treated as the
90
+ * dangerous kind, so labeling is what unlocks the vendor, never silence.
91
+ */
92
+ export type PullTrigger = 'scheduled' | 'explicit';
93
+
94
+ /** The trigger to assume when a caller does not say. Conservative on purpose. */
95
+ export const DEFAULT_PULL_TRIGGER: PullTrigger = 'scheduled';
96
+
97
+ /** Resolve a `PullVendor` to the descriptor whose posture governs it. Unregistered ⇒ no posture. */
98
+ export function resolvePullVendor(vendor: PullVendor): Pick<TwinPack, 'vendor' | 'pullPosture'> {
99
+ if (typeof vendor !== 'string') return vendor;
100
+ return getPack(vendor) ?? { vendor };
101
+ }
102
+
103
+ /**
104
+ * Guard a SCHEDULED/continuous sync. Throws for a vendor that may only be pulled on demand.
105
+ *
106
+ * This is the mechanical form of the rule — worlds differ in what they may pull:
107
+ * • sealed (hermetic OA cycle worlds) pull NOTHING; they seed synthetic state over loopback and
108
+ * an egress guard blocks the internet, so posture is irrelevant there;
109
+ * • shadow/scheduled worlds sync real state on a cadence — `'continuous'` vendors ONLY;
110
+ * • on-demand pulls are explicit, operator-invoked, and may target any vendor.
111
+ * A scheduled world that quietly includes an on-demand vendor is how a lockout happens, so it
112
+ * fails loudly here instead.
113
+ */
114
+ export function assertContinuousPullAllowed(vendor: PullVendor): void {
115
+ const pack = resolvePullVendor(vendor);
116
+ if (pullPosture(pack) === 'continuous') return;
117
+ const undeclared = pack.pullPosture === undefined;
118
+ throw new Error(
119
+ `pull posture: "${pack.vendor}" is on-demand — it must NOT be pulled on a schedule or as a side ` +
120
+ `effect of booting a world. Its rate limits are tight/opaque enough that a burst risks a long ` +
121
+ `lockout. Pull it explicitly when someone asks, through the pack's guarded connector; a sealed ` +
122
+ `world should seed synthetic state locally instead. If this vendor's limits are genuinely ` +
123
+ `generous, declare pullPosture:'continuous' on its TwinPack and say why.` +
124
+ (undeclared
125
+ ? ` (Nothing declared a posture for "${pack.vendor}" — either no pack is registered under ` +
126
+ `that id (registerPack) or its TwinPack omits pullPosture. Silence is not permission: the ` +
127
+ `conservative default applied. If this pull is a one-off someone asked for, say so at the ` +
128
+ `call site — pass the explicit trigger instead of putting it on a cadence.)`
129
+ : ''),
130
+ );
131
+ }
132
+
133
+ /**
134
+ * The ONE sanctioned way to pull a vendor on a cadence. Asserts the posture BEFORE `pull` runs, so
135
+ * a refused vendor makes ZERO vendor requests — the guard has to sit in front of the network call,
136
+ * not behind it (guarding the fold, e.g. `syncPull`, would be theatre: the request already went out).
137
+ *
138
+ * Wrap any scheduler/cron/loop's pull in this and the rule enforces itself:
139
+ * `await pullOnSchedule(pack, () => syncSlackFromReal(client, opts))`
140
+ * An explicit, human-asked pull does NOT go through here — it calls the connector directly, which is
141
+ * exactly the sanctioned path for an on-demand vendor.
142
+ */
143
+ export async function pullOnSchedule<T>(vendor: PullVendor, pull: () => Promise<T>): Promise<T> {
144
+ assertContinuousPullAllowed(vendor);
145
+ return pull();
146
+ }
147
+
148
+ /**
149
+ * One injector host rule — DATA the committed inject.cjs compiles, never a function. Exactly one
150
+ * selector: `host` (exact), `suffix` (endsWith), or `hostPattern` (a RegExp SOURCE over the
151
+ * hostname — regional families like `^s3[.-][a-z0-9-]+\.amazonaws\.com$`). `pathPattern` (a
152
+ * RegExp source over the pathname) splits a host shared between packs. `key` names the routing
153
+ * identity the rule belongs to — the `<KEY>_TWIN_URL` env stem — when it is not the pack's own
154
+ * vendor id (aws's consolidated twin answers under s3 / dynamodb / … ; gemini serves the
155
+ * `googleauth` token exchange); a key belongs to ONE pack. `exclude: true` carves a host out of
156
+ * the key's includes (`.upstash.io` minus `qstash.upstash.io`): a key matches when any include
157
+ * matches and no exclude does. Validated by scripts/pack-facts.ts: keys are `[a-z0-9-]+`, a
158
+ * foreign key is never another pack's vendor id, belongs to one pack, and must appear in the
159
+ * pack's `adoption.worldIds` (the world wires `<KEY>_TWIN_URL` from there); a pack must claim its
160
+ * own vendor id too; no key is exclude-only; `exclude` is `true` or absent.
161
+ */
162
+ export type HostRule = ({ host: string } | { suffix: string } | { hostPattern: string }) & {
163
+ pathPattern?: string;
164
+ key?: string;
165
+ exclude?: true;
166
+ };
11
167
 
12
168
  export type TwinPack = {
13
169
  /** vendor id / service, e.g. 'stripe'. */
14
170
  vendor: string;
15
171
  transport: PackTransport;
172
+ /** The serve-family (see PackArchetype). Every pack declares one. */
173
+ archetype?: PackArchetype;
16
174
  /** subject types the twin serves, e.g. ['customer','charge','payment_intent']. */
17
175
  resources: string[];
18
176
  /** the `world-<vendor>` operator bin, if any. */
@@ -30,13 +188,101 @@ export type TwinPack = {
30
188
  * SDK so its calls become same-origin (e.g. 'https://api.stripe.com'). Omit for vendors
31
189
  * with no browser SDK. */
32
190
  browserRouting?: { apiPathPrefix: string; loaderHost?: string };
191
+ /** How often this vendor's REAL API may be pulled. Omitted ⇒ `'on-demand'` (see `PullPosture`).
192
+ * Declare `'continuous'` only for a vendor whose published limits genuinely tolerate a
193
+ * scheduled sync, and say why in `pullPostureReason`. */
194
+ pullPosture?: PullPosture;
195
+ /** Why this posture — the limits that justify it. Required in review for `'continuous'`, since
196
+ * that is the claim that can cost a lockout if it's wrong. */
197
+ pullPostureReason?: string;
198
+ /**
199
+ * The vendor's CLIENT-SIDE RATE BUDGET — the ceiling, window and per-endpoint weights the pack's
200
+ * guarded connector enforces before a live call goes out. Vendor knowledge as DATA, exactly like
201
+ * `browserRouting`: the mechanism is the kernel's (`rateBudget.ts`), the numbers are the pack's.
202
+ *
203
+ * `registerPack` forwards this to `declareRateBudget`, so registering a pack arms its budget.
204
+ * A pack that omits it is NOT unlimited — any budget built for that vendor falls back to
205
+ * `DEFAULT_RATE_BUDGET` (see its docstring). `pullPosture` says "do not SCHEDULE this vendor";
206
+ * this says "and here is the ceiling on an EXPLICIT pull". They are complementary, not
207
+ * substitutes — the posture guards cadence seams, the budget guards the call itself.
208
+ */
209
+ rateBudget?: RateBudgetDeclaration;
210
+ /**
211
+ * The pack's DELIVER support (`emit` — see emit.ts): how to synthesize this vendor's
212
+ * signed event/webhook deliveries from current twin state. Vendor knowledge on the
213
+ * descriptor, like `browserRouting`; the kernel engine (`emitTwinEvent`) is generic.
214
+ * The operator surface is the pack's own bin (`world-<vendor> emit`); a consumer that
215
+ * registered the pack can also drive it via `volter-twin emit <vendor>`.
216
+ */
217
+ emitter?: TwinEmitter;
218
+ /**
219
+ * SERVE FACTORY OVERRIDE — normally DERIVED, declared only under ambiguity. The colocated
220
+ * host (world-runtime/src/host.ts) mounts a pack by its `create<Name>TwinServer` factory
221
+ * export (`({port,root,readOnly}) => {port,stop}`); `scripts/pack-facts.ts` reads that
222
+ * export's name off the module, so a pack with exactly ONE such export declares nothing.
223
+ * A pack exporting SEVERAL factories declares here which one `cli.ts serve` would have
224
+ * booted — the judgment the exports alone cannot reveal (linear serves its DERIVED server
225
+ * by default; the hand-written one is behind a flag). Must name a function export of the
226
+ * pack's index module matching /^create\w*TwinServer$/.
227
+ */
228
+ serveExport?: string;
229
+ /**
230
+ * ADOPTION — how application repos betray that they talk to this vendor, so
231
+ * `volter-world covers`/`init` can attribute the signal to this pack. Vendor knowledge as
232
+ * DATA, same doctrine as `browserRouting`/`rateBudget`: the detector mechanism lives in
233
+ * world-runtime; the names live here. Absorbs the central `SDK_TWINS` / `SDK_SCOPE_VENDORS` /
234
+ * `ENV_STEM_VENDORS` / `VENDOR_WORLD_IDS` maps (the one wiring point NO gate enforced —
235
+ * the class that let `@planetscale/database` escape, twin#255).
236
+ */
237
+ adoption?: {
238
+ /** every official npm client of the API surface this pack models, e.g. ['stripe']. */
239
+ sdks?: string[];
240
+ /** PyPI distribution names (PEP 503 normalized: lowercase, `-`) the vendor's Python SDKs
241
+ * ship under — the Python half of adoption discovery and coverage (`covers`). */
242
+ pypi?: string[];
243
+ /** npm scope prefixes whose members all belong to this vendor, e.g. ['@upstash/']. */
244
+ scopes?: string[];
245
+ /** credential-env-var stems, e.g. ['STRIPE'] for STRIPE_SECRET_KEY et al. */
246
+ envStems?: string[];
247
+ /** additional world service ids this vendor answers to (the VENDOR_WORLD_IDS case). */
248
+ worldIds?: string[];
249
+ };
250
+ /**
251
+ * INTERCEPTION — the vendor hosts whose traffic the injector must route to this twin,
252
+ * as serializable data (the committed `inject.cjs` table is GENERATED from these — it must
253
+ * stay dependency-free preloaded CJS, so it consumes compiled output, never imports packs).
254
+ * Exactly one of `hosts` or `hostsNone` per pack once migration completes: silence is not a
255
+ * ruling. `pathPattern` (a RegExp source string, applied to the URL pathname) splits shared
256
+ * hosts (the youtube/googleauth case). Absorbed `VENDOR_HOSTS`'s per-pack keys + the retired NO_INJECTOR_ENTRY
257
+ * allowlist (hostsNone IS the ruling now).
258
+ */
259
+ hosts?: HostRule[];
260
+ /** Why this pack deliberately has NO injector entry (explicit-endpoint wiring only). */
261
+ hostsNone?: string;
262
+ /**
263
+ * WORLD WIRING — the env var the vendor's own SDK documents for overriding its base URL,
264
+ * which `volter-world init` injects pointing at the twin. `endpointEnvNone` declares the
265
+ * deliberate absence WITH its reason (inventing a var the app never reads would make
266
+ * `covers` report coverage while traffic still reaches the real vendor — the exact lie the
267
+ * proof exists to catch). Absorbs init.ts's `APP_READ_ENDPOINT_ENV` map, where these
268
+ * reasons lived as comments. Exactly one of the two once migration completes.
269
+ */
270
+ endpointEnv?: { name: string; templates?: Record<string, string>; note: string };
271
+ /** Why this pack deliberately injects no endpoint env (see `endpointEnv`). */
272
+ endpointEnvNone?: string;
33
273
  };
34
274
 
35
275
  const registry = new Map<string, TwinPack>();
36
276
 
37
- /** Register (or replace) a pack descriptor. Returns it. */
277
+ /** Register (or replace) a pack descriptor. Returns it.
278
+ *
279
+ * Side effect, deliberately: a `rateBudget` declaration on the descriptor is ARMED here, so
280
+ * registering a pack is enough to give its vendor the ceiling it declared. `declareRateBudget`
281
+ * is idempotent for an identical declaration and REFUSES a widening one, so re-registering is
282
+ * safe and "register a fatter pack descriptor to buy a bigger budget" is not a move. */
38
283
  export function registerPack(pack: TwinPack): TwinPack {
39
284
  if (!/^[a-z0-9-]+$/.test(pack.vendor)) throw new Error(`invalid pack vendor id: ${pack.vendor}`);
285
+ if (pack.rateBudget) declareRateBudget(pack.vendor, pack.rateBudget);
40
286
  registry.set(pack.vendor, pack);
41
287
  return pack;
42
288
  }