@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
@@ -0,0 +1,807 @@
1
+ // The changeset primitive — "commit" for operational reality (docs/OPERATIONAL_VCS.md, v0).
2
+ //
3
+ // Everything upstream of it already exists: worlds are the working copy, forks are branches,
4
+ // and each twin's `actions.jsonl` (actions.ts) is the history. What was missing is the object
5
+ // in between: a NAMED, BOUNDED, REPLAYABLE slice of that history — and the `diff` that shows a
6
+ // human what a session actually did before anything becomes real.
7
+ //
8
+ // Three objects, in dependency order:
9
+ //
10
+ // MARKER a cross-service BASE POSITION — per-ledger `{count, lastActionId}` pairs captured
11
+ // at one instant. Not a global sequence number: a world has N independent append-only
12
+ // ledgers with no shared clock, so the only honest "where we were" is the tuple of
13
+ // per-ledger positions. `lastActionId` is what makes it VERIFIABLE — a ledger that was
14
+ // rewritten, purged, or rebuilt under a marker fails loudly at diff time instead of
15
+ // silently reporting the wrong delta.
16
+ //
17
+ // DELTA every action appended after the marker, across every ledger, ordered by
18
+ // `occurredAt` (the same causal order `volter-world tail` uses), grouped by vendor
19
+ // for rendering.
20
+ //
21
+ // CHANGESET a delta frozen into a content-addressed object: `{id, world, base, actions,
22
+ // approvals, applied}` + `contentHash`. The hash covers the IMMUTABLE body only
23
+ // (id/world/base/actions), so a later approval or push receipt appended to the object
24
+ // cannot invalidate the hash the approval bound to — the point of content-addressing.
25
+ //
26
+ // REPLAY is the CI primitive. It feeds recorded actions back through `applyTwinWrite` — the
27
+ // KERNEL write path, not HTTP — into another world's twins. Determinism comes for free from a
28
+ // property `applyTwinWrite` already has: its action id is CONTENT-DERIVED
29
+ // (`twin:<service>:<operation>:<subjectId>:<occurredAt>:<hash(content)>[:<uniqueness>]`). Feed
30
+ // back the same occurredAt/operation/subject/fields and the target ledger derives the SAME id,
31
+ // so a re-replay is `appended: false` end to end — idempotent by construction, with no
32
+ // replay-side bookkeeping. The uniqueness seam (billable occurrences that must not collapse)
33
+ // survives because it is recovered from the source id and passed through verbatim.
34
+ //
35
+ // On top of replay sits the OPERATIONAL-PR CONTRACT (v1): VERIFIERS — deterministic checks
36
+ // against post-replay projected state, each one the kernel's own precondition expression
37
+ // evaluated by the kernel's own evaluator (`checkPrecondition`) — whose results are recorded on
38
+ // the object as `verification`; APPROVALS, each bound to the body hash at signing (drift
39
+ // refuses, loudly); and READINESS — recomputed from the object every time, never trusted off a
40
+ // stored boolean. Verification and approvals are ABOUT-the-body metadata: they live on the
41
+ // object but outside `contentHash`, exactly so recording them cannot invalidate the hash they
42
+ // bound to. The verifier SET, by contrast, is authored content and is hashed (when non-empty),
43
+ // so an approval binds to what will be checked as well as what was done. This contract is
44
+ // consumer-agnostic by design: anything that can render a diff, run a replay and record a
45
+ // signature can implement review on top of it.
46
+ //
47
+ // This module is deliberately WORLD-IGNORANT: it takes ledger references (a state service +
48
+ // the control root that holds it) and never learns what a world is. `volter-world` supplies
49
+ // discovery and the CLI verbs; the state-level logic lives here, beside the ledgers it reads.
50
+ import { createHash } from 'node:crypto';
51
+ import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources } from './actions.ts';
52
+ import type { TwinAction, TwinActionPrecondition, TwinActionPreconditionOp } from './actions.ts';
53
+ import { canonicalJson, hashFieldValue } from './shadow.ts';
54
+ import { applyTwinWrite } from './serve.ts';
55
+ import type { TwinResource } from './serve.ts';
56
+
57
+ /** One twin's action ledger, addressed the way the control plane addresses state:
58
+ * `worldPaths(stateService, controlRoot)`. `service` is the caller's label for it (in a world:
59
+ * the world service id); `stateService` is the control-plane service whose ledger this is.
60
+ * They differ whenever a twin records under a different state name than its world service id. */
61
+ export type LedgerRef = {
62
+ /** the vendor/twin as the operator names it — what `diff` groups by and `replay` matches on */
63
+ service: string;
64
+ /** the control-plane state service whose `actions.jsonl` this is (often === service) */
65
+ stateService: string;
66
+ /** control root such that `worldPaths(stateService, controlRoot)` resolves the ledger */
67
+ controlRoot: string;
68
+ };
69
+
70
+ /** One ledger's recorded position inside a marker. `count` is the row count at capture;
71
+ * `lastActionId` is the verification anchor (see the marker note above). */
72
+ export type LedgerPosition = {
73
+ service: string;
74
+ stateService: string;
75
+ count: number;
76
+ lastActionId: string | null;
77
+ };
78
+
79
+ export const MARKER_KIND = 'volter.world.marker.v1';
80
+ export const CHANGESET_KIND = 'volter.world.changeset.v1';
81
+
82
+ /** The base position a diff or changeset is taken against. */
83
+ export type WorldMarker = {
84
+ kind: typeof MARKER_KIND;
85
+ id: string;
86
+ world: string;
87
+ createdAt: string;
88
+ /** Every ledger that existed at capture time. A ledger absent here is treated as position 0
89
+ * (a twin that recorded its first action AFTER the mark contributes its whole ledger). */
90
+ ledgers: LedgerPosition[];
91
+ };
92
+
93
+ /** The id reserved for the synthetic "everything this world has ever recorded" base. */
94
+ export const WORLD_BOOT_MARKER_ID = 'world-boot';
95
+
96
+ /** An action inside a delta/changeset, bound to the twin whose ledger recorded it. The raw
97
+ * `TwinAction` is kept verbatim (provenance); `service`/`stateService` are the binding replay
98
+ * needs to route it into the right twin of another world. */
99
+ export type ChangesetAction = {
100
+ service: string;
101
+ stateService: string;
102
+ action: TwinAction;
103
+ };
104
+
105
+ /** Per-vendor rollup of a delta, in first-occurrence order — the shape `diff` renders. */
106
+ export type VendorSummary = {
107
+ service: string;
108
+ count: number;
109
+ operations: Array<{ operation: string; count: number; subjectIds: string[] }>;
110
+ };
111
+
112
+ export type LedgerDelta = {
113
+ world: string;
114
+ base: WorldMarker;
115
+ /** every action after `base`, across every ledger, ordered by `occurredAt` */
116
+ actions: ChangesetAction[];
117
+ vendors: VendorSummary[];
118
+ total: number;
119
+ };
120
+
121
+ /** A verifier: one deterministic check against post-replay projected state — the CI assertion
122
+ * of the operational PR (docs/OPERATIONAL_VCS.md v1). The `assert` is the kernel's ONE check
123
+ * expression (`TwinActionPrecondition`: subject/field/op/value, evaluated by
124
+ * `checkPrecondition`), the same shape write-time preconditions and plan conflicts already
125
+ * use — a verifier is that check pointed at a replay target instead of the authoring world. */
126
+ export type ChangesetVerifier = {
127
+ /** caller-chosen handle, unique within the changeset (results key on it) */
128
+ id: string;
129
+ /** the world service (twin) whose post-replay state the assert reads */
130
+ service: string;
131
+ assert: TwinActionPrecondition;
132
+ };
133
+
134
+ /** One verifier's outcome against a replay target. `assert` is repeated verbatim so the result
135
+ * is readable on its own; `actual` is what the projected state held (omitted when undefined —
136
+ * which is itself what `exists`/`not_exists` distinguish). */
137
+ export type ChangesetVerifierResult = {
138
+ id: string;
139
+ service: string;
140
+ assert: TwinActionPrecondition;
141
+ passed: boolean;
142
+ actual?: unknown;
143
+ };
144
+
145
+ /** The recorded outcome of `changeset verify` — ABOUT-the-body metadata, like approvals: it
146
+ * lives on the object but is outside `contentHash`, and each verify REPLACES the previous
147
+ * record (the provenance fields say exactly which run this is). */
148
+ export type ChangesetVerification = {
149
+ at: string;
150
+ /** where the replay landed — a world name, or 'ephemeral' for a throwaway target */
151
+ into: string;
152
+ /** the body hash at verification time; a later body edit makes this visibly stale */
153
+ contentHash: string;
154
+ /** sha256 over the post-replay projected state of every replay target — two verifies that
155
+ * produced the same world state produce the same digest (the determinism receipt) */
156
+ worldDigest: string;
157
+ passed: boolean;
158
+ results: ChangesetVerifierResult[];
159
+ };
160
+
161
+ /** One signature: who approved, when, against exactly which body hash. The hash-at-signing is
162
+ * the point — an approval is meaningless without the bytes it bound to. */
163
+ export type ChangesetApproval = {
164
+ principal: string;
165
+ at: string;
166
+ contentHash: string;
167
+ note?: string;
168
+ };
169
+
170
+ export type Changeset = {
171
+ kind: typeof CHANGESET_KIND;
172
+ id: string;
173
+ name: string;
174
+ /** the world it was authored in */
175
+ world: string;
176
+ /** the marker id it forked from */
177
+ base: string;
178
+ createdAt: string;
179
+ actions: ChangesetAction[];
180
+ /** deterministic checks that must pass on replay — part of the hashed body when present,
181
+ * so an approval also binds to WHAT was checked, not just what was done */
182
+ verifiers: ChangesetVerifier[];
183
+ /** latest verify run (replaced, never accumulated) — outside the hash, like approvals */
184
+ verification: ChangesetVerification | null;
185
+ /** who signed, against `contentHash` */
186
+ approvals: ChangesetApproval[];
187
+ /** real-vendor receipt ids after governed push — v2; null until then */
188
+ applied: unknown | null;
189
+ /** sha256 over the canonical JSON of `{id, world, base, actions}` (+ `verifiers` when any) */
190
+ contentHash: string;
191
+ };
192
+
193
+ /** Names that address a file on disk (markers, changesets): no separators, no traversal. */
194
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
195
+
196
+ export function assertSafeChangesetName(name: string, what = 'changeset'): string {
197
+ if (!SAFE_NAME.test(name)) {
198
+ throw new Error(`Invalid ${what} name: ${JSON.stringify(name)} (letters, digits, '.', '_' and '-' only, starting with a letter or digit)`);
199
+ }
200
+ return name;
201
+ }
202
+
203
+ // ── markers ────────────────────────────────────────────────────────────────────────────────────
204
+
205
+ /** The "everything ever recorded in this world" base: zero ledgers, so every ledger diffs from
206
+ * position 0. Used when a world has no marks yet — `diff` must still answer, not refuse. */
207
+ export function worldBootMarker(world: string, createdAt: string): WorldMarker {
208
+ return { kind: MARKER_KIND, id: WORLD_BOOT_MARKER_ID, world, createdAt, ledgers: [] };
209
+ }
210
+
211
+ /** Capture the current position of every ledger — the cross-service base marker. */
212
+ export function captureMarker(opts: { id: string; world: string; ledgers: LedgerRef[]; createdAt?: string }): WorldMarker {
213
+ const ledgers = opts.ledgers.map((ledger) => {
214
+ const rows = listActions(ledger.stateService, ledger.controlRoot);
215
+ return {
216
+ service: ledger.service,
217
+ stateService: ledger.stateService,
218
+ count: rows.length,
219
+ lastActionId: rows.length ? rows[rows.length - 1]!.id : null,
220
+ } satisfies LedgerPosition;
221
+ });
222
+ return { kind: MARKER_KIND, id: opts.id, world: opts.world, createdAt: opts.createdAt ?? new Date().toISOString(), ledgers };
223
+ }
224
+
225
+ function positionKey(service: string, stateService: string): string {
226
+ return `${service}${stateService}`;
227
+ }
228
+
229
+ /** A marker taken in world A says nothing about world B's ledgers — comparing them would
230
+ * produce a confident, wrong delta. Refuse. */
231
+ export function assertMarkerBelongsTo(marker: WorldMarker, world: string): void {
232
+ if (marker.world !== world) {
233
+ throw new Error(`Marker "${marker.id}" was captured in world "${marker.world}", not "${world}" — a base marker only means something in the world it came from`);
234
+ }
235
+ }
236
+
237
+ // ── diff ───────────────────────────────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Every action appended after `base`, across `ledgers`, in `occurredAt` order.
241
+ *
242
+ * A ledger is verified against its recorded position before any of it is reported: fewer rows
243
+ * than the marker counted, or a different action id at that position, means the ledger was
244
+ * rewritten (scrub/purge/rebuild) and the marker no longer addresses anything real. That is a
245
+ * loud error — quietly re-basing on a rewritten ledger is how a diff lies.
246
+ */
247
+ export function diffLedgers(opts: { world: string; ledgers: LedgerRef[]; base: WorldMarker }): LedgerDelta {
248
+ assertMarkerBelongsTo(opts.base, opts.world);
249
+ const positions = new Map(opts.base.ledgers.map((p) => [positionKey(p.service, p.stateService), p]));
250
+
251
+ type Ordered = { entry: ChangesetAction; ledgerIndex: number; rowIndex: number };
252
+ const ordered: Ordered[] = [];
253
+ const seen = new Set<string>();
254
+
255
+ opts.ledgers.forEach((ledger, ledgerIndex) => {
256
+ const key = positionKey(ledger.service, ledger.stateService);
257
+ seen.add(key);
258
+ const rows = listActions(ledger.stateService, ledger.controlRoot);
259
+ const position = positions.get(key);
260
+ const from = position?.count ?? 0;
261
+ if (position && from > 0) {
262
+ if (rows.length < from) {
263
+ throw new Error(`Ledger ${ledger.service}/${ledger.stateService} has ${rows.length} action(s) but marker "${opts.base.id}" recorded ${from} — the ledger was rewritten or purged since the mark`);
264
+ }
265
+ const anchor = rows[from - 1]!;
266
+ if (anchor.id !== position.lastActionId) {
267
+ throw new Error(`Ledger ${ledger.service}/${ledger.stateService} no longer matches marker "${opts.base.id}": expected action "${position.lastActionId}" at position ${from}, found "${anchor.id}" — the ledger was rewritten since the mark`);
268
+ }
269
+ }
270
+ rows.slice(from).forEach((action, offset) => {
271
+ ordered.push({ entry: { service: ledger.service, stateService: ledger.stateService, action }, ledgerIndex, rowIndex: from + offset });
272
+ });
273
+ });
274
+
275
+ // A ledger the marker recorded that has since vanished entirely is the same corruption as a
276
+ // short ledger — the marker's anchor is unverifiable, so the delta cannot be trusted.
277
+ for (const position of opts.base.ledgers) {
278
+ if (position.count > 0 && !seen.has(positionKey(position.service, position.stateService))) {
279
+ throw new Error(`Ledger ${position.service}/${position.stateService} recorded by marker "${opts.base.id}" (${position.count} action(s)) no longer exists in world "${opts.world}" — the ledger was rewritten or purged since the mark`);
280
+ }
281
+ }
282
+
283
+ ordered.sort((a, b) => {
284
+ const at = a.entry.action.occurredAt ?? '';
285
+ const bt = b.entry.action.occurredAt ?? '';
286
+ if (at !== bt) return at < bt ? -1 : 1;
287
+ // ties keep discovery order (ledger, then row) — the same stable rule `tail` uses
288
+ if (a.ledgerIndex !== b.ledgerIndex) return a.ledgerIndex - b.ledgerIndex;
289
+ return a.rowIndex - b.rowIndex;
290
+ });
291
+
292
+ const actions = ordered.map((o) => o.entry);
293
+ return { world: opts.world, base: opts.base, actions, vendors: summarizeByVendor(actions), total: actions.length };
294
+ }
295
+
296
+ /** Group a delta by vendor, then by operation — both in FIRST-OCCURRENCE order, so the rollup
297
+ * reads in the same causal order as the actions themselves. */
298
+ export function summarizeByVendor(actions: ChangesetAction[]): VendorSummary[] {
299
+ const vendors = new Map<string, VendorSummary>();
300
+ for (const { service, action } of actions) {
301
+ let vendor = vendors.get(service);
302
+ if (!vendor) {
303
+ vendor = { service, count: 0, operations: [] };
304
+ vendors.set(service, vendor);
305
+ }
306
+ vendor.count += 1;
307
+ const name = action.operation ?? action.op ?? '?';
308
+ let operation = vendor.operations.find((o) => o.operation === name);
309
+ if (!operation) {
310
+ operation = { operation: name, count: 0, subjectIds: [] };
311
+ vendor.operations.push(operation);
312
+ }
313
+ operation.count += 1;
314
+ operation.subjectIds.push(action.subject?.id ?? '?');
315
+ }
316
+ return [...vendors.values()];
317
+ }
318
+
319
+ /** How many subject ids a rendered operation lists before eliding the rest. */
320
+ const MAX_RENDERED_IDS = 4;
321
+
322
+ function renderIds(ids: string[]): string {
323
+ if (ids.length === 0) return '';
324
+ const shown = ids.slice(0, MAX_RENDERED_IDS);
325
+ return ` (${shown.join(', ')}${ids.length > shown.length ? ', …' : ''})`;
326
+ }
327
+
328
+ /** The human rendering of a delta — one line per vendor plus a total. Shared by `diff` and
329
+ * `changeset show` so a frozen changeset reads exactly like the diff it was cut from. */
330
+ export function formatLedgerDelta(delta: LedgerDelta, opts: { title?: string } = {}): string {
331
+ const title = opts.title ?? `World ${delta.world}`;
332
+ const since = `since ${delta.base.id}${delta.base.createdAt ? ` (${delta.base.createdAt})` : ''}`;
333
+ if (delta.total === 0) return `${title} — no actions ${since}\n`;
334
+ const lines = [`${title} — ${plural(delta.total, 'action')} ${since}`, ''];
335
+ for (const vendor of delta.vendors) {
336
+ const parts = vendor.operations.map((o) => `${o.count} ${o.operation}${renderIds(o.subjectIds)}`);
337
+ lines.push(` ${vendor.service}: ${parts.join(', ')}`);
338
+ }
339
+ lines.push('', ` ${plural(delta.total, 'action')} across ${plural(delta.vendors.length, 'twin')}`);
340
+ return `${lines.join('\n')}\n`;
341
+ }
342
+
343
+ function plural(count: number, noun: string): string {
344
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
345
+ }
346
+
347
+ // ── the changeset object ───────────────────────────────────────────────────────────────────────
348
+
349
+ /** What `changesetContentHash` needs to see — the immutable fields, with `verifiers` optional
350
+ * so a v0 object (authored before verifiers existed) hashes exactly as it always did. */
351
+ export type ChangesetHashable = Pick<Changeset, 'id' | 'world' | 'base' | 'actions'> & Partial<Pick<Changeset, 'verifiers'>>;
352
+
353
+ /** The IMMUTABLE body a `contentHash` covers. `approvals`/`verification`/`applied`/`createdAt`
354
+ * are excluded on purpose: an approval or a verify run must not invalidate the hash it bound
355
+ * to — content-addressing is about the actions, not the object's lifecycle. `verifiers` ARE
356
+ * in the body (when any exist): they are authored content, and an approval must bind to what
357
+ * will be checked, not just what was done. An empty verifier set is omitted so every v0
358
+ * changeset keeps the hash it was signed under. */
359
+ function changesetBody(changeset: ChangesetHashable): Record<string, unknown> {
360
+ const body: Record<string, unknown> = { id: changeset.id, world: changeset.world, base: changeset.base, actions: changeset.actions };
361
+ if (changeset.verifiers?.length) body.verifiers = changeset.verifiers;
362
+ return body;
363
+ }
364
+
365
+ export function changesetContentHash(changeset: ChangesetHashable): string {
366
+ return `sha256:${createHash('sha256').update(canonicalJson(changesetBody(changeset))).digest('hex')}`;
367
+ }
368
+
369
+ const PRECONDITION_OPS: readonly TwinActionPreconditionOp[] = ['exists', 'not_exists', 'eq', 'neq', 'version_eq'];
370
+
371
+ /** A verifier that cannot be evaluated deterministically is refused at authoring time, not
372
+ * discovered at verify time. */
373
+ export function assertValidVerifiers(verifiers: ChangesetVerifier[]): ChangesetVerifier[] {
374
+ const seen = new Set<string>();
375
+ for (const verifier of verifiers) {
376
+ if (!verifier.id?.trim()) throw new Error('Invalid verifier: every verifier needs an id');
377
+ if (seen.has(verifier.id)) throw new Error(`Invalid verifier: duplicate id "${verifier.id}"`);
378
+ seen.add(verifier.id);
379
+ if (!verifier.service?.trim()) throw new Error(`Invalid verifier "${verifier.id}": missing service (the twin whose state it checks)`);
380
+ const assert = verifier.assert;
381
+ if (!assert?.subject?.type || !assert.subject.id || !assert.field) {
382
+ throw new Error(`Invalid verifier "${verifier.id}": assert needs subject {type, id} and a field`);
383
+ }
384
+ if (!PRECONDITION_OPS.includes(assert.op)) {
385
+ throw new Error(`Invalid verifier "${verifier.id}": unknown op ${JSON.stringify(assert.op)} (want ${PRECONDITION_OPS.join('|')})`);
386
+ }
387
+ if ((assert.op === 'eq' || assert.op === 'neq' || assert.op === 'version_eq') && assert.value === undefined) {
388
+ throw new Error(`Invalid verifier "${verifier.id}": op "${assert.op}" needs a value`);
389
+ }
390
+ }
391
+ return verifiers;
392
+ }
393
+
394
+ /**
395
+ * Parse the CLI spelling of a verifier: `<service> <type>:<id> <field> <op> [<value>]`, e.g.
396
+ * `stripe price:price_annual unit_amount eq 47000`. The value is JSON when it parses as JSON
397
+ * (numbers, booleans, quoted strings) and a bare string otherwise, so `eq active` and
398
+ * `eq "active"` mean the same thing.
399
+ */
400
+ export function parseVerifierExpression(expression: string, id: string): ChangesetVerifier {
401
+ const usage = `want "<service> <type>:<id> <field> <op> [<value>]" with op ${PRECONDITION_OPS.join('|')}`;
402
+ const parts = expression.trim().split(/\s+/);
403
+ const [service, subject, field, op] = parts;
404
+ if (!service || !subject || !field || !op) throw new Error(`Invalid verifier expression ${JSON.stringify(expression)} — ${usage}`);
405
+ const colon = subject.indexOf(':');
406
+ if (colon <= 0 || colon === subject.length - 1) {
407
+ throw new Error(`Invalid verifier expression ${JSON.stringify(expression)}: subject ${JSON.stringify(subject)} is not <type>:<id> — ${usage}`);
408
+ }
409
+ const raw = parts.slice(4).join(' ');
410
+ let value: unknown;
411
+ if (raw) {
412
+ try { value = JSON.parse(raw); } catch { value = raw; }
413
+ }
414
+ const assert: TwinActionPrecondition = {
415
+ subject: { type: subject.slice(0, colon), id: subject.slice(colon + 1) },
416
+ field,
417
+ op: op as TwinActionPreconditionOp,
418
+ ...(value === undefined ? {} : { value }),
419
+ };
420
+ return assertValidVerifiers([{ id, service, assert }])[0]!;
421
+ }
422
+
423
+ /** Freeze a delta into a named, content-addressed changeset. */
424
+ export function buildChangeset(opts: { name: string; world: string; base: string; actions: ChangesetAction[]; verifiers?: ChangesetVerifier[]; createdAt?: string }): Changeset {
425
+ assertSafeChangesetName(opts.name);
426
+ const verifiers = assertValidVerifiers(opts.verifiers ?? []);
427
+ const core = { id: `chg_${opts.name}`, world: opts.world, base: opts.base, actions: opts.actions, verifiers };
428
+ return {
429
+ kind: CHANGESET_KIND,
430
+ ...core,
431
+ name: opts.name,
432
+ createdAt: opts.createdAt ?? new Date().toISOString(),
433
+ verification: null,
434
+ approvals: [],
435
+ applied: null,
436
+ contentHash: changesetContentHash(core),
437
+ };
438
+ }
439
+
440
+ /** Fill the lifecycle fields a changeset authored before v1 lacks on disk, without touching
441
+ * its hash (absent and empty `verifiers` hash identically by construction). */
442
+ export function normalizeChangeset(changeset: Changeset): Changeset {
443
+ return {
444
+ ...changeset,
445
+ verifiers: changeset.verifiers ?? [],
446
+ verification: changeset.verification ?? null,
447
+ approvals: changeset.approvals ?? [],
448
+ applied: changeset.applied ?? null,
449
+ };
450
+ }
451
+
452
+ /** Recompute and compare — a changeset whose bytes were edited after authoring is not the
453
+ * object anyone approved. Callers render this as a warning or a hard failure as suits. */
454
+ export function changesetHashMatches(changeset: Changeset): boolean {
455
+ return changeset.contentHash === changesetContentHash(changeset);
456
+ }
457
+
458
+ /** One verifier's assert as a readable expression — the same spelling `parseVerifierExpression` accepts. */
459
+ function renderAssert(verifier: { service: string; assert: TwinActionPrecondition }): string {
460
+ const { assert } = verifier;
461
+ return `${verifier.service} ${assert.subject.type}:${assert.subject.id} ${assert.field} ${assert.op}${assert.value === undefined ? '' : ` ${JSON.stringify(assert.value)}`}`;
462
+ }
463
+
464
+ function renderVerificationLines(changeset: Changeset): string[] {
465
+ const lines: string[] = [];
466
+ const verification = changeset.verification;
467
+ if (!verification) {
468
+ lines.push(` verified: no${changeset.verifiers.length ? ` (${plural(changeset.verifiers.length, 'verifier')} defined)` : ''}`);
469
+ } else {
470
+ const passed = verification.results.filter((result) => result.passed).length;
471
+ const stale = verification.contentHash !== changeset.contentHash ? ' ** STALE: recorded against a different body hash **' : '';
472
+ lines.push(` verified: ${verification.passed ? 'yes' : 'FAILED'} — ${passed}/${verification.results.length} passed, into ${verification.into} at ${verification.at}${stale}`);
473
+ for (const result of verification.results.filter((entry) => !entry.passed)) {
474
+ lines.push(` FAIL ${result.id}: ${renderAssert(result)} — actual ${result.actual === undefined ? 'undefined' : JSON.stringify(result.actual)}`);
475
+ }
476
+ }
477
+ if (changeset.approvals.length === 0) {
478
+ lines.push(' approvals: none');
479
+ } else {
480
+ for (const approval of changeset.approvals) {
481
+ const binds = approval.contentHash === changeset.contentHash ? '' : ' ** signed a DIFFERENT body hash **';
482
+ lines.push(` approved by ${approval.principal} at ${approval.at}${approval.note ? ` — ${approval.note}` : ''}${binds}`);
483
+ }
484
+ }
485
+ return lines;
486
+ }
487
+
488
+ /** Render a changeset exactly like the diff it was cut from. */
489
+ export function formatChangeset(changeset: Changeset): string {
490
+ const delta: LedgerDelta = {
491
+ world: changeset.world,
492
+ // `show` renders against the recorded base ID; the marker object itself lives in the
493
+ // authoring world and is not needed to read the changeset.
494
+ base: { kind: MARKER_KIND, id: changeset.base, world: changeset.world, createdAt: '', ledgers: [] },
495
+ actions: changeset.actions,
496
+ vendors: summarizeByVendor(changeset.actions),
497
+ total: changeset.actions.length,
498
+ };
499
+ const lines = [
500
+ `Changeset ${changeset.name} (${changeset.id})`,
501
+ ` world: ${changeset.world}`,
502
+ ` base: ${changeset.base}`,
503
+ ` hash: ${changeset.contentHash}${changesetHashMatches(changeset) ? '' : ' ** MISMATCH: the recorded contentHash does not match the body **'}`,
504
+ ...changeset.verifiers.map((verifier) => ` verifier ${verifier.id}: ${renderAssert(verifier)}`),
505
+ ...renderVerificationLines(changeset),
506
+ ` applied: ${changeset.applied === null ? 'no' : 'yes'}`,
507
+ ];
508
+ return `${lines.join('\n')}\n\n${formatLedgerDelta(delta, { title: ` ${plural(changeset.actions.length, 'action')}` })}`;
509
+ }
510
+
511
+ // ── replay ─────────────────────────────────────────────────────────────────────────────────────
512
+
513
+ export type ReplayTarget = {
514
+ /** the changeset service this target satisfies */
515
+ service: string;
516
+ /** the state service to write into (may differ from the source's) */
517
+ stateService: string;
518
+ controlRoot: string;
519
+ };
520
+
521
+ export type ReplayActionResult = {
522
+ service: string;
523
+ sourceActionId: string;
524
+ /** the action id in the TARGET ledger — identical to `sourceActionId` whenever the target
525
+ * state service matches the source's, which is what makes replay verifiable */
526
+ actionId: string;
527
+ /** 'write' = through `applyTwinWrite` (the kernel write path); 'append' = recorded verbatim */
528
+ via: 'write' | 'append';
529
+ status: 'performed' | 'replayed';
530
+ };
531
+
532
+ export type ReplayReport = {
533
+ changeset: string;
534
+ contentHash: string;
535
+ /** the authoring world */
536
+ world: string;
537
+ into: string;
538
+ total: number;
539
+ performed: number;
540
+ replayed: number;
541
+ services: string[];
542
+ actions: ReplayActionResult[];
543
+ };
544
+
545
+ /**
546
+ * Recover the `uniqueness` value `applyTwinWrite` folded into an action id, or report that the
547
+ * id is not twin-write-shaped at all (a hand-written or pack-appended action).
548
+ *
549
+ * The id format is `twin:<service>:<operation>:<subjectId>:<occurredAt>:<contentHash>[:<uniqueness>]`
550
+ * and both `operation` and `subjectId` may themselves contain ':', so the id cannot be split
551
+ * field-wise. Instead the deterministic prefix is REBUILT from the action's own recorded parts —
552
+ * if it matches, whatever follows is exactly the uniqueness seam. Getting this right is what
553
+ * keeps billable occurrences (two identical AI completions in one millisecond) from collapsing
554
+ * into one action on replay.
555
+ */
556
+ export function twinWriteShape(action: TwinAction): { uniqueness?: string } | null {
557
+ if (action.op !== 'set' || !action.operation || !action.fields) return null;
558
+ const hash = hashFieldValue({ operation: action.operation, subjectId: action.subject.id, fields: action.fields });
559
+ const prefix = `twin:${action.service}:${action.operation}:${action.subject.id}:${action.occurredAt}:${hash}`;
560
+ if (action.id === prefix) return {};
561
+ if (action.id.startsWith(`${prefix}:`)) return { uniqueness: action.id.slice(prefix.length + 1) };
562
+ return null;
563
+ }
564
+
565
+ /**
566
+ * Replay a changeset's actions, in order, into `targets`.
567
+ *
568
+ * Two paths, and the split is about EXPRESSIVENESS, not preference:
569
+ * - `write` — the default and the point: back through `applyTwinWrite`, the same kernel entry
570
+ * an SDK call lands on. The target re-derives the action id from content, so it matches the
571
+ * source id and a second replay is a total no-op.
572
+ * - `append` — the honest fallback for actions the write path cannot express: multi-resource
573
+ * `projection` transactions (applyTwinWrite carries only flat `fields`, so routing them
574
+ * through it would silently DROP the creates/deletes/emits) and `revert`/`confirm`
575
+ * bookkeeping rows. These are appended verbatim under `appendActionIfAbsent`, which is
576
+ * id-keyed — so they are idempotent on re-replay too.
577
+ *
578
+ * `preconditions` are deliberately NOT replayed. They were evaluated against the authoring
579
+ * world's state at author time; re-evaluating them against a different base would fail replays
580
+ * that are perfectly valid recordings. Verifying a changeset against a target's state is v1's
581
+ * job (verifiers), not a silent side effect of replay. Dropping them cannot change any action
582
+ * id — preconditions are not part of the content hash.
583
+ */
584
+ export async function replayChangeset(
585
+ changeset: Changeset,
586
+ opts: { into: string; targets: ReplayTarget[]; available?: string[] },
587
+ ): Promise<ReplayReport> {
588
+ const byService = new Map(opts.targets.map((t) => [t.service, t]));
589
+ const needed = [...new Set(changeset.actions.map((a) => a.service))];
590
+ const missing = needed.filter((service) => !byService.has(service));
591
+ if (missing.length) {
592
+ const have = opts.available ?? opts.targets.map((t) => t.service);
593
+ throw new Error(`Cannot replay changeset "${changeset.name}" into world "${opts.into}": it touches ${missing.length === 1 ? 'a twin' : 'twins'} that world does not have — ${missing.join(', ')} (world "${opts.into}" has: ${have.length ? have.join(', ') : 'no services'})`);
594
+ }
595
+
596
+ const results: ReplayActionResult[] = [];
597
+ for (const entry of changeset.actions) {
598
+ const target = byService.get(entry.service)!;
599
+ const action = entry.action;
600
+ const shape = action.projection ? null : twinWriteShape(action);
601
+ if (shape) {
602
+ const { result } = await applyTwinWrite(
603
+ target.stateService,
604
+ {
605
+ operation: action.operation!,
606
+ subjectType: action.subject.type,
607
+ subjectId: action.subject.id,
608
+ fields: action.fields!,
609
+ occurredAt: action.occurredAt,
610
+ ...(action.actor ? { actor: action.actor } : {}),
611
+ ...(action.correlationId ? { correlationId: action.correlationId } : {}),
612
+ ...(shape.uniqueness === undefined ? {} : { uniqueness: shape.uniqueness }),
613
+ },
614
+ target.controlRoot,
615
+ );
616
+ results.push({ service: entry.service, sourceActionId: action.id, actionId: result.actionId, via: 'write', status: result.status });
617
+ continue;
618
+ }
619
+ // Drop preconditions (evaluated in the source world) AND the source world's shadowBasis
620
+ // (refs into the SOURCE's event log — meaningless against the target's mirror; carrying
621
+ // it verbatim would poison the target's non-fast-forward check, R14). The appender
622
+ // re-stamps against the target; replay identity excludes the basis, so re-stamping
623
+ // cannot conflict with a prior copy.
624
+ const { preconditions: _dropped, shadowBasis: _foreignBasis, ...rest } = action;
625
+ const { appended } = appendActionIfAbsent({ ...rest, service: target.stateService }, target.controlRoot);
626
+ results.push({ service: entry.service, sourceActionId: action.id, actionId: action.id, via: 'append', status: appended ? 'performed' : 'replayed' });
627
+ }
628
+
629
+ return {
630
+ changeset: changeset.name,
631
+ contentHash: changeset.contentHash,
632
+ world: changeset.world,
633
+ into: opts.into,
634
+ total: results.length,
635
+ performed: results.filter((r) => r.status === 'performed').length,
636
+ replayed: results.filter((r) => r.status === 'replayed').length,
637
+ services: needed,
638
+ actions: results,
639
+ };
640
+ }
641
+
642
+ export function formatReplayReport(report: ReplayReport): string {
643
+ const lines = [
644
+ `Replayed changeset ${report.changeset} (${report.world} → ${report.into})`,
645
+ ` ${plural(report.total, 'action')}: ${report.performed} performed, ${report.replayed} already replayed`,
646
+ ` twins: ${report.services.join(', ') || 'none'}`,
647
+ ` hash: ${report.contentHash}`,
648
+ ];
649
+ return `${lines.join('\n')}\n`;
650
+ }
651
+
652
+ // ── verify / approve / status — the operational-PR contract (v1) ───────────────────────────────
653
+
654
+ /**
655
+ * Run a changeset's verifiers against post-replay state. Deterministic on purpose: each assert
656
+ * reads the target's PROJECTED state (`projectResources` — the same projection the kernel's
657
+ * preconditions and plan conflicts read) and is evaluated by `checkPrecondition` — the same
658
+ * evaluator, so a verifier means exactly what a precondition means. The `worldDigest` is a
659
+ * receipt over everything the verifiers could have seen: two runs that produced the same
660
+ * post-replay state produce the same digest.
661
+ *
662
+ * `targets` are the same references replay wrote through — a verifier is only honest against
663
+ * the ledgers the replay actually landed in. A verifier naming a service with no target is a
664
+ * loud error (there is nothing real to check it against), never a silent pass.
665
+ */
666
+ export function runChangesetVerifiers(
667
+ changeset: Changeset,
668
+ targets: ReplayTarget[],
669
+ opts: { at: string; into: string },
670
+ ): ChangesetVerification {
671
+ const resourcesByService = new Map<string, TwinResource[]>();
672
+ const digestInput: Array<{ service: string; stateService: string; resources: unknown }> = [];
673
+ for (const target of [...targets].sort((a, b) => (a.service === b.service ? (a.stateService < b.stateService ? -1 : 1) : a.service < b.service ? -1 : 1))) {
674
+ const resources = projectResources(target.stateService, target.controlRoot);
675
+ // one world service may record under several state services (aws → s3 + dynamodb):
676
+ // its verifiers see the union
677
+ resourcesByService.set(target.service, [...(resourcesByService.get(target.service) ?? []), ...resources]);
678
+ digestInput.push({ service: target.service, stateService: target.stateService, resources });
679
+ }
680
+
681
+ const results: ChangesetVerifierResult[] = changeset.verifiers.map((verifier) => {
682
+ const resources = resourcesByService.get(verifier.service);
683
+ if (!resources) {
684
+ const have = [...resourcesByService.keys()].sort();
685
+ throw new Error(`Verifier "${verifier.id}" checks twin "${verifier.service}" but the replay target has no such twin (targets: ${have.join(', ') || 'none'})`);
686
+ }
687
+ const { assert } = verifier;
688
+ const actual = projectedPreconditionValue(assert, resources);
689
+ const passed = checkPrecondition(assert, actual);
690
+ return { id: verifier.id, service: verifier.service, assert, passed, ...(actual === undefined ? {} : { actual }) };
691
+ });
692
+
693
+ return {
694
+ at: opts.at,
695
+ into: opts.into,
696
+ contentHash: changesetContentHash(changeset),
697
+ worldDigest: `sha256:${createHash('sha256').update(canonicalJson(digestInput)).digest('hex')}`,
698
+ passed: results.every((result) => result.passed),
699
+ results,
700
+ };
701
+ }
702
+
703
+ /** Record `verification` on the object — REPLACING any prior run (the provenance fields carry
704
+ * which run this is), never touching the hash the body is addressed by. */
705
+ export function withVerification(changeset: Changeset, verification: ChangesetVerification): Changeset {
706
+ return { ...changeset, verification };
707
+ }
708
+
709
+ /**
710
+ * Append an approval bound to the changeset's CURRENT body hash. Refuses when the stored
711
+ * `contentHash` no longer matches the body: an object whose bytes moved after authoring is not
712
+ * the object anyone reviewed, and signing it would launder the drift. Loud, never silent.
713
+ */
714
+ export function approveChangeset(
715
+ changeset: Changeset,
716
+ opts: { principal: string; at?: string; note?: string },
717
+ ): { changeset: Changeset; approval: ChangesetApproval } {
718
+ if (!opts.principal?.trim()) throw new Error('Approval requires a principal (--as <principal>)');
719
+ const bodyHash = changesetContentHash(changeset);
720
+ if (changeset.contentHash !== bodyHash) {
721
+ throw new Error(`Refusing to approve changeset "${changeset.name}": its stored contentHash (${changeset.contentHash}) does not match its body (${bodyHash}) — the object drifted after authoring, and an approval must bind to exactly what was reviewed`);
722
+ }
723
+ const approval: ChangesetApproval = {
724
+ principal: opts.principal.trim(),
725
+ at: opts.at ?? new Date().toISOString(),
726
+ contentHash: bodyHash,
727
+ ...(opts.note ? { note: opts.note } : {}),
728
+ };
729
+ return { changeset: { ...changeset, approvals: [...changeset.approvals, approval] }, approval };
730
+ }
731
+
732
+ /** Everything `changeset status` reports — recomputed from the object, never trusted off
733
+ * stored booleans (the same rule `planRequiresApproval` follows). */
734
+ export type ChangesetReadiness = {
735
+ name: string;
736
+ world: string;
737
+ contentHash: string;
738
+ /** stored hash matches the body */
739
+ hashOk: boolean;
740
+ /** a verification exists, binds to the current hash, and every verifier passed */
741
+ verified: boolean;
742
+ verification: ChangesetVerification | null;
743
+ /** every approval on the object */
744
+ approvals: number;
745
+ /** approvals whose hash-at-signing is the current body hash — the only ones that count */
746
+ bindingApprovals: number;
747
+ ready: boolean;
748
+ /** empty exactly when ready */
749
+ reasons: string[];
750
+ };
751
+
752
+ export function changesetReadiness(changeset: Changeset): ChangesetReadiness {
753
+ const bodyHash = changesetContentHash(changeset);
754
+ const hashOk = changeset.contentHash === bodyHash;
755
+ const verification = changeset.verification;
756
+ const binding = changeset.approvals.filter((approval) => approval.contentHash === changeset.contentHash);
757
+
758
+ const reasons: string[] = [];
759
+ if (!hashOk) reasons.push(`contentHash mismatch: stored ${changeset.contentHash}, body hashes to ${bodyHash} — the object was edited after authoring`);
760
+ if (!verification) {
761
+ reasons.push('never verified — run `changeset verify`');
762
+ } else if (verification.contentHash !== changeset.contentHash) {
763
+ reasons.push(`verification is stale: it ran against ${verification.contentHash} — re-run \`changeset verify\``);
764
+ } else if (!verification.passed) {
765
+ const failed = verification.results.filter((result) => !result.passed);
766
+ reasons.push(`verification FAILED: ${failed.map((result) => result.id).join(', ')} (${failed.length} of ${verification.results.length})`);
767
+ }
768
+ if (binding.length === 0) {
769
+ reasons.push(changeset.approvals.length ? 'no approval binds to the current body hash' : 'no approvals — run `changeset approve --as <principal>`');
770
+ }
771
+
772
+ const verified = Boolean(hashOk && verification && verification.contentHash === changeset.contentHash && verification.passed);
773
+ return {
774
+ name: changeset.name,
775
+ world: changeset.world,
776
+ contentHash: changeset.contentHash,
777
+ hashOk,
778
+ verified,
779
+ verification,
780
+ approvals: changeset.approvals.length,
781
+ bindingApprovals: binding.length,
782
+ ready: reasons.length === 0,
783
+ reasons,
784
+ };
785
+ }
786
+
787
+ /** The single honest line `changeset status` prints. No push here — v2's job; this line only
788
+ * tells the truth about the object. */
789
+ export function formatChangesetStatus(readiness: ChangesetReadiness): string {
790
+ const verdict = readiness.ready ? 'ready' : `not-ready (${readiness.reasons.join('; ')})`;
791
+ return `${readiness.name} ${readiness.contentHash} verified=${readiness.verified ? 'yes' : 'no'} approvals=${readiness.bindingApprovals} ${verdict}\n`;
792
+ }
793
+
794
+ /** The verify verb's report — the verification plus the replay that produced it. */
795
+ export function formatVerification(name: string, verification: ChangesetVerification, report: ReplayReport): string {
796
+ const lines = [
797
+ `Verified changeset ${name} → ${verification.into}`,
798
+ ` replay: ${plural(report.total, 'action')} (${report.performed} performed, ${report.replayed} already replayed)`,
799
+ verification.results.length === 0
800
+ ? ' verifiers: none (replay itself is the only check)'
801
+ : ` verifiers: ${verification.results.filter((result) => result.passed).length}/${verification.results.length} passed`,
802
+ ...verification.results.map((result) => ` ${result.passed ? 'PASS' : 'FAIL'} ${result.id}: ${renderAssert(result)}${result.passed ? '' : ` — actual ${result.actual === undefined ? 'undefined' : JSON.stringify(result.actual)}`}`),
803
+ ` worldDigest: ${verification.worldDigest}`,
804
+ ` verdict: ${verification.passed ? 'PASSED' : 'FAILED'}`,
805
+ ];
806
+ return `${lines.join('\n')}\n`;
807
+ }