@volter/world-core 2.0.0
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/LICENSE +202 -0
- package/README.md +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- package/vendor-hosts.cjs +200 -0
package/src/changeset.ts
ADDED
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
import { resolveReferences } from './references.ts';
|
|
2
|
+
import { withAncestryLock } from './ancestry.ts';
|
|
3
|
+
import { captureParentHistory, historyChanges, historyEntries, historyPrefix, readHistoryView, type HistoryReference } from './history.ts';
|
|
4
|
+
import { worldPaths } from './storage.ts';
|
|
5
|
+
// The changeset primitive — "commit" for operational reality (docs/concepts/the-model.md, v0).
|
|
6
|
+
//
|
|
7
|
+
// Everything upstream of it already exists: worlds are the working copy, forks are branches,
|
|
8
|
+
// and each twin's `actions.jsonl` (actions.ts) is the history. What was missing is the object
|
|
9
|
+
// in between: a NAMED, BOUNDED, REPLAYABLE slice of that history — and the `diff` that shows a
|
|
10
|
+
// human what a session actually did before anything becomes real.
|
|
11
|
+
//
|
|
12
|
+
// Three objects, in dependency order:
|
|
13
|
+
//
|
|
14
|
+
// MARKER a cross-service BASE POSITION — per-ledger `{count, lastActionId}` pairs captured
|
|
15
|
+
// at one instant. Not a global sequence number: a world has N independent append-only
|
|
16
|
+
// ledgers with no shared clock, so the only honest "where we were" is the tuple of
|
|
17
|
+
// per-ledger positions. `lastActionId` is what makes it VERIFIABLE — a ledger that was
|
|
18
|
+
// rewritten, purged, or rebuilt under a marker fails loudly at diff time instead of
|
|
19
|
+
// silently reporting the wrong delta.
|
|
20
|
+
//
|
|
21
|
+
// DELTA every action appended after the marker, across every ledger, ordered by
|
|
22
|
+
// `occurredAt` (the same causal order `volter-world tail` uses), grouped by vendor
|
|
23
|
+
// for rendering.
|
|
24
|
+
//
|
|
25
|
+
// CHANGESET a delta frozen into a content-addressed object: `{id, world, base, actions,
|
|
26
|
+
// approvals, applied}` + `contentHash`. The hash covers the IMMUTABLE body only
|
|
27
|
+
// (id/world/base/actions), so a later approval or push receipt appended to the object
|
|
28
|
+
// cannot invalidate the hash the approval bound to — the point of content-addressing.
|
|
29
|
+
//
|
|
30
|
+
// REPLAY is the CI primitive. It feeds recorded actions back through `applyTwinWrite` — the
|
|
31
|
+
// KERNEL write path, not HTTP — into another world's twins.
|
|
32
|
+
//
|
|
33
|
+
// Replay is idempotent because it SAYS SO, not because content happens to hash the same. A local
|
|
34
|
+
// vendor write is an OCCURRENCE — two calls are two actions, even byte-identical in the same
|
|
35
|
+
// instant (BRIEFS/TWIN-RUNTIME-CONTRACT.md, "Local writes: occurrence or replay?") — so the old
|
|
36
|
+
// mechanism, "feed back the same content and the target derives the same id", no longer holds and
|
|
37
|
+
// would make a re-replay DOUBLE-APPLY. Instead replay passes the source action's own id as
|
|
38
|
+
// `actionId`, which is at-most-once on an identity it genuinely holds: a replayed world's action
|
|
39
|
+
// ids equal the source's, and replaying twice is `appended: false` end to end. The uniqueness seam
|
|
40
|
+
// (billable occurrences that must not collapse) still survives, recovered from the source id and
|
|
41
|
+
// passed through verbatim.
|
|
42
|
+
//
|
|
43
|
+
// On top of replay sits the OPERATIONAL-PR CONTRACT (v1): VERIFIERS — deterministic checks
|
|
44
|
+
// against post-replay projected state, each one the kernel's own precondition expression
|
|
45
|
+
// evaluated by the kernel's own evaluator (`checkPrecondition`) — whose results are recorded on
|
|
46
|
+
// the object as `verification`; APPROVALS, each bound to the body hash at signing (drift
|
|
47
|
+
// refuses, loudly); and READINESS — recomputed from the object every time, never trusted off a
|
|
48
|
+
// stored boolean. Verification and approvals are ABOUT-the-body metadata: they live on the
|
|
49
|
+
// object but outside `contentHash`, exactly so recording them cannot invalidate the hash they
|
|
50
|
+
// bound to. The verifier SET, by contrast, is authored content and is hashed (when non-empty),
|
|
51
|
+
// so an approval binds to what will be checked as well as what was done. This contract is
|
|
52
|
+
// consumer-agnostic by design: anything that can render a diff, run a replay and record a
|
|
53
|
+
// signature can implement review on top of it.
|
|
54
|
+
//
|
|
55
|
+
// This module is deliberately WORLD-IGNORANT: it takes ledger references (a state service +
|
|
56
|
+
// the control root that holds it) and never learns what a world is. `volter-world` supplies
|
|
57
|
+
// discovery and the CLI verbs; the state-level logic lives here, beside the ledgers it reads.
|
|
58
|
+
import { createHash } from 'node:crypto';
|
|
59
|
+
import { aliasesFrom, parentEntries, type Entry } from './log.ts';
|
|
60
|
+
import { appendActionIfAbsent, checkPrecondition, listActions, projectedPreconditionValue, projectResources, isTwinBookkeeping } from './actions.ts';
|
|
61
|
+
import type { TwinActionRevertSpec } from './actions.ts';
|
|
62
|
+
import { touchedSubjects } from './actions.ts';
|
|
63
|
+
import type { PerformAction } from './head.ts';
|
|
64
|
+
import type { RemoteExecute } from './remote-execute.ts';
|
|
65
|
+
import type { TwinAction, TwinActionPrecondition, TwinActionPreconditionOp } from './actions.ts';
|
|
66
|
+
import { canonicalJson, hashFieldValue } from './hash.ts';
|
|
67
|
+
import { applyTwinWrite } from './serve.ts';
|
|
68
|
+
import type { TwinResource } from './serve.ts';
|
|
69
|
+
|
|
70
|
+
/** One twin's action ledger, addressed the way the control plane addresses state:
|
|
71
|
+
* `worldPaths(stateService, controlRoot)`. `service` is the caller's label for it (in a world:
|
|
72
|
+
* the world service id); `stateService` is the control-plane service whose ledger this is.
|
|
73
|
+
* They differ whenever a twin records under a different state name than its world service id. */
|
|
74
|
+
export type LedgerRef = {
|
|
75
|
+
/** the vendor/twin as the operator names it — what `diff` groups by and `replay` matches on */
|
|
76
|
+
service: string;
|
|
77
|
+
/** the control-plane state service whose `actions.jsonl` this is (often === service) */
|
|
78
|
+
stateService: string;
|
|
79
|
+
/** control root such that `worldPaths(stateService, controlRoot)` resolves the ledger */
|
|
80
|
+
controlRoot: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** One ledger's recorded position inside a marker. `count` is the row count at capture;
|
|
84
|
+
* `lastActionId` is the verification anchor (see the marker note above). */
|
|
85
|
+
export type LedgerPosition = {
|
|
86
|
+
service: string;
|
|
87
|
+
stateService: string;
|
|
88
|
+
count: number;
|
|
89
|
+
lastActionId: string | null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const MARKER_KIND = 'volter.world.marker.v1';
|
|
93
|
+
export const CHANGESET_KIND = 'volter.world.changeset.v1';
|
|
94
|
+
|
|
95
|
+
/** The base position a diff or changeset is taken against. */
|
|
96
|
+
export type WorldMarker = {
|
|
97
|
+
kind: typeof MARKER_KIND;
|
|
98
|
+
id: string;
|
|
99
|
+
world: string;
|
|
100
|
+
createdAt: string;
|
|
101
|
+
/** Every ledger that existed at capture time. A ledger absent here is treated as position 0
|
|
102
|
+
* (a twin that recorded its first action AFTER the mark contributes its whole ledger). */
|
|
103
|
+
ledgers: LedgerPosition[];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The id reserved for the synthetic "everything this world has ever recorded" base. */
|
|
107
|
+
export const WORLD_BOOT_MARKER_ID = 'world-boot';
|
|
108
|
+
|
|
109
|
+
/** An action inside a delta/changeset, bound to the twin whose ledger recorded it. The raw
|
|
110
|
+
* `TwinAction` is kept verbatim (provenance); `service`/`stateService` are the binding replay
|
|
111
|
+
* needs to route it into the right twin of another world. */
|
|
112
|
+
export type ChangesetAction = {
|
|
113
|
+
service: string;
|
|
114
|
+
stateService: string;
|
|
115
|
+
action: TwinAction;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Per-vendor rollup of a delta, in first-occurrence order — the shape `diff` renders. */
|
|
119
|
+
export type VendorSummary = {
|
|
120
|
+
service: string;
|
|
121
|
+
count: number;
|
|
122
|
+
operations: Array<{ operation: string; count: number; subjectIds: string[] }>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type LedgerDelta = {
|
|
126
|
+
world: string;
|
|
127
|
+
base: WorldMarker;
|
|
128
|
+
/** every action after `base`, across every ledger, ordered by `occurredAt` */
|
|
129
|
+
actions: ChangesetAction[];
|
|
130
|
+
vendors: VendorSummary[];
|
|
131
|
+
total: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** A verifier: one deterministic check against post-replay projected state — the CI assertion
|
|
135
|
+
* of the operational PR (docs/concepts/the-model.md v1). The `assert` is the kernel's ONE check
|
|
136
|
+
* expression (`TwinActionPrecondition`: subject/field/op/value, evaluated by
|
|
137
|
+
* `checkPrecondition`), the same shape write-time preconditions and plan conflicts already
|
|
138
|
+
* use — a verifier is that check pointed at a replay target instead of the authoring world. */
|
|
139
|
+
export type ChangesetVerifier = {
|
|
140
|
+
/** caller-chosen handle, unique within the changeset (results key on it) */
|
|
141
|
+
id: string;
|
|
142
|
+
/** the world service (twin) whose post-replay state the assert reads */
|
|
143
|
+
service: string;
|
|
144
|
+
assert: TwinActionPrecondition;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** One verifier's outcome against a replay target. `assert` is repeated verbatim so the result
|
|
148
|
+
* is readable on its own; `actual` is what the projected state held (omitted when undefined —
|
|
149
|
+
* which is itself what `exists`/`not_exists` distinguish). */
|
|
150
|
+
export type ChangesetVerifierResult = {
|
|
151
|
+
id: string;
|
|
152
|
+
service: string;
|
|
153
|
+
assert: TwinActionPrecondition;
|
|
154
|
+
passed: boolean;
|
|
155
|
+
actual?: unknown;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** The recorded outcome of `changeset verify` — ABOUT-the-body metadata, like approvals: it
|
|
159
|
+
* lives on the object but is outside `contentHash`, and each verify REPLACES the previous
|
|
160
|
+
* record (the provenance fields say exactly which run this is). */
|
|
161
|
+
export type ChangesetVerification = {
|
|
162
|
+
at: string;
|
|
163
|
+
/** where the replay landed — a world name, or 'ephemeral' for a throwaway target */
|
|
164
|
+
into: string;
|
|
165
|
+
/** the body hash at verification time; a later body edit makes this visibly stale */
|
|
166
|
+
contentHash: string;
|
|
167
|
+
/** sha256 over the post-replay projected state of every replay target — two verifies that
|
|
168
|
+
* produced the same world state produce the same digest (the determinism receipt) */
|
|
169
|
+
worldDigest: string;
|
|
170
|
+
passed: boolean;
|
|
171
|
+
results: ChangesetVerifierResult[];
|
|
172
|
+
/** the world's checks (state-system.ts) that refused an entry — CI on deployment, run at verify */
|
|
173
|
+
refusals?: Array<{ actionId: string; service: string; check: string; reason: string }>;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** One signature: who approved, when, against exactly which body hash. The hash-at-signing is
|
|
177
|
+
* the point — an approval is meaningless without the bytes it bound to. */
|
|
178
|
+
export type ChangesetApproval = {
|
|
179
|
+
principal: string;
|
|
180
|
+
at: string;
|
|
181
|
+
contentHash: string;
|
|
182
|
+
note?: string;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export type Changeset = {
|
|
186
|
+
kind: typeof CHANGESET_KIND;
|
|
187
|
+
id: string;
|
|
188
|
+
name: string;
|
|
189
|
+
/** the world it was authored in */
|
|
190
|
+
world: string;
|
|
191
|
+
/** the marker id it forked from */
|
|
192
|
+
base: string;
|
|
193
|
+
createdAt: string;
|
|
194
|
+
actions: ChangesetAction[];
|
|
195
|
+
/** deterministic checks that must pass on replay — part of the hashed body when present,
|
|
196
|
+
* so an approval also binds to WHAT was checked, not just what was done */
|
|
197
|
+
verifiers: ChangesetVerifier[];
|
|
198
|
+
/** latest verify run (replaced, never accumulated) — outside the hash, like approvals */
|
|
199
|
+
verification: ChangesetVerification | null;
|
|
200
|
+
/** who signed, against `contentHash` */
|
|
201
|
+
approvals: ChangesetApproval[];
|
|
202
|
+
/** the governed push's outcome (v2) — receipts and, on a partial apply, the compensation report; null until applied */
|
|
203
|
+
applied: ChangesetApplication | null;
|
|
204
|
+
/** v4 — the CHECKABLE NARRATION: the human summary rendered deterministically from `actions`
|
|
205
|
+
* at authoring (`narrateActions`). Outside the hash — it is DERIVED from the hashed actions, so
|
|
206
|
+
* an approval already binds to it — and re-rendered on every read: a hand-edited narration no
|
|
207
|
+
* longer equals its re-render, and the object is neither approvable nor applicable until
|
|
208
|
+
* re-authored. Absent on objects authored before v4. */
|
|
209
|
+
narration?: string;
|
|
210
|
+
/** The author's own words for the changeset (git's commit message) — what `volter changeset -m`
|
|
211
|
+
* records. Outside the hash, like the narration; a reviewer reads both. */
|
|
212
|
+
message?: string;
|
|
213
|
+
/** v3 — set by `rebaseChangeset`: the hash this object was rebased from, and when. Outside the hash. */
|
|
214
|
+
rebasedFrom?: { contentHash: string; at: string };
|
|
215
|
+
/** PROTOCOL 2 — where the parent log stood when this was cut, per state service (its entry count):
|
|
216
|
+
* a rebase reads the parent's entries since here to name what moved under the changeset. Outside
|
|
217
|
+
* the hash (metadata of the cut, not of the change). */
|
|
218
|
+
cut?: Record<string, HistoryReference>;
|
|
219
|
+
/** sha256 over the canonical JSON of `{id, world, base, actions}` (+ `verifiers` when any) */
|
|
220
|
+
contentHash: string;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Names that address a file on disk (markers, changesets): no separators, no traversal. */
|
|
224
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
225
|
+
|
|
226
|
+
export function assertSafeChangesetName(name: string, what = 'changeset'): string {
|
|
227
|
+
if (!SAFE_NAME.test(name)) {
|
|
228
|
+
throw new Error(`Invalid ${what} name: ${JSON.stringify(name)} (letters, digits, '.', '_' and '-' only, starting with a letter or digit)`);
|
|
229
|
+
}
|
|
230
|
+
return name;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── markers ────────────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** The "everything ever recorded in this world" base: zero ledgers, so every ledger diffs from
|
|
236
|
+
* position 0. Used when a world has no marks yet — `diff` must still answer, not refuse. */
|
|
237
|
+
export function worldBootMarker(world: string, createdAt: string): WorldMarker {
|
|
238
|
+
return { kind: MARKER_KIND, id: WORLD_BOOT_MARKER_ID, world, createdAt, ledgers: [] };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Capture the current position of every ledger — the cross-service base marker. */
|
|
242
|
+
export function captureMarker(opts: { id: string; world: string; ledgers: LedgerRef[]; createdAt?: string }): WorldMarker {
|
|
243
|
+
const ledgers = opts.ledgers.map((ledger) => {
|
|
244
|
+
const rows = listActions(ledger.stateService, ledger.controlRoot);
|
|
245
|
+
return {
|
|
246
|
+
service: ledger.service,
|
|
247
|
+
stateService: ledger.stateService,
|
|
248
|
+
count: rows.length,
|
|
249
|
+
lastActionId: rows.length ? rows[rows.length - 1]!.id : null,
|
|
250
|
+
} satisfies LedgerPosition;
|
|
251
|
+
});
|
|
252
|
+
return { kind: MARKER_KIND, id: opts.id, world: opts.world, createdAt: opts.createdAt ?? new Date().toISOString(), ledgers };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function positionKey(service: string, stateService: string): string {
|
|
256
|
+
return `${service}${stateService}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A marker taken in world A says nothing about world B's ledgers — comparing them would
|
|
260
|
+
* produce a confident, wrong delta. Refuse. */
|
|
261
|
+
export function assertMarkerBelongsTo(marker: WorldMarker, world: string): void {
|
|
262
|
+
if (marker.world !== world) {
|
|
263
|
+
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`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── diff ───────────────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Every action appended after `base`, across `ledgers`, in `occurredAt` order.
|
|
271
|
+
*
|
|
272
|
+
* A ledger is verified against its recorded position before any of it is reported: fewer rows
|
|
273
|
+
* than the marker counted, or a different action id at that position, means the ledger was
|
|
274
|
+
* rewritten (scrub/purge/rebuild) and the marker no longer addresses anything real. That is a
|
|
275
|
+
* loud error — quietly re-basing on a rewritten ledger is how a diff lies.
|
|
276
|
+
*/
|
|
277
|
+
export function diffLedgers(opts: { world: string; ledgers: LedgerRef[]; base: WorldMarker }): LedgerDelta {
|
|
278
|
+
assertMarkerBelongsTo(opts.base, opts.world);
|
|
279
|
+
const positions = new Map(opts.base.ledgers.map((p) => [positionKey(p.service, p.stateService), p]));
|
|
280
|
+
|
|
281
|
+
type Ordered = { entry: ChangesetAction; ledgerIndex: number; rowIndex: number };
|
|
282
|
+
const ordered: Ordered[] = [];
|
|
283
|
+
const seen = new Set<string>();
|
|
284
|
+
|
|
285
|
+
opts.ledgers.forEach((ledger, ledgerIndex) => {
|
|
286
|
+
const key = positionKey(ledger.service, ledger.stateService);
|
|
287
|
+
seen.add(key);
|
|
288
|
+
const rows = listActions(ledger.stateService, ledger.controlRoot);
|
|
289
|
+
const position = positions.get(key);
|
|
290
|
+
const from = position?.count ?? 0;
|
|
291
|
+
if (position && from > 0) {
|
|
292
|
+
if (rows.length < from) {
|
|
293
|
+
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`);
|
|
294
|
+
}
|
|
295
|
+
const anchor = rows[from - 1]!;
|
|
296
|
+
if (anchor.id !== position.lastActionId) {
|
|
297
|
+
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`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
rows.slice(from).forEach((action, offset) => {
|
|
301
|
+
if (isTwinBookkeeping(action)) return; // the twin's own store, never a change the app made
|
|
302
|
+
ordered.push({ entry: { service: ledger.service, stateService: ledger.stateService, action }, ledgerIndex, rowIndex: from + offset });
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// A ledger the marker recorded that has since vanished entirely is the same corruption as a
|
|
307
|
+
// short ledger — the marker's anchor is unverifiable, so the delta cannot be trusted.
|
|
308
|
+
for (const position of opts.base.ledgers) {
|
|
309
|
+
if (position.count > 0 && !seen.has(positionKey(position.service, position.stateService))) {
|
|
310
|
+
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`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
ordered.sort((a, b) => {
|
|
315
|
+
const at = a.entry.action.occurredAt ?? '';
|
|
316
|
+
const bt = b.entry.action.occurredAt ?? '';
|
|
317
|
+
if (at !== bt) return at < bt ? -1 : 1;
|
|
318
|
+
// ties keep discovery order (ledger, then row) — the same stable rule `tail` uses
|
|
319
|
+
if (a.ledgerIndex !== b.ledgerIndex) return a.ledgerIndex - b.ledgerIndex;
|
|
320
|
+
return a.rowIndex - b.rowIndex;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const actions = ordered.map((o) => o.entry);
|
|
324
|
+
return { world: opts.world, base: opts.base, actions, vendors: summarizeByVendor(actions), total: actions.length };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Group a delta by vendor, then by operation — both in FIRST-OCCURRENCE order, so the rollup
|
|
328
|
+
* reads in the same causal order as the actions themselves. */
|
|
329
|
+
export function summarizeByVendor(actions: ChangesetAction[]): VendorSummary[] {
|
|
330
|
+
const vendors = new Map<string, VendorSummary>();
|
|
331
|
+
for (const { service, action } of actions) {
|
|
332
|
+
let vendor = vendors.get(service);
|
|
333
|
+
if (!vendor) {
|
|
334
|
+
vendor = { service, count: 0, operations: [] };
|
|
335
|
+
vendors.set(service, vendor);
|
|
336
|
+
}
|
|
337
|
+
vendor.count += 1;
|
|
338
|
+
const name = action.operation ?? action.op ?? '?';
|
|
339
|
+
let operation = vendor.operations.find((o) => o.operation === name);
|
|
340
|
+
if (!operation) {
|
|
341
|
+
operation = { operation: name, count: 0, subjectIds: [] };
|
|
342
|
+
vendor.operations.push(operation);
|
|
343
|
+
}
|
|
344
|
+
operation.count += 1;
|
|
345
|
+
operation.subjectIds.push(action.subject?.id ?? '?');
|
|
346
|
+
}
|
|
347
|
+
return [...vendors.values()];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** How many subject ids a rendered operation lists before eliding the rest. */
|
|
351
|
+
const MAX_RENDERED_IDS = 4;
|
|
352
|
+
|
|
353
|
+
function renderIds(ids: string[]): string {
|
|
354
|
+
if (ids.length === 0) return '';
|
|
355
|
+
const shown = ids.slice(0, MAX_RENDERED_IDS);
|
|
356
|
+
return ` (${shown.join(', ')}${ids.length > shown.length ? ', …' : ''})`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The human rendering of a delta — one line per vendor plus a total. Shared by `diff` and
|
|
360
|
+
* `changeset show` so a frozen changeset reads exactly like the diff it was cut from. */
|
|
361
|
+
export function formatLedgerDelta(delta: LedgerDelta, opts: { title?: string } = {}): string {
|
|
362
|
+
const title = opts.title ?? `World ${delta.world}`;
|
|
363
|
+
const since = `since ${delta.base.id}${delta.base.createdAt ? ` (${delta.base.createdAt})` : ''}`;
|
|
364
|
+
if (delta.total === 0) return `${title} — no actions ${since}\n`;
|
|
365
|
+
const lines = [`${title} — ${plural(delta.total, 'action')} ${since}`, ''];
|
|
366
|
+
for (const vendor of delta.vendors) {
|
|
367
|
+
const parts = vendor.operations.map((o) => `${o.count} ${o.operation}${renderIds(o.subjectIds)}`);
|
|
368
|
+
lines.push(` ${vendor.service}: ${parts.join(', ')}`);
|
|
369
|
+
}
|
|
370
|
+
lines.push('', ` ${plural(delta.total, 'action')} across ${plural(delta.vendors.length, 'twin')}`);
|
|
371
|
+
return `${lines.join('\n')}\n`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function plural(count: number, noun: string): string {
|
|
375
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── the changeset object ───────────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/** What `changesetContentHash` needs to see — the immutable fields, with `verifiers` optional
|
|
381
|
+
* so a v0 object (authored before verifiers existed) hashes exactly as it always did. */
|
|
382
|
+
export type ChangesetHashable = Pick<Changeset, 'id' | 'world' | 'base' | 'actions'> & Partial<Pick<Changeset, 'verifiers' | 'cut'>>;
|
|
383
|
+
|
|
384
|
+
/** The IMMUTABLE body a `contentHash` covers. `approvals`/`verification`/`applied`/`createdAt`
|
|
385
|
+
* are excluded on purpose: an approval or a verify run must not invalidate the hash it bound
|
|
386
|
+
* to — content-addressing is about the actions, not the object's lifecycle. `verifiers` ARE
|
|
387
|
+
* in the body (when any exist): they are authored content, and an approval must bind to what
|
|
388
|
+
* will be checked, not just what was done. An empty verifier set is omitted so every v0
|
|
389
|
+
* changeset keeps the hash it was signed under. */
|
|
390
|
+
function changesetBody(changeset: ChangesetHashable): Record<string, unknown> {
|
|
391
|
+
const body: Record<string, unknown> = { id: changeset.id, world: changeset.world, base: changeset.base, actions: changeset.actions };
|
|
392
|
+
if (changeset.verifiers?.length) body.verifiers = changeset.verifiers;
|
|
393
|
+
// the cut is part of what an approval binds to: a rebase moves it, and with it the hash — a rebase is a re-review
|
|
394
|
+
if (changeset.cut && Object.keys(changeset.cut).length) body.cut = changeset.cut;
|
|
395
|
+
return body;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function changesetContentHash(changeset: ChangesetHashable): string {
|
|
399
|
+
return `sha256:${createHash('sha256').update(canonicalJson(changesetBody(changeset))).digest('hex')}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const PRECONDITION_OPS: readonly TwinActionPreconditionOp[] = ['exists', 'not_exists', 'eq', 'neq', 'version_eq'];
|
|
403
|
+
|
|
404
|
+
/** A verifier that cannot be evaluated deterministically is refused at authoring time, not
|
|
405
|
+
* discovered at verify time. */
|
|
406
|
+
export function assertValidVerifiers(verifiers: ChangesetVerifier[]): ChangesetVerifier[] {
|
|
407
|
+
const seen = new Set<string>();
|
|
408
|
+
for (const verifier of verifiers) {
|
|
409
|
+
if (!verifier.id?.trim()) throw new Error('Invalid verifier: every verifier needs an id');
|
|
410
|
+
if (seen.has(verifier.id)) throw new Error(`Invalid verifier: duplicate id "${verifier.id}"`);
|
|
411
|
+
seen.add(verifier.id);
|
|
412
|
+
if (!verifier.service?.trim()) throw new Error(`Invalid verifier "${verifier.id}": missing service (the twin whose state it checks)`);
|
|
413
|
+
const assert = verifier.assert;
|
|
414
|
+
if (!assert?.subject?.type || !assert.subject.id || !assert.field) {
|
|
415
|
+
throw new Error(`Invalid verifier "${verifier.id}": assert needs subject {type, id} and a field`);
|
|
416
|
+
}
|
|
417
|
+
if (!PRECONDITION_OPS.includes(assert.op)) {
|
|
418
|
+
throw new Error(`Invalid verifier "${verifier.id}": unknown op ${JSON.stringify(assert.op)} (want ${PRECONDITION_OPS.join('|')})`);
|
|
419
|
+
}
|
|
420
|
+
if ((assert.op === 'eq' || assert.op === 'neq' || assert.op === 'version_eq') && assert.value === undefined) {
|
|
421
|
+
throw new Error(`Invalid verifier "${verifier.id}": op "${assert.op}" needs a value`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return verifiers;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Parse the CLI spelling of a verifier: `<service> <type>:<id> <field> <op> [<value>]`, e.g.
|
|
429
|
+
* `stripe price:price_annual unit_amount eq 47000`. The value is JSON when it parses as JSON
|
|
430
|
+
* (numbers, booleans, quoted strings) and a bare string otherwise, so `eq active` and
|
|
431
|
+
* `eq "active"` mean the same thing.
|
|
432
|
+
*/
|
|
433
|
+
export function parseVerifierExpression(expression: string, id: string): ChangesetVerifier {
|
|
434
|
+
const usage = `want "<service> <type>:<id> <field> <op> [<value>]" with op ${PRECONDITION_OPS.join('|')}`;
|
|
435
|
+
const parts = expression.trim().split(/\s+/);
|
|
436
|
+
const [service, subject, field, op] = parts;
|
|
437
|
+
if (!service || !subject || !field || !op) throw new Error(`Invalid verifier expression ${JSON.stringify(expression)} — ${usage}`);
|
|
438
|
+
const colon = subject.indexOf(':');
|
|
439
|
+
if (colon <= 0 || colon === subject.length - 1) {
|
|
440
|
+
throw new Error(`Invalid verifier expression ${JSON.stringify(expression)}: subject ${JSON.stringify(subject)} is not <type>:<id> — ${usage}`);
|
|
441
|
+
}
|
|
442
|
+
const raw = parts.slice(4).join(' ');
|
|
443
|
+
let value: unknown;
|
|
444
|
+
if (raw) {
|
|
445
|
+
try { value = JSON.parse(raw); } catch { value = raw; }
|
|
446
|
+
}
|
|
447
|
+
const assert: TwinActionPrecondition = {
|
|
448
|
+
subject: { type: subject.slice(0, colon), id: subject.slice(colon + 1) },
|
|
449
|
+
field,
|
|
450
|
+
op: op as TwinActionPreconditionOp,
|
|
451
|
+
...(value === undefined ? {} : { value }),
|
|
452
|
+
};
|
|
453
|
+
return assertValidVerifiers([{ id, service, assert }])[0]!;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Freeze a delta into a named, content-addressed changeset. */
|
|
457
|
+
export function buildChangeset(opts: { name: string; world: string; base: string; actions: ChangesetAction[]; verifiers?: ChangesetVerifier[]; createdAt?: string; message?: string; cut?: Record<string, HistoryReference> }): Changeset {
|
|
458
|
+
assertSafeChangesetName(opts.name);
|
|
459
|
+
const verifiers = assertValidVerifiers(opts.verifiers ?? []);
|
|
460
|
+
const core = { id: `chg_${opts.name}`, world: opts.world, base: opts.base, actions: opts.actions, verifiers, ...(opts.cut === undefined ? {} : { cut: opts.cut }) };
|
|
461
|
+
return {
|
|
462
|
+
kind: CHANGESET_KIND,
|
|
463
|
+
...core,
|
|
464
|
+
name: opts.name,
|
|
465
|
+
createdAt: opts.createdAt ?? new Date().toISOString(),
|
|
466
|
+
verification: null,
|
|
467
|
+
approvals: [],
|
|
468
|
+
applied: null,
|
|
469
|
+
narration: narrateActions(opts.actions),
|
|
470
|
+
...(opts.message === undefined ? {} : { message: opts.message }),
|
|
471
|
+
contentHash: changesetContentHash(core),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Fill the lifecycle fields a changeset authored before v1 lacks on disk, without touching
|
|
476
|
+
* its hash (absent and empty `verifiers` hash identically by construction). */
|
|
477
|
+
export function normalizeChangeset(changeset: Changeset): Changeset {
|
|
478
|
+
return {
|
|
479
|
+
...changeset,
|
|
480
|
+
verifiers: changeset.verifiers ?? [],
|
|
481
|
+
verification: changeset.verification ?? null,
|
|
482
|
+
approvals: changeset.approvals ?? [],
|
|
483
|
+
applied: changeset.applied ?? null,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Recompute and compare — a changeset whose bytes were edited after authoring is not the
|
|
488
|
+
* object anyone approved. Callers render this as a warning or a hard failure as suits. */
|
|
489
|
+
export function changesetHashMatches(changeset: Changeset): boolean {
|
|
490
|
+
return changeset.contentHash === changesetContentHash(changeset);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** One verifier's assert as a readable expression — the same spelling `parseVerifierExpression` accepts. */
|
|
494
|
+
function renderAssert(verifier: { service: string; assert: TwinActionPrecondition }): string {
|
|
495
|
+
const { assert } = verifier;
|
|
496
|
+
return `${verifier.service} ${assert.subject.type}:${assert.subject.id} ${assert.field} ${assert.op}${assert.value === undefined ? '' : ` ${JSON.stringify(assert.value)}`}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function renderVerificationLines(changeset: Changeset): string[] {
|
|
500
|
+
const lines: string[] = [];
|
|
501
|
+
const verification = changeset.verification;
|
|
502
|
+
if (!verification) {
|
|
503
|
+
lines.push(` verified: no${changeset.verifiers.length ? ` (${plural(changeset.verifiers.length, 'verifier')} defined)` : ''}`);
|
|
504
|
+
} else {
|
|
505
|
+
const passed = verification.results.filter((result) => result.passed).length;
|
|
506
|
+
const stale = verification.contentHash !== changeset.contentHash ? ' ** STALE: recorded against a different body hash **' : '';
|
|
507
|
+
lines.push(` verified: ${verification.passed ? 'yes' : 'FAILED'} — ${passed}/${verification.results.length} passed, into ${verification.into} at ${verification.at}${stale}`);
|
|
508
|
+
for (const result of verification.results.filter((entry) => !entry.passed)) {
|
|
509
|
+
lines.push(` FAIL ${result.id}: ${renderAssert(result)} — actual ${result.actual === undefined ? 'undefined' : JSON.stringify(result.actual)}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (changeset.approvals.length === 0) {
|
|
513
|
+
lines.push(' approvals: none');
|
|
514
|
+
} else {
|
|
515
|
+
for (const approval of changeset.approvals) {
|
|
516
|
+
const binds = approval.contentHash === changeset.contentHash ? '' : ' ** signed a DIFFERENT body hash **';
|
|
517
|
+
lines.push(` approved by ${approval.principal} at ${approval.at}${approval.note ? ` — ${approval.note}` : ''}${binds}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return lines;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Render a changeset exactly like the diff it was cut from. */
|
|
524
|
+
export function formatChangeset(changeset: Changeset): string {
|
|
525
|
+
const delta: LedgerDelta = {
|
|
526
|
+
world: changeset.world,
|
|
527
|
+
// `show` renders against the recorded base ID; the marker object itself lives in the
|
|
528
|
+
// authoring world and is not needed to read the changeset.
|
|
529
|
+
base: { kind: MARKER_KIND, id: changeset.base, world: changeset.world, createdAt: '', ledgers: [] },
|
|
530
|
+
actions: changeset.actions,
|
|
531
|
+
vendors: summarizeByVendor(changeset.actions),
|
|
532
|
+
total: changeset.actions.length,
|
|
533
|
+
};
|
|
534
|
+
const lines = [
|
|
535
|
+
`Changeset ${changeset.name} (${changeset.id})`,
|
|
536
|
+
` world: ${changeset.world}`,
|
|
537
|
+
` base: ${changeset.base}`,
|
|
538
|
+
` hash: ${changeset.contentHash}${changesetHashMatches(changeset) ? '' : ' ** MISMATCH: the recorded contentHash does not match the body **'}`,
|
|
539
|
+
...changeset.verifiers.map((verifier) => ` verifier ${verifier.id}: ${renderAssert(verifier)}`),
|
|
540
|
+
...renderVerificationLines(changeset),
|
|
541
|
+
` applied: ${changeset.applied === null ? 'no' : changeset.applied.outcome}`,
|
|
542
|
+
...(changeset.rebasedFrom ? [` rebased from ${changeset.rebasedFrom.contentHash} at ${changeset.rebasedFrom.at}`] : []),
|
|
543
|
+
...(changeset.narration === undefined ? [] : [` narration${narrationDrift(changeset) ? ' ** DRIFTED: this text no longer describes the actions **' : ''}:`, ...changeset.narration.split('\n').map((line) => ` ${line}`)]),
|
|
544
|
+
];
|
|
545
|
+
return `${lines.join('\n')}\n\n${formatLedgerDelta(delta, { title: ` ${plural(changeset.actions.length, 'action')}` })}`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── replay ─────────────────────────────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
export type ReplayTarget = {
|
|
551
|
+
/** the changeset service this target satisfies */
|
|
552
|
+
service: string;
|
|
553
|
+
/** the state service to write into (may differ from the source's) */
|
|
554
|
+
stateService: string;
|
|
555
|
+
controlRoot: string;
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
export type ReplayActionResult = {
|
|
559
|
+
service: string;
|
|
560
|
+
sourceActionId: string;
|
|
561
|
+
/** the action id in the TARGET ledger — identical to `sourceActionId` whenever the target
|
|
562
|
+
* state service matches the source's, which is what makes replay verifiable */
|
|
563
|
+
actionId: string;
|
|
564
|
+
/** 'write' = through `applyTwinWrite` (the kernel write path); 'append' = recorded verbatim */
|
|
565
|
+
via: 'write' | 'append';
|
|
566
|
+
status: 'performed' | 'replayed';
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
export type ReplayReport = {
|
|
570
|
+
changeset: string;
|
|
571
|
+
contentHash: string;
|
|
572
|
+
/** the authoring world */
|
|
573
|
+
world: string;
|
|
574
|
+
into: string;
|
|
575
|
+
total: number;
|
|
576
|
+
performed: number;
|
|
577
|
+
replayed: number;
|
|
578
|
+
services: string[];
|
|
579
|
+
actions: ReplayActionResult[];
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Recover the `uniqueness` value `applyTwinWrite` folded into an action id, or report that the
|
|
584
|
+
* id is not twin-write-shaped at all (a hand-written or pack-appended action).
|
|
585
|
+
*
|
|
586
|
+
* The id format is `twin:<service>:<operation>:<subjectId>:<occurredAt>:<contentHash>[:<uniqueness>]`
|
|
587
|
+
* and both `operation` and `subjectId` may themselves contain ':', so the id cannot be split
|
|
588
|
+
* field-wise. A repeated write additionally carries an occurrence ordinal (`…#1`), stripped below. Instead the deterministic prefix is REBUILT from the action's own recorded parts —
|
|
589
|
+
* if it matches, whatever follows is exactly the uniqueness seam. Getting this right is what
|
|
590
|
+
* keeps billable occurrences (two identical AI completions in one millisecond) from collapsing
|
|
591
|
+
* into one action on replay.
|
|
592
|
+
*/
|
|
593
|
+
export function twinWriteShape(action: TwinAction): { uniqueness?: string } | null {
|
|
594
|
+
if (action.op !== 'set' || !action.operation || !action.fields) return null;
|
|
595
|
+
const hash = hashFieldValue({ operation: action.operation, subjectId: action.subject.id, fields: action.fields });
|
|
596
|
+
const prefix = `twin:${action.service}:${action.operation}:${action.subject.id}:${action.occurredAt}:${hash}`;
|
|
597
|
+
// A REPEAT carries an occurrence ordinal (`…#1`). Strip it before matching, or every repeated
|
|
598
|
+
// write falls out of the write path and replays through the `append` fallback — which is
|
|
599
|
+
// reserved for actions the write path cannot EXPRESS, and which the report labels accordingly.
|
|
600
|
+
// The ruling makes repeats ordinary, so without this the first changeset containing one flips a
|
|
601
|
+
// replay assertion that has nothing to do with it. The ordinal is digits after a trailing '#',
|
|
602
|
+
// and a content hash is hex, so this cannot eat a real id's tail.
|
|
603
|
+
const id = action.id.replace(/#\d+$/, '');
|
|
604
|
+
if (id === prefix) return {};
|
|
605
|
+
if (id.startsWith(`${prefix}:`)) return { uniqueness: id.slice(prefix.length + 1) };
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Replay a changeset's actions, in order, into `targets`.
|
|
611
|
+
*
|
|
612
|
+
* Two paths, and the split is about EXPRESSIVENESS, not preference:
|
|
613
|
+
* - `write` — the default and the point: back through `applyTwinWrite`, the same kernel entry
|
|
614
|
+
* an SDK call lands on. The target re-derives the action id from content, so it matches the
|
|
615
|
+
* source id and a second replay is a total no-op.
|
|
616
|
+
* - `append` — the honest fallback for actions the write path cannot express: multi-resource
|
|
617
|
+
* `projection` transactions (applyTwinWrite carries only flat `fields`, so routing them
|
|
618
|
+
* through it would silently DROP the creates/deletes/emits) and `revert`/`confirm`
|
|
619
|
+
* bookkeeping rows. These are appended verbatim under `appendActionIfAbsent`, which is
|
|
620
|
+
* id-keyed — so they are idempotent on re-replay too.
|
|
621
|
+
*
|
|
622
|
+
* `preconditions` are deliberately NOT replayed. They were evaluated against the authoring
|
|
623
|
+
* world's state at author time; re-evaluating them against a different base would fail replays
|
|
624
|
+
* that are perfectly valid recordings. Verifying a changeset against a target's state is v1's
|
|
625
|
+
* job (verifiers), not a silent side effect of replay. Dropping them cannot change any action
|
|
626
|
+
* id — preconditions are not part of the content hash.
|
|
627
|
+
*/
|
|
628
|
+
export async function replayChangeset(
|
|
629
|
+
changeset: Changeset,
|
|
630
|
+
opts: { into: string; targets: ReplayTarget[]; available?: string[] },
|
|
631
|
+
): Promise<ReplayReport> {
|
|
632
|
+
const byService = new Map(opts.targets.map((t) => [t.service, t]));
|
|
633
|
+
const needed = [...new Set(changeset.actions.map((a) => a.service))];
|
|
634
|
+
const missing = needed.filter((service) => !byService.has(service));
|
|
635
|
+
if (missing.length) {
|
|
636
|
+
const have = opts.available ?? opts.targets.map((t) => t.service);
|
|
637
|
+
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'})`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const results: ReplayActionResult[] = [];
|
|
641
|
+
for (const entry of changeset.actions) {
|
|
642
|
+
const target = byService.get(entry.service)!;
|
|
643
|
+
const action = entry.action;
|
|
644
|
+
const shape = action.projection ? null : twinWriteShape(action);
|
|
645
|
+
if (shape) {
|
|
646
|
+
const { result } = await applyTwinWrite(
|
|
647
|
+
target.stateService,
|
|
648
|
+
{
|
|
649
|
+
operation: action.operation!,
|
|
650
|
+
subjectType: action.subject.type,
|
|
651
|
+
subjectId: action.subject.id,
|
|
652
|
+
fields: action.fields!,
|
|
653
|
+
occurredAt: action.occurredAt,
|
|
654
|
+
...(action.actor ? { actor: action.actor } : {}),
|
|
655
|
+
...(action.correlationId ? { correlationId: action.correlationId } : {}),
|
|
656
|
+
...(shape.uniqueness === undefined ? {} : { uniqueness: shape.uniqueness }),
|
|
657
|
+
// AT MOST ONCE, AND THE SOURCE'S IDENTITY. Replay re-materializes an action that
|
|
658
|
+
// already has an id, and the target must keep it — a replayed world's action ids equal
|
|
659
|
+
// the source's, which the CI primitive pins. Local vendor writes are occurrences by
|
|
660
|
+
// default and no longer collapse on content, which is what silently dropped a write
|
|
661
|
+
// returning a value to what it was one instant earlier; replay is the caller that
|
|
662
|
+
// genuinely wants dedupe and is the one that can say so honestly, because it holds the
|
|
663
|
+
// identity. See the runtime contract, "Local writes: occurrence or replay?".
|
|
664
|
+
actionId: action.id,
|
|
665
|
+
},
|
|
666
|
+
target.controlRoot,
|
|
667
|
+
);
|
|
668
|
+
results.push({ service: entry.service, sourceActionId: action.id, actionId: result.actionId, via: 'write', status: result.status });
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
// Drop preconditions (evaluated in the source world) AND the source world's shadowBasis
|
|
672
|
+
// (refs into the SOURCE's event log — meaningless against the target's mirror; carrying
|
|
673
|
+
// it verbatim would poison the target's non-fast-forward check, R14). The appender
|
|
674
|
+
// re-stamps against the target; replay identity excludes the basis, so re-stamping
|
|
675
|
+
// cannot conflict with a prior copy.
|
|
676
|
+
const { preconditions: _dropped, ...rest } = action;
|
|
677
|
+
const { appended } = appendActionIfAbsent({ ...rest, service: target.stateService }, target.controlRoot);
|
|
678
|
+
results.push({ service: entry.service, sourceActionId: action.id, actionId: action.id, via: 'append', status: appended ? 'performed' : 'replayed' });
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
changeset: changeset.name,
|
|
683
|
+
contentHash: changeset.contentHash,
|
|
684
|
+
world: changeset.world,
|
|
685
|
+
into: opts.into,
|
|
686
|
+
total: results.length,
|
|
687
|
+
performed: results.filter((r) => r.status === 'performed').length,
|
|
688
|
+
replayed: results.filter((r) => r.status === 'replayed').length,
|
|
689
|
+
services: needed,
|
|
690
|
+
actions: results,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function formatReplayReport(report: ReplayReport): string {
|
|
695
|
+
const lines = [
|
|
696
|
+
`Replayed changeset ${report.changeset} (${report.world} → ${report.into})`,
|
|
697
|
+
` ${plural(report.total, 'action')}: ${report.performed} performed, ${report.replayed} already replayed`,
|
|
698
|
+
` twins: ${report.services.join(', ') || 'none'}`,
|
|
699
|
+
` hash: ${report.contentHash}`,
|
|
700
|
+
];
|
|
701
|
+
return `${lines.join('\n')}\n`;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── verify / approve / status — the operational-PR contract (v1) ───────────────────────────────
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Run a changeset's verifiers against post-replay state. Deterministic on purpose: each assert
|
|
708
|
+
* reads the target's PROJECTED state (`projectResources` — the same projection the kernel's
|
|
709
|
+
* preconditions and plan conflicts read) and is evaluated by `checkPrecondition` — the same
|
|
710
|
+
* evaluator, so a verifier means exactly what a precondition means. The `worldDigest` is a
|
|
711
|
+
* receipt over everything the verifiers could have seen: two runs that produced the same
|
|
712
|
+
* post-replay state produce the same digest.
|
|
713
|
+
*
|
|
714
|
+
* `targets` are the same references replay wrote through — a verifier is only honest against
|
|
715
|
+
* the ledgers the replay actually landed in. A verifier naming a service with no target is a
|
|
716
|
+
* loud error (there is nothing real to check it against), never a silent pass.
|
|
717
|
+
*/
|
|
718
|
+
export function runChangesetVerifiers(
|
|
719
|
+
changeset: Changeset,
|
|
720
|
+
targets: ReplayTarget[],
|
|
721
|
+
opts: { at: string; into: string },
|
|
722
|
+
): ChangesetVerification {
|
|
723
|
+
const resourcesByService = new Map<string, TwinResource[]>();
|
|
724
|
+
const digestInput: Array<{ service: string; stateService: string; resources: unknown }> = [];
|
|
725
|
+
for (const target of [...targets].sort((a, b) => (a.service === b.service ? (a.stateService < b.stateService ? -1 : 1) : a.service < b.service ? -1 : 1))) {
|
|
726
|
+
const resources = projectResources(target.stateService, target.controlRoot);
|
|
727
|
+
// one world service may record under several state services (aws → s3 + dynamodb):
|
|
728
|
+
// its verifiers see the union
|
|
729
|
+
resourcesByService.set(target.service, [...(resourcesByService.get(target.service) ?? []), ...resources]);
|
|
730
|
+
digestInput.push({ service: target.service, stateService: target.stateService, resources });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const results: ChangesetVerifierResult[] = changeset.verifiers.map((verifier) => {
|
|
734
|
+
const resources = resourcesByService.get(verifier.service);
|
|
735
|
+
if (!resources) {
|
|
736
|
+
const have = [...resourcesByService.keys()].sort();
|
|
737
|
+
throw new Error(`Verifier "${verifier.id}" checks twin "${verifier.service}" but the replay target has no such twin (targets: ${have.join(', ') || 'none'})`);
|
|
738
|
+
}
|
|
739
|
+
const { assert } = verifier;
|
|
740
|
+
const actual = projectedPreconditionValue(assert, resources);
|
|
741
|
+
const passed = checkPrecondition(assert, actual);
|
|
742
|
+
return { id: verifier.id, service: verifier.service, assert, passed, ...(actual === undefined ? {} : { actual }) };
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
at: opts.at,
|
|
747
|
+
into: opts.into,
|
|
748
|
+
contentHash: changesetContentHash(changeset),
|
|
749
|
+
worldDigest: `sha256:${createHash('sha256').update(canonicalJson(digestInput)).digest('hex')}`,
|
|
750
|
+
passed: results.every((result) => result.passed),
|
|
751
|
+
results,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Record `verification` on the object — REPLACING any prior run (the provenance fields carry
|
|
756
|
+
* which run this is), never touching the hash the body is addressed by. */
|
|
757
|
+
export function withVerification(changeset: Changeset, verification: ChangesetVerification): Changeset {
|
|
758
|
+
return { ...changeset, verification };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Append an approval bound to the changeset's CURRENT body hash. Refuses when the stored
|
|
763
|
+
* `contentHash` no longer matches the body: an object whose bytes moved after authoring is not
|
|
764
|
+
* the object anyone reviewed, and signing it would launder the drift. Loud, never silent.
|
|
765
|
+
*/
|
|
766
|
+
export function approveChangeset(
|
|
767
|
+
changeset: Changeset,
|
|
768
|
+
opts: { principal: string; at?: string; note?: string },
|
|
769
|
+
): { changeset: Changeset; approval: ChangesetApproval } {
|
|
770
|
+
if (!opts.principal?.trim()) throw new Error('Approval requires a principal (--as <principal>)');
|
|
771
|
+
const bodyHash = changesetContentHash(changeset);
|
|
772
|
+
if (changeset.contentHash !== bodyHash) {
|
|
773
|
+
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`);
|
|
774
|
+
}
|
|
775
|
+
if (narrationDrift(changeset)) throw new Error(`Refusing to approve changeset "${changeset.name}": its narration no longer describes its actions — a hand-edited summary cannot be signed; re-author it`);
|
|
776
|
+
const approval: ChangesetApproval = {
|
|
777
|
+
principal: opts.principal.trim(),
|
|
778
|
+
at: opts.at ?? new Date().toISOString(),
|
|
779
|
+
contentHash: bodyHash,
|
|
780
|
+
...(opts.note ? { note: opts.note } : {}),
|
|
781
|
+
};
|
|
782
|
+
return { changeset: { ...changeset, approvals: [...changeset.approvals, approval] }, approval };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Everything `changeset status` reports — recomputed from the object, never trusted off
|
|
786
|
+
* stored booleans (the same rule `planRequiresApproval` follows). */
|
|
787
|
+
export type ChangesetReadiness = {
|
|
788
|
+
name: string;
|
|
789
|
+
world: string;
|
|
790
|
+
contentHash: string;
|
|
791
|
+
/** stored hash matches the body */
|
|
792
|
+
hashOk: boolean;
|
|
793
|
+
/** a verification exists, binds to the current hash, and every verifier passed */
|
|
794
|
+
verified: boolean;
|
|
795
|
+
verification: ChangesetVerification | null;
|
|
796
|
+
/** every approval on the object */
|
|
797
|
+
approvals: number;
|
|
798
|
+
/** approvals whose hash-at-signing is the current body hash — the only ones that count */
|
|
799
|
+
bindingApprovals: number;
|
|
800
|
+
ready: boolean;
|
|
801
|
+
/** empty exactly when ready */
|
|
802
|
+
reasons: string[];
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
export function changesetReadiness(changeset: Changeset): ChangesetReadiness {
|
|
806
|
+
const bodyHash = changesetContentHash(changeset);
|
|
807
|
+
const hashOk = changeset.contentHash === bodyHash;
|
|
808
|
+
const verification = changeset.verification;
|
|
809
|
+
const binding = changeset.approvals.filter((approval) => approval.contentHash === changeset.contentHash);
|
|
810
|
+
|
|
811
|
+
const reasons: string[] = [];
|
|
812
|
+
if (!hashOk) reasons.push(`contentHash mismatch: stored ${changeset.contentHash}, body hashes to ${bodyHash} — the object was edited after authoring`);
|
|
813
|
+
const drift = narrationDrift(changeset);
|
|
814
|
+
if (drift) reasons.push('narration drifted from the actions — the summary a reviewer reads no longer describes what would be done; re-author');
|
|
815
|
+
if (!verification) {
|
|
816
|
+
reasons.push('never verified — run `changeset verify`');
|
|
817
|
+
} else if (verification.contentHash !== changeset.contentHash) {
|
|
818
|
+
reasons.push(`verification is stale: it ran against ${verification.contentHash} — re-run \`changeset verify\``);
|
|
819
|
+
} else if (!verification.passed) {
|
|
820
|
+
const failed = verification.results.filter((result) => !result.passed);
|
|
821
|
+
reasons.push(`verification FAILED: ${failed.map((result) => result.id).join(', ')} (${failed.length} of ${verification.results.length})`);
|
|
822
|
+
}
|
|
823
|
+
if (binding.length === 0) {
|
|
824
|
+
reasons.push(changeset.approvals.length ? 'no approval binds to the current body hash' : 'no approvals — run `changeset approve --as <principal>`');
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const verified = Boolean(hashOk && verification && verification.contentHash === changeset.contentHash && verification.passed);
|
|
828
|
+
return {
|
|
829
|
+
name: changeset.name,
|
|
830
|
+
world: changeset.world,
|
|
831
|
+
contentHash: changeset.contentHash,
|
|
832
|
+
hashOk,
|
|
833
|
+
verified,
|
|
834
|
+
verification,
|
|
835
|
+
approvals: changeset.approvals.length,
|
|
836
|
+
bindingApprovals: binding.length,
|
|
837
|
+
ready: reasons.length === 0,
|
|
838
|
+
reasons,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** The single honest line `changeset status` prints. No push here — v2's job; this line only
|
|
843
|
+
* tells the truth about the object. */
|
|
844
|
+
export function formatChangesetStatus(readiness: ChangesetReadiness): string {
|
|
845
|
+
const verdict = readiness.ready ? 'ready' : `not-ready (${readiness.reasons.join('; ')})`;
|
|
846
|
+
return `${readiness.name} ${readiness.contentHash} verified=${readiness.verified ? 'yes' : 'no'} approvals=${readiness.bindingApprovals} ${verdict}\n`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** The verify verb's report — the verification plus the replay that produced it. */
|
|
850
|
+
export function formatVerification(name: string, verification: ChangesetVerification, report: ReplayReport): string {
|
|
851
|
+
const lines = [
|
|
852
|
+
`Verified changeset ${name} → ${verification.into}`,
|
|
853
|
+
` replay: ${plural(report.total, 'action')} (${report.performed} performed, ${report.replayed} already replayed)`,
|
|
854
|
+
verification.results.length === 0
|
|
855
|
+
? ' verifiers: none (replay itself is the only check)'
|
|
856
|
+
: ` verifiers: ${verification.results.filter((result) => result.passed).length}/${verification.results.length} passed`,
|
|
857
|
+
...verification.results.map((result) => ` ${result.passed ? 'PASS' : 'FAIL'} ${result.id}: ${renderAssert(result)}${result.passed ? '' : ` — actual ${result.actual === undefined ? 'undefined' : JSON.stringify(result.actual)}`}`),
|
|
858
|
+
` worldDigest: ${verification.worldDigest}`,
|
|
859
|
+
` verdict: ${verification.passed ? 'PASSED' : 'FAILED'}`,
|
|
860
|
+
];
|
|
861
|
+
return `${lines.join('\n')}\n`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ── v2: governed push — apply ───────────────────────────────────────────────────────────────
|
|
865
|
+
//
|
|
866
|
+
|
|
867
|
+
/** The object with its application recorded — `applied` is outside the hash. */
|
|
868
|
+
export function withApplication(changeset: Changeset, application: ChangesetApplication): Changeset {
|
|
869
|
+
return { ...changeset, applied: application };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export function formatApplication(name: string, application: ChangesetApplication): string {
|
|
873
|
+
const lines = [`Applied changeset ${name}: ${application.outcome}${application.forced ? ' (forced past the readiness gate)' : ''}`];
|
|
874
|
+
if (application.refusal) for (const r of application.refusal) lines.push(` refused: ${r}`);
|
|
875
|
+
for (const r of application.receipts) lines.push(` ${r.status.padEnd(9)} ${r.service} ${r.actionId}${r.externalId ? ` → ${r.externalId}` : ''}${r.error ? ` — ${r.error}` : ''}`);
|
|
876
|
+
if (application.compensation.length) {
|
|
877
|
+
lines.push(' COMPENSATION — what crossed before the failure, and the inverse each one would take:');
|
|
878
|
+
for (const c of application.compensation) lines.push(` ${c.service} ${c.subject.type}:${c.subject.id}${c.externalId ? ` (vendor id ${c.externalId})` : ''}: ${c.revert.strategy}${'operation' in c.revert && c.revert.operation ? ` ${c.revert.operation}` : ''}`);
|
|
879
|
+
}
|
|
880
|
+
return `${lines.join('\n')}\n`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ── v4: the checkable narration ────────────────────────────────────────────────────────────
|
|
884
|
+
//
|
|
885
|
+
// The narration is what a reviewer READS; the actions are what would be DONE. They are the same
|
|
886
|
+
// thing only if the narration is a pure function of the actions — so it is: rendered here at
|
|
887
|
+
// authoring, inside the hash, and re-rendered on every read. A narration that no longer equals
|
|
888
|
+
// its re-render (a hand edit, a stale copy) makes the object not-ready: neither approvable nor
|
|
889
|
+
// applicable until re-authored. Deterministic by construction — causal order, full subject
|
|
890
|
+
// lists, the field names each write sets, no clock, no elision.
|
|
891
|
+
|
|
892
|
+
function narrateFields(action: TwinAction): string {
|
|
893
|
+
const names = Object.keys(action.fields ?? {}).sort();
|
|
894
|
+
if (names.length === 0) return '';
|
|
895
|
+
return ` setting ${names.join(', ')}`;
|
|
896
|
+
}
|
|
897
|
+
/** One deterministic paragraph per twin, one sentence per operation, in first-occurrence order. */
|
|
898
|
+
export function narrateActions(actions: ChangesetAction[]): string {
|
|
899
|
+
if (actions.length === 0) return 'Does nothing: the changeset carries no actions.';
|
|
900
|
+
const paragraphs: string[] = [];
|
|
901
|
+
for (const vendor of summarizeByVendor(actions)) {
|
|
902
|
+
const sentences = vendor.operations.map((operation) => {
|
|
903
|
+
const rows = actions.filter((entry) => entry.service === vendor.service && (entry.action.operation ?? entry.action.op ?? '?') === operation.operation);
|
|
904
|
+
const fieldSets = [...new Set(rows.map((entry) => narrateFields(entry.action)))];
|
|
905
|
+
const fields = fieldSets.length === 1 ? fieldSets[0]! : '';
|
|
906
|
+
return `${operation.count} × ${operation.operation} on ${rows.map((entry) => `${entry.action.subject.type}:${entry.action.subject.id}`).join(', ')}${fields}.`;
|
|
907
|
+
});
|
|
908
|
+
paragraphs.push(`${vendor.service}: ${sentences.join(' ')}`);
|
|
909
|
+
}
|
|
910
|
+
return paragraphs.join('\n');
|
|
911
|
+
}
|
|
912
|
+
/** The recorded narration against its re-render — null when they agree or when the object carries none. */
|
|
913
|
+
export function narrationDrift(changeset: Pick<Changeset, 'actions'> & Partial<Pick<Changeset, 'narration'>>): { recorded: string; expected: string } | null {
|
|
914
|
+
if (changeset.narration === undefined) return null;
|
|
915
|
+
const expected = narrateActions(changeset.actions);
|
|
916
|
+
return expected === changeset.narration ? null : { recorded: changeset.narration, expected };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ── v3: rebase onto a moved mirror ──────────────────────────────────────────────────────────
|
|
920
|
+
//
|
|
921
|
+
// Every `set` action carries its merge base (`shadowBasis`: the remote ref of each subject it
|
|
922
|
+
// touches, stamped at append) and push refuses on drift against it. A changeset authored before
|
|
923
|
+
// the mirror moved is therefore unapplicable after a fetch — correct, and until v3 terminal.
|
|
924
|
+
// `rebaseChangeset` is the merge: per action, the current refs of its subjects are read in the
|
|
925
|
+
// authoring world; an unmoved basis is carried; a moved basis has the action's preconditions
|
|
926
|
+
// re-evaluated against the world's current projected state — pass: re-stamped and carried;
|
|
927
|
+
// fail: a CONFLICT by subject, field, expected and actual, and the object is not rewritten. A
|
|
928
|
+
// moved basis with no precondition is carried re-stamped and reported `rebased-blind`. The
|
|
929
|
+
// rebased object keeps id, name, base and every action id; its hash moves (the actions moved),
|
|
930
|
+
// so approvals drop and the verification is stale — a rebase is a re-review.
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
export type ApplyReceipt = {
|
|
934
|
+
actionId: string;
|
|
935
|
+
service: string;
|
|
936
|
+
stateService: string;
|
|
937
|
+
status: 'confirmed' | 'replayed' | 'failed' | 'skipped';
|
|
938
|
+
pushId?: string;
|
|
939
|
+
externalId?: string;
|
|
940
|
+
url?: string;
|
|
941
|
+
error?: string;
|
|
942
|
+
};
|
|
943
|
+
export type CompensationEntry = {
|
|
944
|
+
service: string;
|
|
945
|
+
actionId: string;
|
|
946
|
+
subject: { type: string; id: string };
|
|
947
|
+
externalId?: string;
|
|
948
|
+
/** the action's own revert spec when it carries one, else the inverse on the vendor id */
|
|
949
|
+
revert: TwinActionRevertSpec | { strategy: 'inverse' };
|
|
950
|
+
};
|
|
951
|
+
/** How a push went: the receipts origin answered, recorded on the changeset object. */
|
|
952
|
+
export type ChangesetApplication = {
|
|
953
|
+
at: string;
|
|
954
|
+
contentHash: string;
|
|
955
|
+
outcome: 'applied' | 'partial' | 'refused';
|
|
956
|
+
forced?: boolean;
|
|
957
|
+
refusal?: string[];
|
|
958
|
+
receipts: ApplyReceipt[];
|
|
959
|
+
compensation: CompensationEntry[];
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
export type RebaseTarget = { service: string; stateService: string; controlRoot: string };
|
|
963
|
+
export type RebaseActionResult = {
|
|
964
|
+
actionId: string;
|
|
965
|
+
service: string;
|
|
966
|
+
status: 'unchanged' | 'rebased' | 'rebased-blind' | 'conflict';
|
|
967
|
+
moved?: Record<string, { base: string | null; current: string | null }>;
|
|
968
|
+
conflicts?: Array<{ subject: { type: string; id: string }; field: string; op: string; expected?: unknown; actual: unknown }>;
|
|
969
|
+
};
|
|
970
|
+
export type RebaseReport = { changeset: string; from: string; to: string | null; at: string; outcome: 'unchanged' | 'rebased' | 'conflict'; actions: RebaseActionResult[] };
|
|
971
|
+
|
|
972
|
+
export function rebaseChangeset(changeset: Changeset, opts: { targets: RebaseTarget[]; at?: string }): { changeset: Changeset; report: RebaseReport } {
|
|
973
|
+
return withAncestryLock(() => rebaseChangesetLocked(changeset, opts));
|
|
974
|
+
}
|
|
975
|
+
function rebaseChangesetLocked(changeset: Changeset, opts: { targets: RebaseTarget[]; at?: string }): { changeset: Changeset; report: RebaseReport } {
|
|
976
|
+
const at = opts.at ?? new Date().toISOString();
|
|
977
|
+
const targetFor = new Map(opts.targets.map((t) => [t.service, t]));
|
|
978
|
+
// PROTOCOL 2: what the parent did since the cut, per service — the entries past the cut position.
|
|
979
|
+
// A set action conflicts where the parent set the same field of the same subject to something
|
|
980
|
+
// else; a subject the parent touched otherwise is rebased; the rest is unchanged.
|
|
981
|
+
const sinceCut = new Map<string, Entry[]>();
|
|
982
|
+
const aliases = new Map<string, Map<string, string>>();
|
|
983
|
+
for (const t of opts.targets) {
|
|
984
|
+
const cut = changeset.cut?.[t.service] ?? changeset.cut?.[t.stateService];
|
|
985
|
+
if (!cut) continue;
|
|
986
|
+
const directory = worldPaths(t.stateService, t.controlRoot).dir;
|
|
987
|
+
const current = captureParentHistory(t.stateService, t.controlRoot);
|
|
988
|
+
aliases.set(t.service, aliasesFrom(historyEntries(readHistoryView(directory, current.view))));
|
|
989
|
+
sinceCut.set(t.service, historyChanges(t.stateService, historyPrefix(readHistoryView(directory, cut.view), cut.position), readHistoryView(directory, current.view)));
|
|
990
|
+
}
|
|
991
|
+
const stable = (v: unknown): string => JSON.stringify(v) ?? 'undefined';
|
|
992
|
+
// a field both sides set to an INSTANT is when each wrote, not what: never a conflict
|
|
993
|
+
const instant = (v: unknown): boolean => typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(v) && Number.isFinite(Date.parse(v));
|
|
994
|
+
const missing = [...new Set(changeset.actions.map((e) => e.service))].filter((s) => !targetFor.has(s));
|
|
995
|
+
if (missing.length) throw new Error(`Cannot rebase changeset "${changeset.name}": no target for ${missing.join(', ')}`);
|
|
996
|
+
const results: RebaseActionResult[] = [];
|
|
997
|
+
const actions: ChangesetAction[] = [];
|
|
998
|
+
for (const entry of changeset.actions) {
|
|
999
|
+
const target = targetFor.get(entry.service)!;
|
|
1000
|
+
const action = entry.action;
|
|
1001
|
+
const since = sinceCut.get(entry.service);
|
|
1002
|
+
if (action.op === 'set' && since !== undefined) {
|
|
1003
|
+
const mine = resolveReferences(target.stateService, action.subject.type, action.fields ?? {}, aliases.get(entry.service) ?? new Map());
|
|
1004
|
+
const touching = since.filter((e) => e.op === 'set' && e.subject.type === action.subject.type && (e.subject.id === action.subject.id || e.aliasOf === action.subject.id));
|
|
1005
|
+
const conflicts: NonNullable<RebaseActionResult['conflicts']> = [];
|
|
1006
|
+
for (const e of touching) if (e.fields?.deleted === true && mine.deleted !== true) conflicts.push({ subject: action.subject, field: 'deleted', op: 'exists', expected: true, actual: false });
|
|
1007
|
+
for (const e of touching) for (const [field, value] of Object.entries(e.fields ?? {})) if (field in mine && stable(mine[field]) !== stable(value) && !(instant(value) && instant(mine[field]))) conflicts.push({ subject: action.subject, field, op: 'set', expected: mine[field], actual: value });
|
|
1008
|
+
if (conflicts.length) { actions.push(entry); results.push({ actionId: action.id, service: entry.service, status: 'conflict', conflicts }); continue; }
|
|
1009
|
+
if (touching.length) { actions.push(entry); results.push({ actionId: action.id, service: entry.service, status: 'rebased' }); continue; }
|
|
1010
|
+
}
|
|
1011
|
+
actions.push(entry); results.push({ actionId: action.id, service: entry.service, status: 'unchanged' });
|
|
1012
|
+
}
|
|
1013
|
+
const conflict = results.some((r) => r.status === 'conflict');
|
|
1014
|
+
const movedAny = results.some((r) => r.status === 'rebased' || r.status === 'rebased-blind');
|
|
1015
|
+
if (conflict || !movedAny) {
|
|
1016
|
+
return { changeset, report: { changeset: changeset.name, from: changeset.contentHash, to: null, at, outcome: conflict ? 'conflict' : 'unchanged', actions: results } };
|
|
1017
|
+
}
|
|
1018
|
+
// the cut moves to where the parent stands now: the hash moves with it
|
|
1019
|
+
const cut = Object.fromEntries(opts.targets.map((t) => [t.service, captureParentHistory(t.stateService, t.controlRoot)]));
|
|
1020
|
+
const core = { id: changeset.id, world: changeset.world, base: changeset.base, actions, verifiers: changeset.verifiers, cut };
|
|
1021
|
+
const rebased: Changeset = { ...changeset, ...core, ...(changeset.narration === undefined ? {} : { narration: narrateActions(actions) }), contentHash: changesetContentHash(core), approvals: [], verification: null, rebasedFrom: { contentHash: changeset.contentHash, at } };
|
|
1022
|
+
return { changeset: rebased, report: { changeset: changeset.name, from: changeset.contentHash, to: rebased.contentHash, at, outcome: 'rebased', actions: results } };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export function formatRebaseReport(report: RebaseReport): string {
|
|
1026
|
+
const lines = [`Rebase of changeset ${report.changeset}: ${report.outcome}${report.to ? ` (${report.from} → ${report.to}; approvals dropped, verification stale — re-review)` : ''}`];
|
|
1027
|
+
for (const r of report.actions) {
|
|
1028
|
+
lines.push(` ${r.status.padEnd(13)} ${r.service} ${r.actionId}${r.moved ? ` — moved: ${Object.keys(r.moved).join(', ')}` : ''}`);
|
|
1029
|
+
for (const c of r.conflicts ?? []) lines.push(` CONFLICT ${c.subject.type}:${c.subject.id}.${c.field} ${c.op}${'expected' in c ? ` ${JSON.stringify(c.expected)}` : ''} — actual ${JSON.stringify(c.actual)}`);
|
|
1030
|
+
}
|
|
1031
|
+
return `${lines.join('\n')}\n`;
|
|
1032
|
+
}
|