@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/serve.ts CHANGED
@@ -9,10 +9,318 @@
9
9
  // is a repo". The only serve-time policy here is `readOnly`: a twin accepts local
10
10
  // writes (as actions) unless you start it read-only (a pure mirror of pulled
11
11
  // reality). Forking is a separate data operation (fork.ts), never a serve mode.
12
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
+ import { createHash } from 'node:crypto';
14
+ import { dirname, join } from 'node:path';
12
15
  import type { SubjectFields } from './shadow.ts';
13
16
  import { hashFieldValue } from './shadow.ts';
14
- import { appendActionIfAbsent, projectResources } from './actions.ts';
15
- import type { TwinActionPrecondition } from './actions.ts';
17
+ import { appendActionIfAbsent, decideAndAppendAction, projectResources } from './actions.ts';
18
+ import type { ActionProjection, TwinAction, TwinActionPrecondition } from './actions.ts';
19
+ import { observeWorldPaths, worldPaths } from './storage.ts';
20
+ import { getActiveWorldStore } from './world-store.ts';
21
+
22
+ // ── the request journal: the INSPECT half of the read path ─────────────────────────────────────
23
+ // `actions.jsonl` records MUTATIONS only, so a twin's READS are invisible to `volter-world tail`
24
+ // — an empty-catalog GET that 404s leaves no trace, and "did the app even ask?" is unanswerable.
25
+ // The journal is the minimal honest answer: an OPT-IN (VOLTER_TWIN_REQUEST_JOURNAL=1) per-twin
26
+ // `requests.jsonl` beside `actions.jsonl`, one line per served HTTP request. Size-capped: at ~1 MB
27
+ // the file rotates once to `requests.jsonl.1` (previous rotation overwritten), so an enabled
28
+ // journal can never grow unbounded. `volter-world tail --requests` merges these lines live.
29
+ //
30
+ // WHAT A LINE CARRIES. `{at, method, path, status}` — the shape — plus, when the request presented
31
+ // one, the CREDENTIAL SHAPE: which header/query-param NAMES carried a credential-looking value,
32
+ // each with a truncated sha256 FINGERPRINT of the value. Never the value, never the rest of the
33
+ // query string, never a body. That is what makes the journal answer the question a twin exists to
34
+ // answer and could not: *which credential arrived, in which header?* Two different fingerprints
35
+ // under `dd-api-key` and `dd-application-key` say "two distinct keys arrived, in their own named
36
+ // headers"; the same fingerprint on two rows says "the same value came back"; a missing name says
37
+ // the caller sent nothing. Reading a fingerprint back to its secret is not possible — it is
38
+ // sha256, truncated — so the file stays safe to paste into an issue.
39
+ //
40
+ // The query string used to be dropped whole (it carries tokens), which is precisely why a
41
+ // query-placed key was invisible: the journal now names the credential PARAMS and fingerprints
42
+ // them, and still records `path` without its query.
43
+ //
44
+ // A fingerprint is not a vault: it is sha256 of the value, so a LOW-entropy credential (`"test"`)
45
+ // is guessable from its fingerprint by anyone who guesses the value first. It is exactly the right
46
+ // strength for its job — telling two credentials apart, and recognising one across requests.
47
+ //
48
+ // WHERE IT IS WIRED. Not per-pack. Every vendor pack mounts its own `Bun.serve({fetch})` inside a
49
+ // `create<Vendor>TwinServer({port, root, readOnly})` factory — there is no shared middleware, but
50
+ // there IS a shared CONSTRUCTOR, so `installTwinRequestJournal()` (below, called once at module
51
+ // load) wraps `Bun.serve` and journals every twin's fetch handler. The pack writes no journaling
52
+ // line at all; the four packs that used to call `journalTwinRequest` themselves no longer do.
53
+ // Measured coverage: 67 of the 69 pack HTTP servers journal with zero pack-side code. The two that
54
+ // do not are STRUCTURAL, not missed — `smtp` is a raw-TCP `Bun.listen` mail listener with no HTTP
55
+ // surface, and termfleet's PROVIDER server is `node:http` + socket.io (its registry server, which
56
+ // is `Bun.serve`, is covered). Anything that mounts an HTTP twin through `Bun.serve` is covered by
57
+ // construction; anything that does not is a second transport and would need its own seam.
58
+
59
+ export type TwinCredentialShape = {
60
+ /** header name (lowercased) or query-param name the credential arrived under. */
61
+ name: string;
62
+ /** where it sat on the request. */
63
+ in: 'header' | 'query';
64
+ /** the auth SCHEME when the value carried one (`Bearer`, `Basic`, `AWS4-HMAC-SHA256`). A scheme
65
+ * is not a secret, and it is what distinguishes "a bearer token arrived" from "a named vendor
66
+ * header arrived" — the exact confusion this journal was added to end. */
67
+ scheme?: string;
68
+ /** `sha256:<16 hex>` of the value (of the credential part when a scheme was present). Stable
69
+ * across processes and runs, so equal fingerprints mean equal values; non-reversible. */
70
+ fp?: string;
71
+ /** the name arrived with an EMPTY value — the "the SDK sent the header but no key" case. */
72
+ empty?: true;
73
+ };
74
+
75
+ export type TwinRequestJournalEntry = {
76
+ at?: string;
77
+ method: string;
78
+ path: string;
79
+ status: number;
80
+ /** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
81
+ credentials?: TwinCredentialShape[];
82
+ };
83
+
84
+ /** ~1 MB cap before a one-deep rotation — small enough to never matter on disk, large enough for
85
+ * tens of thousands of entries (a shape line is ~80 bytes). */
86
+ const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
87
+
88
+ /**
89
+ * Does this header/param NAME look like it carries a credential? Deliberately a generic word
90
+ * predicate, never a vendor table: the kernel must not learn that Datadog calls its keys
91
+ * `DD-API-KEY` or that Postmark calls its tokens `X-Postmark-Server-Token`. Both fall out of
92
+ * `key`/`token` as name segments, and so does every vendor that has not been written yet.
93
+ */
94
+ const CREDENTIAL_NAME = /(?:^|[-_.])(?:auth|authorization|token|jwt|key|apikey|secret|credential|credentials|signature|sig|password|passwd|pwd|session|sessionid|cookie|access[-_]?token|refresh[-_]?token)(?:[-_.]|$)/i;
95
+
96
+ /** Auth schemes recognised in a value even when the NAME says nothing (`Bearer …` in `x-custom`). */
97
+ const CREDENTIAL_SCHEME = /^(Bearer|Basic|Digest|Token|Negotiate|NTLM|OAuth|Signature|Hawk|AWS4-HMAC-SHA256|SharedKey|SharedKeyLite|GoogleLogin)\s+(\S[\s\S]*)$/i;
98
+
99
+ /** Non-reversible, stable fingerprint. 64 bits of sha256 — enough that two distinct credentials
100
+ * never collide in a journal, far too little to walk back to a real key. */
101
+ export function credentialFingerprint(value: string): string {
102
+ return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16)}`;
103
+ }
104
+
105
+ function credentialShapeFor(name: string, value: string, where: 'header' | 'query'): TwinCredentialShape | undefined {
106
+ const scheme = CREDENTIAL_SCHEME.exec(value);
107
+ if (!scheme && !CREDENTIAL_NAME.test(name)) return undefined;
108
+ const lower = name.toLowerCase();
109
+ if (value === '') return { name: lower, in: where, empty: true };
110
+ if (scheme) return { name: lower, in: where, scheme: scheme[1]!, fp: credentialFingerprint(scheme[2]!) };
111
+ return { name: lower, in: where, fp: credentialFingerprint(value) };
112
+ }
113
+
114
+ /**
115
+ * The credential SHAPE of one request: every credential-looking header and query param, named and
116
+ * fingerprinted, sorted for stable diffing. Values never leave this function.
117
+ */
118
+ export function twinRequestCredentials(headers: Headers | Record<string, string>, url?: string | URL): TwinCredentialShape[] {
119
+ const shapes: TwinCredentialShape[] = [];
120
+ const seen = new Set<string>();
121
+ const add = (name: string, value: string, where: 'header' | 'query') => {
122
+ const shape = credentialShapeFor(name, value, where);
123
+ if (!shape) return;
124
+ const key = `${shape.in}:${shape.name}`;
125
+ if (seen.has(key)) return;
126
+ seen.add(key);
127
+ shapes.push(shape);
128
+ };
129
+ try {
130
+ if (typeof (headers as Headers).forEach === 'function') {
131
+ (headers as Headers).forEach((value, name) => add(name, value, 'header'));
132
+ } else {
133
+ for (const [name, value] of Object.entries(headers as Record<string, string>)) add(name, String(value), 'header');
134
+ }
135
+ } catch { /* a malformed header bag must not fail a serve */ }
136
+ try {
137
+ if (url !== undefined) {
138
+ const search = typeof url === 'string' ? new URL(url, 'http://twin.invalid').search : url.search;
139
+ for (const [name, value] of new URLSearchParams(search)) add(name, value, 'query');
140
+ }
141
+ } catch { /* an unparseable URL must not fail a serve */ }
142
+ return shapes.sort((a, b) => (a.in === b.in ? a.name.localeCompare(b.name) : a.in.localeCompare(b.in)));
143
+ }
144
+
145
+ export function twinRequestJournalEnabled(env: Record<string, string | undefined> = process.env): boolean {
146
+ return env.VOLTER_TWIN_REQUEST_JOURNAL === '1';
147
+ }
148
+
149
+ /** Where a service's request journal lives: beside its `actions.jsonl`. */
150
+ export function twinRequestJournalPath(service: string, root?: string): string {
151
+ return join(dirname(worldPaths(service, root).events), 'requests.jsonl');
152
+ }
153
+
154
+ /**
155
+ * Append one served request to the journal — a no-op unless VOLTER_TWIN_REQUEST_JOURNAL=1, so
156
+ * the default path does zero I/O. Never throws: an observability append must not fail a serve.
157
+ */
158
+ export function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): void {
159
+ if (!twinRequestJournalEnabled()) return;
160
+ try {
161
+ const store = getActiveWorldStore();
162
+ const path = twinRequestJournalPath(service, root);
163
+ store.mkdir(dirname(path));
164
+ const size = store.stat(path)?.size ?? 0;
165
+ if (size >= REQUEST_JOURNAL_MAX_BYTES) {
166
+ // one-deep rotation: the previous overflow is overwritten, the live file starts fresh.
167
+ store.writeAtomic(`${path}.1`, store.read(path) ?? '');
168
+ store.write(path, '');
169
+ }
170
+ const line = JSON.stringify({
171
+ at: entry.at ?? new Date().toISOString(),
172
+ method: entry.method.toUpperCase(),
173
+ path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
174
+ status: entry.status,
175
+ ...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
176
+ });
177
+ store.append(path, `${line}\n`);
178
+ } catch {
179
+ // journaling is best-effort observability; the serve path must not care
180
+ }
181
+ }
182
+
183
+ // ── how a wrapped `Bun.serve` learns which twin it is ──────────────────────────────────────────
184
+ // `Bun.serve({port, fetch})` carries neither the vendor id nor the state root — both live in the
185
+ // factory's closure. They are recoverable without touching a single pack, because handling a
186
+ // request makes the pack call the kernel, and every kernel state path funnels through
187
+ // `worldPaths(service, root)` (storage.ts). Observing the FIRST such call a server makes is the
188
+ // twin naming itself: `datadog` under `/tmp/world/data/datadog`. It is cached per server, so only
189
+ // the first request pays for it. A server whose first request touches no state falls back to the
190
+ // call site (`packages/twin/<vendor>/…` or `@volter/twin-<vendor>`) and the process's `--root`
191
+ // argument, which is how the world runtime launches every twin.
192
+
193
+ export const TWIN_JOURNAL_IDENTITY = Symbol.for('volter.twin.requestJournal.identity');
194
+ const JOURNAL_SHIM_INSTALLED = Symbol.for('volter.twin.requestJournal.installed');
195
+
196
+ export type TwinJournalIdentity = { service: string; root?: string };
197
+
198
+ /** One capture slot per in-flight request, so two twins co-located in ONE process (the shared
199
+ * host) can never read each other's identity off a racing sibling's first request.
200
+ *
201
+ * LAZY ON PURPOSE, and this is load-bearing rather than style. Mirror-UI packs import their own
202
+ * server module into the BROWSER bundle and rely on Bun tree-shaking the server-only half away
203
+ * (see any `*-mirror-ui.ts` header). A module-scope `new AsyncLocalStorage()` is a side effect,
204
+ * so it survives tree-shaking and lands in the client — where `node:async_hooks` does not
205
+ * resolve, `new` throws at bundle evaluation, and the React app never mounts. That took out
206
+ * every mirror UI in the estate at once. Nothing in this module may run at module scope. */
207
+ let identityCapture: AsyncLocalStorage<{ identity?: TwinJournalIdentity }> | undefined;
208
+
209
+ function identitySlot(): AsyncLocalStorage<{ identity?: TwinJournalIdentity }> {
210
+ identityCapture ??= new AsyncLocalStorage<{ identity?: TwinJournalIdentity }>();
211
+ return identityCapture;
212
+ }
213
+
214
+ function observeIdentityFromWorldPaths(): void {
215
+ observeWorldPaths((service, root) => {
216
+ const slot = identityCapture?.getStore();
217
+ if (!slot || slot.identity !== undefined) return;
218
+ slot.identity = root === undefined ? { service } : { service, root };
219
+ });
220
+ }
221
+
222
+ /** The `--root DIR` a twin was launched with (the world runtime's spawn contract), if any. */
223
+ function rootFromArgv(argv: readonly string[] = process.argv): string | undefined {
224
+ const index = argv.indexOf('--root');
225
+ const value = index >= 0 ? argv[index + 1] : undefined;
226
+ return value && !value.startsWith('--') ? value : undefined;
227
+ }
228
+
229
+ /** The vendor a `Bun.serve` call site belongs to, from its module path. Infra packages are not
230
+ * vendors, so they are skipped: whoever called THEM is the twin. */
231
+ const INFRA_PACKAGES = new Set(['control-plane', 'world-runtime', 'tooling', 'attach', 'browser-assets', 'twins-host']);
232
+
233
+ export function vendorFromStack(stack: string | undefined): string | undefined {
234
+ if (!stack) return undefined;
235
+ const pattern = /(?:packages[\\/]twin[\\/]|@volter[\\/]twin-)([A-Za-z0-9_-]+)/g;
236
+ for (const match of stack.matchAll(pattern)) {
237
+ const name = match[1]!;
238
+ if (!INFRA_PACKAGES.has(name)) return name;
239
+ }
240
+ return undefined;
241
+ }
242
+
243
+
244
+
245
+ /**
246
+ * Wrap `Bun.serve` so EVERY twin's HTTP surface journals, with no per-pack line. Idempotent
247
+ * (a `Symbol.for` marker survives a second copy of this module) and a strict pass-through when
248
+ * VOLTER_TWIN_REQUEST_JOURNAL is not `1` — the default path adds one function call per request
249
+ * and does zero I/O. Called once at module load, below; exported so a test can assert it.
250
+ *
251
+ * A pack that knows its own identity may declare it by putting `{service, root}` on the serve
252
+ * options under `TWIN_JOURNAL_IDENTITY` (a symbol key Bun's option reader ignores). Nothing in
253
+ * the estate needs to: `createTwinServer` is the only caller, because it is the one server whose
254
+ * service is an argument rather than a fact about the pack.
255
+ */
256
+ export function installTwinRequestJournal(): boolean {
257
+ const bun = (globalThis as { Bun?: { serve?: unknown } }).Bun;
258
+ if (!bun || typeof bun.serve !== 'function') return false; // not Bun (node tooling) — nothing to wrap
259
+ const original = bun.serve as (...args: unknown[]) => { port?: number };
260
+ if ((original as unknown as Record<symbol, unknown>)[JOURNAL_SHIM_INSTALLED]) return true;
261
+ // Registered HERE, not at module scope, for the same reason the capture slot is lazy: a
262
+ // module-scope registration is a side effect that survives tree-shaking into the client.
263
+ observeIdentityFromWorldPaths();
264
+
265
+ const wrapped = function serve(this: unknown, options: unknown, ...rest: unknown[]) {
266
+ const config = options as (Record<string | symbol, unknown> & { fetch?: (...a: unknown[]) => unknown }) | undefined;
267
+ if (!config || typeof config.fetch !== 'function') return original.apply(this, [options, ...rest]);
268
+ const declared = config[TWIN_JOURNAL_IDENTITY] as TwinJournalIdentity | undefined;
269
+ // Resolved once per SERVER, not per request: a twin's identity is a constant of the server.
270
+ let identity: TwinJournalIdentity | undefined = declared;
271
+ // Captured HERE, at the `Bun.serve` call, because this is the only moment the pack's own
272
+ // module is on the stack — by the time a request is served, Bun is the caller.
273
+ const callSiteVendor = declared ? undefined : vendorFromStack(new Error().stack);
274
+ const inner = config.fetch as (this: unknown, request: Request, server: unknown) => unknown;
275
+ const journaling = async function (this: unknown, request: Request, server: unknown) {
276
+ if (!twinRequestJournalEnabled()) return inner.call(this, request, server);
277
+ const slot: { identity?: TwinJournalIdentity } = {};
278
+ let status = 500;
279
+ try {
280
+ const response = (await identitySlot().run(slot, () => inner.call(this, request, server))) as Response | undefined;
281
+ if (response instanceof Response) status = response.status;
282
+ else if (response === undefined) return response; // upgraded (websocket) — nothing served
283
+ return response;
284
+ } finally {
285
+ // Only an OBSERVED identity is cached: it is the twin's own words. A fallback is
286
+ // per-request, so the first request that touches state upgrades every later one instead
287
+ // of locking a guessed root in for the life of the server.
288
+ identity ??= slot.identity;
289
+ const resolved = identity ?? fallbackIdentity(callSiteVendor);
290
+ if (resolved) {
291
+ const url = new URL(request.url);
292
+ journalTwinRequest(resolved.service, {
293
+ method: request.method,
294
+ path: url.pathname,
295
+ status,
296
+ credentials: twinRequestCredentials(request.headers, url),
297
+ }, resolved.root);
298
+ }
299
+ }
300
+ };
301
+ return original.apply(this, [{ ...config, fetch: journaling }, ...rest]);
302
+ };
303
+ Object.defineProperty(wrapped, JOURNAL_SHIM_INSTALLED, { value: true });
304
+ (bun as { serve: unknown }).serve = wrapped;
305
+ return true;
306
+ }
307
+
308
+ /** Identity for a server whose first request touched no state: the vendor that owns the calling
309
+ * module, plus the `--root` the world runtime launched this twin with. */
310
+ function fallbackIdentity(callSiteVendor: string | undefined): TwinJournalIdentity | undefined {
311
+ const service = process.env.VOLTER_TWIN_JOURNAL_SERVICE || callSiteVendor;
312
+ if (!service || !/^[A-Za-z0-9_-]+$/.test(service)) return undefined;
313
+ const root = rootFromArgv();
314
+ return root === undefined ? { service } : { service, root };
315
+ }
316
+
317
+ // One call, at module load. `@volter/twin`'s entrypoint re-exports this module, and every pack
318
+ // that serves imports the kernel, so wrapping here is what makes the journal a property of the
319
+ // PRODUCT rather than of the four packs that once remembered to call it.
320
+ //
321
+ // It is a no-op wherever `Bun.serve` is absent, which is what keeps it safe in a browser bundle:
322
+ // off-Bun it returns before touching `node:async_hooks`, `process.argv`, or the storage hook.
323
+ installTwinRequestJournal();
16
324
 
17
325
  // A subject rendered as a vendor resource: its folded current fields + identity.
18
326
  export type TwinResource = { id: string; type: string; updatedAt: string } & Record<string, unknown>;
@@ -27,6 +335,69 @@ export function twinResources(service: string, root?: string): TwinResource[] {
27
335
  // Result of a local twin write — it is an ACTION, not an egress/real write.
28
336
  export type TwinWriteResult = { status: 'performed' | 'replayed'; actionId: string };
29
337
 
338
+ export type TwinWriteInput = {
339
+ operation: string;
340
+ provider?: string;
341
+ subjectType: string;
342
+ subjectId: string;
343
+ fields: SubjectFields;
344
+ input?: Record<string, unknown>;
345
+ projection?: ActionProjection;
346
+ occurredAt?: string;
347
+ actor?: { kind: 'agent' | 'human' | 'bot' | 'system'; id?: string };
348
+ preconditions?: TwinActionPrecondition[];
349
+ correlationId?: string;
350
+ uniqueness?: string;
351
+ };
352
+
353
+ export type AtomicTwinWriteDecision<T> =
354
+ | { kind: 'skip'; value: T }
355
+ | { kind: 'write'; value: T; write: TwinWriteInput };
356
+
357
+ function actionForTwinWrite(service: string, write: TwinWriteInput): TwinAction {
358
+ const occurredAt = write.occurredAt ?? new Date().toISOString();
359
+ // Idempotency: dedup a RE-ISSUED IDENTICAL write only. The key includes a hash of the write
360
+ // CONTENT, not just the timestamp. `undefined` keys are omitted by canonicalJson, preserving
361
+ // every pre-input/projection action id for existing packs.
362
+ const contentHash = hashFieldValue({ operation: write.operation, subjectId: write.subjectId, fields: write.fields, input: write.input, projection: write.projection });
363
+ const actionId = `twin:${service}:${write.operation}:${write.subjectId}:${occurredAt}:${contentHash}${write.uniqueness ? `:${write.uniqueness}` : ''}`;
364
+ return {
365
+ id: actionId,
366
+ service,
367
+ op: 'set',
368
+ operation: write.operation,
369
+ subject: { type: write.subjectType, id: write.subjectId },
370
+ occurredAt,
371
+ ...(write.actor ? { actor: write.actor } : {}),
372
+ ...(write.preconditions?.length ? { preconditions: write.preconditions } : {}),
373
+ ...(write.correlationId ? { correlationId: write.correlationId } : {}),
374
+ ...(write.input ? { input: write.input } : {}),
375
+ fields: write.fields,
376
+ ...(write.projection ? { projection: write.projection } : {}),
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Decide a state-dependent local write and append it under the same cross-process action lock.
382
+ * Use this when acceptance or the new fields depend on current projected state; a caller-side
383
+ * read followed by `applyTwinWrite` is not atomic across processes.
384
+ */
385
+ export async function applyTwinWriteAtomic<T>(
386
+ service: string,
387
+ prepare: (resources: readonly TwinResource[]) => AtomicTwinWriteDecision<T>,
388
+ root?: string,
389
+ ): Promise<{ value: T; result?: TwinWriteResult }> {
390
+ const committed = decideAndAppendAction(service, (resources) => {
391
+ const decision = prepare(resources);
392
+ if (decision.kind === 'skip') return { kind: 'skip', value: decision.value };
393
+ return { kind: 'append', value: decision.value, action: actionForTwinWrite(service, decision.write) };
394
+ }, root);
395
+ return {
396
+ value: committed.value,
397
+ ...(committed.action ? { result: { status: committed.appended ? 'performed' : 'replayed', actionId: committed.action.id } } : {}),
398
+ };
399
+ }
400
+
30
401
  // Resolve a read request against the twin's resources. Returns the matched
31
402
  // resource(s) + an HTTP-ish status, deterministically from state.
32
403
  // GET / -> { service, mode, resourceTypes, count }
@@ -63,38 +434,41 @@ export function resolveTwinRead(
63
434
  // vendor is a separate, explicit step that records egress + confirms the action.
64
435
  export async function applyTwinWrite(
65
436
  service: string,
66
- write: { operation: string; provider?: string; subjectType: string; subjectId: string; fields: SubjectFields; occurredAt?: string; actor?: { kind: 'agent' | 'human' | 'bot' | 'system'; id?: string }; preconditions?: TwinActionPrecondition[]; correlationId?: string },
437
+ write: TwinWriteInput,
67
438
  root?: string,
68
439
  ): Promise<{ result: TwinWriteResult; resource: TwinResource }> {
69
- const occurredAt = write.occurredAt ?? new Date().toISOString();
440
+ const action = actionForTwinWrite(service, write);
70
441
  // Idempotency: dedup a RE-ISSUED IDENTICAL write only. The key includes a hash of the write
71
442
  // CONTENT (operation + subject + fields), not just the timestamp — so two DIFFERENT writes to
72
443
  // the same subject in the same millisecond are BOTH kept (previously the second silently
73
444
  // no-opped: e.g. a sprint "start" then "close" in the same ms lost the close). Check + append
74
445
  // run atomically under the actions lock, so concurrent processes can't double-apply either.
75
- const contentHash = hashFieldValue({ operation: write.operation, subjectId: write.subjectId, fields: write.fields });
76
- const actionId = `twin:${service}:${write.operation}:${write.subjectId}:${occurredAt}:${contentHash}`;
446
+ //
447
+ // The UNIQUENESS SEAM: some vendor operations are billable OCCURRENCES, not idempotent state
448
+ // writes — every call is a distinct action even when byte-identical in the same millisecond
449
+ // (an AI completion: the real vendor runs + bills each call; two identical rapid calls must
450
+ // ledger as TWO actions, never collapse). A pack passes a per-call `uniqueness` value (e.g.
451
+ // randomUUID()) and the content-dedupe stops applying to that action. Leave it unset for
452
+ // ordinary resource writes, where replay-dedupe is the correct semantics.
77
453
  // correlationId (D3): pass a caller-supplied request-scoped id through (e.g. propagated
78
454
  // from an inbound HTTP request id) so it lands on the action row; appendActionIfAbsent
79
455
  // generates one when omitted, so it's never missing.
80
- const { appended } = appendActionIfAbsent(
81
- { id: actionId, service, op: 'set', operation: write.operation, subject: { type: write.subjectType, id: write.subjectId }, occurredAt, ...(write.actor ? { actor: write.actor } : {}), ...(write.preconditions?.length ? { preconditions: write.preconditions } : {}), ...(write.correlationId ? { correlationId: write.correlationId } : {}), fields: write.fields },
82
- root,
83
- );
456
+ const { appended } = appendActionIfAbsent(action, root);
84
457
  // Resolve by (type, id) — an id alone is ambiguous when two resource TYPES share it.
85
458
  const resource = projectResources(service, root).find((r) => r.type === write.subjectType && r.id === write.subjectId)
86
- ?? ({ id: write.subjectId, type: write.subjectType, updatedAt: occurredAt, ...write.fields } as TwinResource);
87
- return { result: { status: appended ? 'performed' : 'replayed', actionId }, resource };
459
+ ?? ({ id: write.subjectId, type: write.subjectType, updatedAt: action.occurredAt, ...write.fields } as TwinResource);
460
+ return { result: { status: appended ? 'performed' : 'replayed', actionId: action.id }, resource };
88
461
  }
89
462
 
90
463
  export function createTwinServer(options: { service: string; root?: string; port?: number; readOnly?: boolean }): { port: number; stop: () => void } {
91
464
  const readOnly = options.readOnly ?? false;
92
- const server = Bun.serve({
465
+ const serveOptions = {
93
466
  port: options.port ?? 0,
94
467
  idleTimeout: 60,
95
- async fetch(request) {
468
+ async fetch(request: Request) {
96
469
  const url = new URL(request.url);
97
- const json = (status: number, body: unknown) => new Response(JSON.stringify(body, null, 2), { status, headers: { 'content-type': 'application/json' } });
470
+ const json = (status: number, body: unknown) =>
471
+ new Response(JSON.stringify(body, null, 2), { status, headers: { 'content-type': 'application/json' } });
98
472
  if (request.method === 'GET') {
99
473
  // Re-read per request so a concurrently-syncing twin serves fresh state.
100
474
  const { status, body } = resolveTwinRead(options.service, url.pathname, { root: options.root });
@@ -115,6 +489,14 @@ export function createTwinServer(options: { service: string; root?: string; port
115
489
  }, options.root);
116
490
  return json(result.status === 'replayed' ? 200 : 201, { status: result.status, resource });
117
491
  },
492
+ };
493
+ // This generic server's twin is an ARGUMENT, not a fact about a pack, so it NAMES ITSELF for the
494
+ // request journal rather than being recognised from its call site. Every vendor pack is journaled
495
+ // without declaring anything (installTwinRequestJournal, above); this is the lone declaration.
496
+ Object.defineProperty(serveOptions, TWIN_JOURNAL_IDENTITY, {
497
+ value: { service: options.service, ...(options.root !== undefined ? { root: options.root } : {}) },
498
+ enumerable: true,
118
499
  });
500
+ const server = Bun.serve(serveOptions);
119
501
  return { port: server.port ?? 0, stop: () => server.stop(true) };
120
502
  }
package/src/shadow.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { isEgressEventType } from './egress.ts';
3
- import { appendEvent, createEvent, listEvents } from './storage.ts';
4
- import type { AppendEventResult, WorldServiceEvent } from './types.ts';
3
+ import { appendEvent, appendEventLocked, createEvent, listEvents, worldPaths } from './storage.ts';
4
+ import { getActiveWorldStore } from './world-store.ts';
5
+ import type { AppendEventResult, WorldPaths, WorldServiceEvent } from './types.ts';
5
6
 
6
7
  export const DELTA_TYPE_SUFFIX = '.delta';
7
8
 
@@ -31,7 +32,11 @@ export type SubjectFieldExtractor = (event: WorldServiceEvent) => SubjectFields
31
32
 
32
33
  export type FieldChange = { before: unknown; after: unknown };
33
34
 
34
- function canonicalJson(value: unknown): string {
35
+ /** Deterministic JSON: object keys sorted, `undefined` entries dropped, arrays in order.
36
+ * The one canonicalizer in this package — `hashFieldValue` (action-id content hashing) and
37
+ * `changesetContentHash` (approval binds to exact bytes) must agree on what "same content"
38
+ * means, so they share this function rather than each rolling their own. */
39
+ export function canonicalJson(value: unknown): string {
35
40
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
36
41
  if (value && typeof value === 'object') {
37
42
  const entries = Object.entries(value as Record<string, unknown>)
@@ -47,10 +52,67 @@ export function hashFieldValue(value: unknown): string {
47
52
  return createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
48
53
  }
49
54
 
50
- function subjectKey(subject: { type: string; id: string }): string {
55
+ export function subjectKey(subject: { type: string; id: string }): string {
51
56
  return `${subject.type}:${subject.id}`;
52
57
  }
53
58
 
59
+ /**
60
+ * The per-subject REMOTE REF (runtime contract R14): the id of the last event in the
61
+ * observed mirror that moved the remote from THIS action's point of view. Null = the
62
+ * mirror has never seen the subject.
63
+ *
64
+ * This is the git remote-tracking ref of the model: an action stamps these at authoring
65
+ * time (its merge base), and push compares them again — a moved ref means someone else
66
+ * changed the remote since we last looked, and the push must refuse (non-fast-forward).
67
+ *
68
+ * What "someone else" means differs by moment, so the reader has three MODES:
69
+ * - AUTHORING (mode omitted): every push confirmation this world has ever written
70
+ * (`confirmed:{service}:…` ids) is excluded — everything already confirmed at
71
+ * authoring time was, by construction, pushed before this action existed.
72
+ * - PUSH (mode `{countAsDrift}`): self-confirmations are excluded EXCEPT the listed
73
+ * event ids — pushLedger.ts lists the confirmations of the pushed action's
74
+ * DESCENDANTS (actions authored after it), which count as drift: pushing a stale
75
+ * action once a newer one already landed remotely is the out-of-order lost update.
76
+ * Orphaned self-confirm events (a crash between confirmAction's event append and its
77
+ * confirm row — referenced by no row) stay excluded in this mode, matching what the
78
+ * authoring stamp saw; counting them would wedge the subject behind a permanent
79
+ * phantom drift.
80
+ * - RAW (mode 'raw'): nothing is excluded — the last mirror event, period. The sync
81
+ * plane's capture/compare mode: a reconcile plan is stale the moment ANY event lands
82
+ * after its `real` pull, our own confirmations included (syncPush, sync.ts).
83
+ *
84
+ * Reads the event log raw (JSON.parse of id/type/subject only, no schema validation) —
85
+ * this runs on the applyTwinWrite hot path, where a full zod parse per append is the
86
+ * exact O(N·M) cost storage.ts already had to engineer out of appendEvent.
87
+ */
88
+ export type RemoteRefsMode = 'raw' | { countAsDrift: ReadonlySet<string> };
89
+
90
+ export function remoteRefs(
91
+ service: string,
92
+ subjects: Array<{ type: string; id: string }>,
93
+ root?: string,
94
+ mode?: RemoteRefsMode,
95
+ ): Record<string, string | null> {
96
+ const wanted = new Map(subjects.map((s) => [subjectKey(s), null as string | null]));
97
+ if (wanted.size === 0) return {};
98
+ const selfConfirmed = `confirmed:${service}:`;
99
+ const path = worldPaths(service, root).events;
100
+ for (const line of getActiveWorldStore().readLines(path)) {
101
+ if (!line.trim()) continue;
102
+ let event: { id?: string; type?: string; subject?: { type: string; id: string } };
103
+ try { event = JSON.parse(line) as typeof event; } catch { continue; }
104
+ if (!event.id || !event.type || !event.subject) continue;
105
+ if (isEgressEventType(event.type)) continue;
106
+ if (mode !== 'raw' && event.id.startsWith(selfConfirmed)) {
107
+ const countAsDrift = mode !== undefined && mode.countAsDrift.has(event.id);
108
+ if (!countAsDrift) continue;
109
+ }
110
+ const key = subjectKey(event.subject);
111
+ if (wanted.has(key)) wanted.set(key, event.id);
112
+ }
113
+ return Object.fromEntries(wanted);
114
+ }
115
+
54
116
  function isDeltaEvent(event: WorldServiceEvent): boolean {
55
117
  return event.type.endsWith(DELTA_TYPE_SUFFIX) && typeof event.data.changed === 'object';
56
118
  }
@@ -150,10 +212,10 @@ export type DeltaResult =
150
212
  * The delta idempotencyKey is derived from the after-state hashes, so the
151
213
  * same observed change never appends twice.
152
214
  */
153
- export function recordObservedDelta(
215
+ function recordObservedDeltaWith(
154
216
  state: ShadowState,
155
217
  observation: DeltaObservation,
156
- root?: string,
218
+ appendFn: (event: WorldServiceEvent) => AppendEventResult,
157
219
  ): DeltaResult {
158
220
  const shadow = state.subjects[subjectKey(observation.subject)];
159
221
  const changes = diffSubjectFields(shadow, observation.observed);
@@ -186,7 +248,24 @@ export function recordObservedDelta(
186
248
  changed: changes,
187
249
  },
188
250
  });
189
- const append = appendEvent(event, root);
251
+ const append = appendFn(event);
190
252
  if (append.appended) applyFields(state, append.event, deltaAfterFields(append.event));
191
253
  return { changed: true, event: append.event, changes, append };
192
254
  }
255
+
256
+ export function recordObservedDelta(
257
+ state: ShadowState,
258
+ observation: DeltaObservation,
259
+ root?: string,
260
+ ): DeltaResult {
261
+ return recordObservedDeltaWith(state, observation, (event) => appendEvent(event, root));
262
+ }
263
+
264
+ /** Internal batch seam: caller already owns the service projection + events locks. */
265
+ export function recordObservedDeltaLocked(
266
+ state: ShadowState,
267
+ observation: DeltaObservation,
268
+ paths: WorldPaths,
269
+ ): DeltaResult {
270
+ return recordObservedDeltaWith(state, observation, (event) => appendEventLocked(event, paths));
271
+ }