@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.
- package/README.md +16 -2
- package/inject.cjs +453 -59
- package/package.json +12 -22
- package/src/actions.ts +234 -49
- package/src/blob-store.ts +136 -0
- package/src/changeset.ts +807 -0
- package/src/cli.ts +60 -10
- package/src/connector.ts +30 -7
- package/src/control-plane.ts +17 -1
- package/src/emit.ts +242 -0
- package/src/fork.ts +19 -7
- package/src/index.ts +139 -6
- package/src/lease.ts +4 -6
- package/src/lifecycle.ts +8 -0
- package/src/packRegistry.ts +248 -2
- package/src/plan.ts +131 -23
- package/src/proxy.ts +5 -2
- package/src/pushLedger.ts +116 -11
- package/src/queueLifecycle.ts +3 -4
- package/src/rateBudget.ts +1115 -0
- package/src/refs.ts +9 -10
- package/src/remote-execute.ts +16 -0
- package/src/scenario.ts +387 -0
- package/src/serve.ts +397 -15
- package/src/shadow.ts +86 -7
- package/src/storage.ts +76 -147
- package/src/sync.ts +63 -17
- package/src/twin-fetch.ts +115 -0
- package/src/validate.ts +6 -5
- package/src/world-clock.ts +33 -0
- package/src/world-store.ts +482 -0
- package/src/worldConfig.ts +4 -3
- package/dist/src/actions.d.ts +0 -138
- package/dist/src/actions.js +0 -201
- package/dist/src/args.d.ts +0 -3
- package/dist/src/args.js +0 -12
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.js +0 -425
- package/dist/src/connector.d.ts +0 -106
- package/dist/src/connector.js +0 -129
- package/dist/src/control-plane.d.ts +0 -21
- package/dist/src/control-plane.js +0 -40
- package/dist/src/egress.d.ts +0 -93
- package/dist/src/egress.js +0 -264
- package/dist/src/fork.d.ts +0 -126
- package/dist/src/fork.js +0 -206
- package/dist/src/index.d.ts +0 -42
- package/dist/src/index.js +0 -52
- package/dist/src/lease.d.ts +0 -50
- package/dist/src/lease.js +0 -80
- package/dist/src/packRegistry.d.ts +0 -34
- package/dist/src/packRegistry.js +0 -22
- package/dist/src/plan.d.ts +0 -97
- package/dist/src/plan.js +0 -151
- package/dist/src/proxy.d.ts +0 -25
- package/dist/src/proxy.js +0 -152
- package/dist/src/pushLedger.d.ts +0 -81
- package/dist/src/pushLedger.js +0 -130
- package/dist/src/queueLifecycle.d.ts +0 -62
- package/dist/src/queueLifecycle.js +0 -95
- package/dist/src/reconcile.d.ts +0 -58
- package/dist/src/reconcile.js +0 -137
- package/dist/src/refs.d.ts +0 -29
- package/dist/src/refs.js +0 -68
- package/dist/src/schemas.d.ts +0 -78
- package/dist/src/schemas.js +0 -50
- package/dist/src/serve.d.ts +0 -44
- package/dist/src/serve.js +0 -93
- package/dist/src/shadow.d.ts +0 -77
- package/dist/src/shadow.js +0 -138
- package/dist/src/status.d.ts +0 -31
- package/dist/src/status.js +0 -42
- package/dist/src/storage.d.ts +0 -119
- package/dist/src/storage.js +0 -535
- package/dist/src/sync.d.ts +0 -91
- package/dist/src/sync.js +0 -121
- package/dist/src/types.d.ts +0 -40
- package/dist/src/types.js +0 -1
- package/dist/src/validate.d.ts +0 -27
- package/dist/src/validate.js +0 -68
- package/dist/src/visualizer.d.ts +0 -13
- package/dist/src/visualizer.js +0 -133
- package/dist/src/worldConfig.d.ts +0 -9
- package/dist/src/worldConfig.js +0 -16
package/src/changeset.ts
ADDED
|
@@ -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
|
+
}
|