driftseal 2.0.0 → 3.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.
@@ -0,0 +1,377 @@
1
+ 'use strict';
2
+
3
+ function createOutcomeFold({ fail, contentHash, logVersion, defaultLane }) {
4
+ if (typeof fail !== 'function' || typeof contentHash !== 'function') {
5
+ throw new TypeError('createOutcomeFold requires fail and contentHash functions');
6
+ }
7
+
8
+ function emptyLaneCatalog() {
9
+ return new Map([
10
+ [
11
+ defaultLane,
12
+ {
13
+ name: defaultLane,
14
+ description: null,
15
+ addedAt: null,
16
+ head: null,
17
+ },
18
+ ],
19
+ ]);
20
+ }
21
+
22
+ function outcomeContractHash(record) {
23
+ return contentHash(
24
+ JSON.stringify({
25
+ outcome: record.outcome,
26
+ extensions: record.extensions.map(({ extension, acceptance, verify, decisions }) => ({
27
+ extension,
28
+ acceptance,
29
+ verify,
30
+ decisions,
31
+ })),
32
+ acceptance: record.acceptance,
33
+ verify: record.verify,
34
+ decisions: record.decisions,
35
+ })
36
+ );
37
+ }
38
+
39
+ function newOutcomeRecord(ev) {
40
+ const record = {
41
+ id: ev.id,
42
+ tsBegin: ev.ts,
43
+ outcome: ev.outcome,
44
+ extensions: [],
45
+ acceptance: Array.isArray(ev.acceptance) ? ev.acceptance : [],
46
+ verify: ev.verify || null,
47
+ beginHead: ev.head || null,
48
+ decisions: Array.isArray(ev.decisions) ? ev.decisions : [],
49
+ logVersion: ev.logVersion || 1,
50
+ schemaVersion: ev.schemaVersion || 1,
51
+ lane: ev.lane || defaultLane,
52
+ decisionPrepares: [],
53
+ decisionTerminals: [],
54
+ decisionUpdates: [],
55
+ verificationAttempts: [],
56
+ verification: null,
57
+ status: 'in_progress',
58
+ tsEnd: null,
59
+ note: null,
60
+ verifyResult: null,
61
+ endHead: null,
62
+ reclaimed: false,
63
+ reclaimReason: null,
64
+ reclaimedAt: null,
65
+ imported: null,
66
+ contractHash: null,
67
+ };
68
+ record.contractHash = outcomeContractHash(record);
69
+ return record;
70
+ }
71
+
72
+ function qualifyingDecisionUpdates(record, decisionId) {
73
+ return record.decisionUpdates.filter((update) => {
74
+ if (update.decisionId !== decisionId) return false;
75
+ if (record.logVersion === 1 && record.schemaVersion < 2) return true;
76
+ return (
77
+ update.type === 'decision_reconcile_commit' &&
78
+ (update.logVersion === logVersion || (update.schemaVersion || 1) >= 2) &&
79
+ typeof update.fileHash === 'string'
80
+ );
81
+ });
82
+ }
83
+
84
+ function applyFoldEvent(state, ev) {
85
+ const { records, reconciliations, order, lanes } = state;
86
+ const ensureLane = (name) => {
87
+ if (lanes.has(name)) return;
88
+ lanes.set(name, {
89
+ name,
90
+ description: null,
91
+ addedAt: null,
92
+ head: null,
93
+ inferred: true,
94
+ });
95
+ };
96
+ if (ev.type === 'begin') {
97
+ if (records.has(ev.id)) fail(`duplicate begin event for outcome id: ${ev.id}`);
98
+ const record = newOutcomeRecord(ev);
99
+ ensureLane(record.lane);
100
+ records.set(ev.id, record);
101
+ order.push(ev.id);
102
+ return;
103
+ }
104
+ if (ev.type === 'import') {
105
+ if (records.has(ev.id)) fail(`duplicate imported outcome id: ${ev.id}`);
106
+ const record = newOutcomeRecord({
107
+ ...ev,
108
+ ts: ev.beganAt,
109
+ acceptance: [],
110
+ verify: null,
111
+ });
112
+ ensureLane(record.lane);
113
+ record.status = ev.status;
114
+ record.tsEnd = ev.endedAt;
115
+ record.note = ev.summary || null;
116
+ record.reclaimed = ev.reclaimed === true;
117
+ record.reclaimReason = ev.reclaimReason || null;
118
+ record.reclaimedAt = ev.reclaimedAt || null;
119
+ record.imported = {
120
+ sourceIds: ev.sources.map((source) => source.id),
121
+ sourceFingerprint: ev.sourceFingerprint,
122
+ sources: ev.sources,
123
+ };
124
+ records.set(ev.id, record);
125
+ order.push(ev.id);
126
+ return;
127
+ }
128
+ if (ev.type === 'migration') return;
129
+ if (ev.type === 'lane_add') {
130
+ const existing = lanes.get(ev.lane);
131
+ if (existing && !existing.inferred) {
132
+ if (ev.description) existing.description = ev.description;
133
+ return;
134
+ }
135
+ lanes.set(ev.lane, {
136
+ name: ev.lane,
137
+ description: ev.description || null,
138
+ addedAt: ev.ts,
139
+ head: existing ? existing.head : null,
140
+ inferred: false,
141
+ });
142
+ return;
143
+ }
144
+ if (ev.type === 'lane_assign') {
145
+ const rec = records.get(ev.id);
146
+ if (!rec) fail(`lane assign references unknown outcome id: ${ev.id}`);
147
+ if (rec.status === 'in_progress') fail(`cannot assign lane of in_progress outcome ${ev.id}`);
148
+ ensureLane(ev.lane);
149
+ rec.lane = ev.lane;
150
+ return;
151
+ }
152
+ if (ev.type === 'extend') {
153
+ const rec = records.get(ev.id);
154
+ if (!rec) fail(`extension references unknown outcome id: ${ev.id}`);
155
+ if (rec.status !== 'in_progress') fail(`extension occurred after outcome ${ev.id} was closed`);
156
+ rec.extensions.push({
157
+ extension: ev.extension,
158
+ acceptance: ev.acceptance,
159
+ verify: ev.verify,
160
+ decisions: ev.decisions,
161
+ extendedAt: ev.ts,
162
+ head: ev.head || null,
163
+ });
164
+ rec.acceptance = [...new Set([...rec.acceptance, ...ev.acceptance])];
165
+ if (ev.verify) rec.verify = ev.verify;
166
+ rec.decisions = [...new Set([...rec.decisions, ...ev.decisions])];
167
+ rec.contractHash = outcomeContractHash(rec);
168
+ rec.verification = null;
169
+ rec.decisionUpdates = [];
170
+ return;
171
+ }
172
+ if (ev.type === 'verify') {
173
+ const rec = records.get(ev.id);
174
+ if (!rec) fail(`verification event references unknown outcome id: ${ev.id}`);
175
+ if (rec.status !== 'in_progress') fail(`verification occurred after outcome ${ev.id} was closed`);
176
+ if (rec.acceptance.length === 0 || !rec.verify) {
177
+ fail(`verification event references outcome ${ev.id} without acceptance criteria`);
178
+ }
179
+ if (ev.command !== rec.verify) fail(`verification command does not match outcome ${ev.id}`);
180
+ if (rec.logVersion === logVersion && ev.contractHash !== rec.contractHash) {
181
+ fail(`verification contract does not match outcome ${ev.id}`);
182
+ }
183
+ rec.verificationAttempts.push(ev);
184
+ rec.verification = ev;
185
+ return;
186
+ }
187
+ if (ev.type === 'reclaim' || ev.type === 'unreclaim') {
188
+ const rec = records.get(ev.id);
189
+ if (!rec) fail(`${ev.type} event references unknown outcome id: ${ev.id}`);
190
+ if (ev.type === 'reclaim') {
191
+ if (rec.status === 'in_progress') fail(`cannot reclaim outcome ${ev.id} while it is in_progress`);
192
+ if (rec.reclaimed) fail(`duplicate reclaim event for outcome id: ${ev.id}`);
193
+ rec.reclaimed = true;
194
+ rec.reclaimReason = ev.reason;
195
+ rec.reclaimedAt = ev.ts;
196
+ } else {
197
+ if (!rec.reclaimed) fail(`unreclaim event for outcome id that is not reclaimed: ${ev.id}`);
198
+ rec.reclaimed = false;
199
+ rec.reclaimReason = null;
200
+ rec.reclaimedAt = null;
201
+ }
202
+ return;
203
+ }
204
+ if (ev.type === 'end') {
205
+ const rec = records.get(ev.id);
206
+ if (!rec) fail(`end event references unknown outcome id: ${ev.id}`);
207
+ if (rec.status !== 'in_progress') fail(`duplicate end event for outcome id: ${ev.id}`);
208
+ const conflictingCancellation = rec.decisionTerminals.find(
209
+ (terminal) =>
210
+ terminal.type === 'decision_reconcile_cancel' &&
211
+ terminal.outcomeStatus !== ev.status
212
+ );
213
+ if (conflictingCancellation) {
214
+ fail(
215
+ `outcome ${ev.id} was closed as ${ev.status} after reconciliation recovery was cancelled for ${conflictingCancellation.outcomeStatus}`
216
+ );
217
+ }
218
+ if (
219
+ ['completed', 'partial'].includes(ev.status) &&
220
+ rec.decisions.length > 0 &&
221
+ ((rec.logVersion === 1 &&
222
+ rec.schemaVersion >= 2 &&
223
+ (ev.schemaVersion || 1) < 2) ||
224
+ rec.decisions.some(
225
+ (decisionId) => qualifyingDecisionUpdates(rec, decisionId).length === 0
226
+ ))
227
+ ) {
228
+ fail(
229
+ `linked outcome ${ev.id} was closed without reconciling every declared decision`
230
+ );
231
+ }
232
+ if (ev.status === 'completed' && rec.acceptance.length > 0) {
233
+ if (!rec.verification || !rec.verification.passed) {
234
+ fail(
235
+ `acceptance-bound outcome ${ev.id} was completed without successful machine verification`
236
+ );
237
+ }
238
+ if (
239
+ (rec.logVersion === 1 && (ev.schemaVersion || 1) < 4) ||
240
+ ev.verificationId !== rec.verification.verificationId ||
241
+ (ev.workspace ?? null) !== rec.verification.workspace ||
242
+ (rec.logVersion === logVersion &&
243
+ (ev.contractHash !== rec.contractHash ||
244
+ rec.verification.contractHash !== rec.contractHash))
245
+ ) {
246
+ fail(
247
+ `acceptance-bound outcome ${ev.id} was completed with stale machine verification`
248
+ );
249
+ }
250
+ }
251
+ rec.status = ev.status;
252
+ rec.tsEnd = ev.ts;
253
+ rec.note = ev.note || null;
254
+ rec.verifyResult = ev.verifyResult || null;
255
+ rec.endHead = ev.head || null;
256
+ return;
257
+ }
258
+ if (ev.type === 'decision_reconcile_prepare') {
259
+ const rec = records.get(ev.id);
260
+ if (!rec) fail(`decision reconciliation references unknown outcome id: ${ev.id}`);
261
+ if (rec.status !== 'in_progress') {
262
+ fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
263
+ }
264
+ if (!rec.decisions.includes(ev.decisionId)) {
265
+ fail(`decision reconciliation references unlinked decision ${ev.decisionId}`);
266
+ }
267
+ if (reconciliations.has(ev.reconciliationId)) {
268
+ fail(`duplicate reconciliation id: ${ev.reconciliationId}`);
269
+ }
270
+ rec.decisionPrepares.push(ev);
271
+ reconciliations.set(ev.reconciliationId, {
272
+ prepare: ev,
273
+ terminal: null,
274
+ contractHash: rec.contractHash,
275
+ });
276
+ return;
277
+ }
278
+ if (ev.type === 'decision_reconcile') {
279
+ const rec = records.get(ev.id);
280
+ if (!rec) fail(`decision reconciliation references unknown outcome id: ${ev.id}`);
281
+ if (rec.status !== 'in_progress') {
282
+ fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
283
+ }
284
+ if (rec.logVersion === 1 && rec.schemaVersion >= 2) {
285
+ fail(
286
+ `linked legacy schema-v2 outcome ${rec.id} contains a legacy decision reconciliation`
287
+ );
288
+ }
289
+ rec.decisionUpdates.push(ev);
290
+ return;
291
+ }
292
+ if (
293
+ ev.type === 'decision_reconcile_commit' ||
294
+ ev.type === 'decision_reconcile_abort' ||
295
+ ev.type === 'decision_reconcile_cancel'
296
+ ) {
297
+ const rec = records.get(ev.id);
298
+ const reconciliation = reconciliations.get(ev.reconciliationId);
299
+ if (rec && rec.status !== 'in_progress') {
300
+ fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
301
+ }
302
+ if (
303
+ !rec ||
304
+ !reconciliation ||
305
+ reconciliation.prepare.id !== ev.id ||
306
+ reconciliation.prepare.decisionId !== ev.decisionId
307
+ ) {
308
+ fail(
309
+ `decision reconciliation terminal has no matching prepare: ${ev.reconciliationId}`
310
+ );
311
+ }
312
+ if (reconciliation.terminal) {
313
+ fail(`decision reconciliation already has a terminal event: ${ev.reconciliationId}`);
314
+ }
315
+ const priorCancellation = rec.decisionTerminals.find(
316
+ (terminal) => terminal.type === 'decision_reconcile_cancel'
317
+ );
318
+ if (
319
+ ev.type === 'decision_reconcile_cancel' &&
320
+ priorCancellation &&
321
+ priorCancellation.outcomeStatus !== ev.outcomeStatus
322
+ ) {
323
+ fail(`outcome ${ev.id} has conflicting reconciliation cancellation statuses`);
324
+ }
325
+ if (
326
+ ev.type === 'decision_reconcile_commit' &&
327
+ (reconciliation.prepare.newHash !== ev.fileHash ||
328
+ reconciliation.prepare.fromStatus !== ev.fromStatus ||
329
+ reconciliation.prepare.toStatus !== ev.toStatus)
330
+ ) {
331
+ fail(
332
+ `decision reconciliation commit does not match prepare: ${ev.reconciliationId}`
333
+ );
334
+ }
335
+ reconciliation.terminal = ev;
336
+ rec.decisionTerminals.push(ev);
337
+ if (
338
+ ev.type === 'decision_reconcile_commit' &&
339
+ reconciliation.contractHash === rec.contractHash
340
+ ) {
341
+ rec.decisionUpdates.push(ev);
342
+ }
343
+ }
344
+ }
345
+
346
+ function foldState(events) {
347
+ const state = {
348
+ records: new Map(),
349
+ reconciliations: new Map(),
350
+ order: [],
351
+ lanes: emptyLaneCatalog(),
352
+ };
353
+ for (const event of events) applyFoldEvent(state, event);
354
+ return state;
355
+ }
356
+
357
+ function fold(events) {
358
+ const state = foldState(events);
359
+ const folded = state.order.map((id) => state.records.get(id));
360
+ folded.lanes = state.lanes;
361
+ return folded;
362
+ }
363
+
364
+ return Object.freeze({
365
+ applyFoldEvent,
366
+ emptyLaneCatalog,
367
+ fold,
368
+ foldState,
369
+ newOutcomeRecord,
370
+ outcomeContractHash,
371
+ qualifyingDecisionUpdates,
372
+ });
373
+ }
374
+
375
+ module.exports = {
376
+ createOutcomeFold,
377
+ };