@sublang/playbook 9.0.0 → 10.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.
Files changed (64) hide show
  1. package/docs/cli.md +51 -8
  2. package/docs/embedding.md +38 -12
  3. package/package.json +7 -3
  4. package/reference/sdlc/captain.md +14 -10
  5. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  6. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  7. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  8. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  9. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  10. package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
  11. package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
  12. package/reference/sdlc/code.md +0 -1
  13. package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
  14. package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
  15. package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
  16. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  17. package/reference/sdlc/code.playbook/bin/run.js +365 -63
  18. package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
  19. package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
  20. package/reference/sdlc/code.playbook/code.fsm.js +74 -25
  21. package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
  22. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  23. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  24. package/reference/sdlc/code.playbook/code.playbook.js +54 -2
  25. package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
  26. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  27. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  28. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  29. package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
  30. package/reference/sdlc/code.playbook/playbook-captain.js +1850 -72
  31. package/reference/sdlc/code.playbook/playbook-captain.ts +2759 -96
  32. package/reference/sdlc/decide.md +0 -1
  33. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  35. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  36. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  37. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
  38. package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
  39. package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
  40. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  41. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  42. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  44. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  45. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  47. package/reference/sdlc/review.playbook/review.playbook.js +65 -2
  48. package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  50. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  51. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  52. package/slc/gears2fsm.md +6 -5
  53. package/slc/link.md +544 -41
  54. package/src/accepted-outcome.d.ts +18 -0
  55. package/src/accepted-outcome.js +94 -0
  56. package/src/accepted-outcome.ts +140 -0
  57. package/src/runtime.d.ts +164 -3
  58. package/src/runtime.ts +213 -2
  59. package/src/xstate-playbook-runtime.d.ts +149 -10
  60. package/src/xstate-playbook-runtime.js +2569 -270
  61. package/src/xstate-playbook-runtime.ts +4133 -490
  62. package/src/xstate-runtime.d.ts +59 -1
  63. package/src/xstate-runtime.js +866 -7
  64. package/src/xstate-runtime.ts +1397 -7
@@ -3,6 +3,8 @@
3
3
 
4
4
  // PBCLI-23: durable headless Captain sessions use one exact settled/uncertain
5
5
  // record union and one exclusive, crash-recoverable lease per logical session.
6
+ // PBCLI-53: a fresh lease may move its newest same-directory predecessor's
7
+ // complete retained-generation map under guarded source-first publication.
6
8
 
7
9
  import { randomUUID } from 'node:crypto';
8
10
  import { constants } from 'node:fs';
@@ -23,11 +25,30 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
23
25
  import { isDeepStrictEqual } from 'node:util';
24
26
  import { assertSupportedEffort } from '@sublang/cligent';
25
27
  import { KNOWN_PLAYER_ADAPTERS } from '@sublang/cligent/tmux-play';
26
- import { snapshotJsonValue } from '../../../../src/xstate-runtime.js';
27
- import { assertPlaybookCaptainShellSnapshot } from '../playbook-captain.js';
28
+ import {
29
+ assertPlaybookEffectLedger,
30
+ assertPlaybookRuntimeSnapshot,
31
+ emptyPlaybookEffectLedger,
32
+ isPlaybookEffectLedgerMonotonicExtension,
33
+ snapshotJsonValue,
34
+ } from '../../../../src/xstate-runtime.js';
35
+ import {
36
+ assertPlaybookCaptainShellSnapshot,
37
+ assertPlaybookCaptainUnresolvedEffects,
38
+ } from '../playbook-captain.js';
28
39
 
29
- export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 3;
40
+ export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 6;
30
41
  export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
42
+ const LEGACY_CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 3;
43
+ // The interrupted retention change emitted this required-member shape before
44
+ // canonical writes returned to additive schema 3. Both pre-effect shapes and
45
+ // the pre-unresolved-effects schema-5 shape are projected in memory only far
46
+ // enough to validate malformed data before the effect-authority cutover
47
+ // classifies them as nonresumable.
48
+ const COMPATIBLE_RETENTION_RECORD_SCHEMA_VERSION = 4;
49
+ const PRE_UNRESOLVED_EFFECTS_RECORD_SCHEMA_VERSION = 5;
50
+ const CURRENT_ARTIFACT_SCHEMAS = new Set([3]);
51
+ const PRE_EFFECT_ARTIFACT_SCHEMAS = new Set([2, 3]);
31
52
  export const SESSION_ID_PATTERN =
32
53
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
33
54
  export const CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION = 1;
@@ -36,6 +57,7 @@ export const CAPTAIN_SESSION_EXECUTION_PROJECTION_SCHEMA_VERSION = 2;
36
57
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
37
58
  const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
38
59
  const RESERVED_ID = 'captain';
60
+ const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
39
61
  const KNOWN_ADAPTERS = new Set(KNOWN_PLAYER_ADAPTERS);
40
62
 
41
63
  const COMMON_RECORD_KEYS = [
@@ -49,7 +71,12 @@ const COMMON_RECORD_KEYS = [
49
71
  'structuralProjection',
50
72
  'lastAppliedExecutionProjection',
51
73
  'snapshot',
74
+ 'effectLedger',
75
+ 'unresolvedEffects',
52
76
  ];
77
+ const LEGACY_COMMON_RECORD_KEYS = COMMON_RECORD_KEYS.filter(
78
+ (key) => key !== 'effectLedger' && key !== 'unresolvedEffects',
79
+ );
53
80
  const UNCERTAIN_KEYS = [
54
81
  'baseUpdatedAt',
55
82
  'input',
@@ -58,6 +85,11 @@ const UNCERTAIN_KEYS = [
58
85
  'markedAt',
59
86
  'attemptedExecutionProjection',
60
87
  ];
88
+ const UNCERTAIN_ABANDONMENT_KEYS = [
89
+ 'phase',
90
+ 'rootPlaybookId',
91
+ 'unresolvedEffects',
92
+ ];
61
93
  const RELEASED_SCHEMA_2_COMMON_RECORD_KEYS = [
62
94
  'schemaVersion',
63
95
  'kind',
@@ -109,6 +141,26 @@ class CaptainSessionRecordSchemaError extends Error {
109
141
  }
110
142
  }
111
143
 
144
+ class CaptainSessionRecordNonresumableError extends
145
+ CaptainSessionRecordSchemaError {
146
+ constructor(schemaVersion, message, orderingBoundary, cause) {
147
+ super(schemaVersion, message, cause);
148
+ this.name = 'CaptainSessionRecordNonresumableError';
149
+ this.orderingBoundary = orderingBoundary;
150
+ }
151
+ }
152
+
153
+ class CaptainSessionLeaseActiveError extends Error {
154
+ constructor(sessionId, pid) {
155
+ super(
156
+ `cannot acquire Captain session ${JSON.stringify(sessionId)} lease: Captain session lease is active in process ${pid}`,
157
+ );
158
+ this.name = 'CaptainSessionLeaseActiveError';
159
+ this.sessionId = sessionId;
160
+ this.pid = pid;
161
+ }
162
+ }
163
+
112
164
  export function defaultCaptainSessionsDir(
113
165
  env = process.env,
114
166
  home = env.HOME ?? homedir(),
@@ -194,12 +246,25 @@ export function createCaptainSessionStore(options = {}) {
194
246
  `Captain session ${JSON.stringify(sessionId)} at ` +
195
247
  `${JSON.stringify(path)}`;
196
248
  if (cause instanceof CaptainSessionRecordSchemaError) {
197
- if (cause.schemaVersion === 2 && value.sessionId !== sessionId) {
249
+ const nonresumable =
250
+ cause instanceof CaptainSessionRecordNonresumableError;
251
+ if (
252
+ nonresumable &&
253
+ value.sessionId !== sessionId
254
+ ) {
198
255
  throw new Error(
199
256
  `Captain session file ${JSON.stringify(path)} contains record ` +
200
257
  JSON.stringify(value.sessionId),
201
258
  );
202
259
  }
260
+ if (nonresumable) {
261
+ throw new CaptainSessionRecordNonresumableError(
262
+ cause.schemaVersion,
263
+ `${context}: ${cause.message}`,
264
+ cause.orderingBoundary,
265
+ cause,
266
+ );
267
+ }
203
268
  throw new CaptainSessionRecordSchemaError(
204
269
  cause.schemaVersion,
205
270
  `${context}: ${cause.message}`,
@@ -221,7 +286,12 @@ export function createCaptainSessionStore(options = {}) {
221
286
 
222
287
  const read = (sessionId) => readRecord(sessionId);
223
288
 
224
- const latest = async ({ onLegacyRecord } = {}) => {
289
+ const listRecords = async ({
290
+ onLegacyRecord,
291
+ onLegacyOrderingBoundary,
292
+ onInvalidRecord,
293
+ skipInvalidRecords = false,
294
+ } = {}) => {
225
295
  if (
226
296
  onLegacyRecord !== undefined &&
227
297
  typeof onLegacyRecord !== 'function'
@@ -230,13 +300,34 @@ export function createCaptainSessionStore(options = {}) {
230
300
  'Captain session legacy-record observer must be a function',
231
301
  );
232
302
  }
303
+ if (
304
+ onLegacyOrderingBoundary !== undefined &&
305
+ typeof onLegacyOrderingBoundary !== 'function'
306
+ ) {
307
+ throw new Error(
308
+ 'Captain session legacy ordering-boundary observer must be a function',
309
+ );
310
+ }
311
+ if (
312
+ onInvalidRecord !== undefined &&
313
+ typeof onInvalidRecord !== 'function'
314
+ ) {
315
+ throw new Error(
316
+ 'Captain session invalid-record observer must be a function',
317
+ );
318
+ }
319
+ if (typeof skipInvalidRecords !== 'boolean') {
320
+ throw new Error(
321
+ 'Captain session invalid-record skip option must be a boolean',
322
+ );
323
+ }
233
324
  let names;
234
325
  try {
235
326
  await assertPrivateDirectory(sessionsDir, fs);
236
327
  names = await fs.readdir(sessionsDir);
237
328
  } catch (cause) {
238
329
  if (cause?.code === 'ENOENT') {
239
- throw new Error('no resumable Captain session exists');
330
+ return [];
240
331
  }
241
332
  throw new Error(`cannot list Captain sessions: ${errorMessage(cause)}`);
242
333
  }
@@ -250,15 +341,23 @@ export function createCaptainSessionStore(options = {}) {
250
341
  try {
251
342
  candidates.push(await readRecord(sessionId));
252
343
  } catch (error) {
253
- if (
254
- error instanceof CaptainSessionRecordSchemaError &&
255
- error.schemaVersion === 2
256
- ) {
344
+ if (error instanceof CaptainSessionRecordNonresumableError) {
345
+ await onLegacyOrderingBoundary?.(error.orderingBoundary);
257
346
  await onLegacyRecord?.(
258
347
  Object.freeze({
259
348
  sessionId,
260
349
  path: recordPathFor(sessionId),
261
- schemaVersion: 2,
350
+ schemaVersion: error.schemaVersion,
351
+ }),
352
+ );
353
+ continue;
354
+ }
355
+ if (skipInvalidRecords) {
356
+ await onInvalidRecord?.(
357
+ Object.freeze({
358
+ sessionId,
359
+ path: recordPathFor(sessionId),
360
+ reason: errorMessage(error),
262
361
  }),
263
362
  );
264
363
  continue;
@@ -266,19 +365,80 @@ export function createCaptainSessionStore(options = {}) {
266
365
  throw error;
267
366
  }
268
367
  }
269
- candidates.sort((left, right) => {
270
- const byUpdated = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
271
- if (byUpdated !== 0) return byUpdated;
272
- if (right.sessionId === left.sessionId) return 0;
273
- return right.sessionId < left.sessionId ? -1 : 1;
274
- });
368
+ return candidates;
369
+ };
370
+
371
+ const latest = async ({ onLegacyRecord, preferredCwd } = {}) => {
372
+ const candidates = sortCaptainSessionRecords(
373
+ await listRecords({ onLegacyRecord }),
374
+ );
275
375
  if (candidates.length === 0) {
276
376
  throw new Error('no resumable Captain session exists');
277
377
  }
278
- return candidates[0];
378
+ return (
379
+ candidates.find((candidate) => candidate.cwd === preferredCwd) ??
380
+ candidates[0]
381
+ );
382
+ };
383
+
384
+ const scanAdoptionPredecessor = async (
385
+ target,
386
+ { onLegacyRecord, onInvalidRecord } = {},
387
+ ) => {
388
+ let orderingUnproved = false;
389
+ const legacyOrderingBoundaries = [];
390
+ const observeInvalid = async (record) => {
391
+ orderingUnproved = true;
392
+ await onInvalidRecord?.(record);
393
+ };
394
+ const records = await listRecords({
395
+ onLegacyRecord,
396
+ onLegacyOrderingBoundary: (record) => {
397
+ legacyOrderingBoundaries.push(record);
398
+ },
399
+ onInvalidRecord: observeInvalid,
400
+ skipInvalidRecords: true,
401
+ });
402
+ const resumableCandidates = records.filter(
403
+ (candidate) =>
404
+ candidate.sessionId !== target.sessionId &&
405
+ candidate.state === 'settled' &&
406
+ candidate.cwd === target.cwd,
407
+ );
408
+ const resumableById = new Map(
409
+ resumableCandidates.map((candidate) => [candidate.sessionId, candidate]),
410
+ );
411
+ const orderedBoundaries = sortCaptainSessionRecords([
412
+ ...resumableCandidates,
413
+ ...legacyOrderingBoundaries.filter(
414
+ (candidate) =>
415
+ candidate.sessionId !== target.sessionId &&
416
+ candidate.state === 'settled' &&
417
+ candidate.cwd === target.cwd,
418
+ ),
419
+ ]);
420
+ const newestBoundary = orderedBoundaries[0];
421
+ const candidate =
422
+ orderingUnproved || newestBoundary === undefined
423
+ ? undefined
424
+ : resumableById.get(newestBoundary.sessionId);
425
+ const adoptionDeclined =
426
+ orderingUnproved ||
427
+ (newestBoundary !== undefined && candidate === undefined);
428
+ // A fully validated legacy record has a trustworthy place in the
429
+ // same-cwd predecessor order even though it cannot resume. Only a record
430
+ // whose contents cannot be validated leaves that order globally unproved.
431
+ return {
432
+ candidate,
433
+ newestBoundaryUpdatedAt: newestBoundary?.updatedAt,
434
+ adoptionDeclined,
435
+ };
279
436
  };
280
437
 
281
- const writeRecord = async (recordValue, { noReplace }) => {
438
+ const writeRecord = async (
439
+ recordValue,
440
+ { noReplace, onPublished },
441
+ ) => {
282
442
  const record = validateCaptainSessionRecord(recordValue);
283
443
  const destination = recordPathFor(record.sessionId);
284
444
  await ensurePrivateDirectory(sessionsDir, fs);
@@ -319,6 +479,7 @@ export function createCaptainSessionStore(options = {}) {
319
479
  try {
320
480
  await fs.link(temporary, destination);
321
481
  published = true;
482
+ onPublished?.();
322
483
  } catch (cause) {
323
484
  if (cause?.code === 'EEXIST') {
324
485
  throw new Error(
@@ -339,6 +500,7 @@ export function createCaptainSessionStore(options = {}) {
339
500
  await fs.rename(temporary, destination);
340
501
  ownsTemporary = false;
341
502
  published = true;
503
+ onPublished?.();
342
504
  }
343
505
  await syncDirectory(sessionsDir, fs);
344
506
  return record;
@@ -619,18 +781,13 @@ export function createCaptainSessionStore(options = {}) {
619
781
  }
620
782
  try {
621
783
  await probeProcess(existing.pid);
622
- throw new Error(
623
- `Captain session lease is active in process ${existing.pid}`,
784
+ throw new CaptainSessionLeaseActiveError(
785
+ sessionId,
786
+ existing.pid,
624
787
  );
625
788
  } catch (cause) {
626
789
  if (cause?.code !== 'ESRCH') {
627
- if (
628
- cause instanceof Error &&
629
- cause.message ===
630
- `Captain session lease is active in process ${existing.pid}`
631
- ) {
632
- throw cause;
633
- }
790
+ if (cause instanceof CaptainSessionLeaseActiveError) throw cause;
634
791
  throw new Error(
635
792
  `Captain session lease owner process cannot be ruled dead: ${errorMessage(cause)}`,
636
793
  );
@@ -651,7 +808,10 @@ export function createCaptainSessionStore(options = {}) {
651
808
  owner: stage.owner,
652
809
  readRecord,
653
810
  writeRecord,
811
+ syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
654
812
  deleteRecord,
813
+ scanAdoptionPredecessor,
814
+ acquireSession: acquire,
655
815
  readLeaseOwner,
656
816
  retireObservedLease,
657
817
  validateRetiredLeases,
@@ -677,6 +837,7 @@ export function createCaptainSessionStore(options = {}) {
677
837
  `cannot acquire Captain session ${JSON.stringify(sessionId)} lease without leaving ownership uncertain`,
678
838
  );
679
839
  }
840
+ if (cause instanceof CaptainSessionLeaseActiveError) throw cause;
680
841
  throw new Error(
681
842
  `cannot acquire Captain session ${JSON.stringify(sessionId)} lease: ${errorMessage(cause)}`,
682
843
  );
@@ -691,7 +852,10 @@ function createLease({
691
852
  owner,
692
853
  readRecord,
693
854
  writeRecord,
855
+ syncRecordDirectory,
694
856
  deleteRecord,
857
+ scanAdoptionPredecessor,
858
+ acquireSession,
695
859
  readLeaseOwner,
696
860
  retireObservedLease,
697
861
  validateRetiredLeases,
@@ -699,6 +863,7 @@ function createLease({
699
863
  }) {
700
864
  let released = false;
701
865
  let operationActive = false;
866
+ let indeterminateEffectLedgerWrite;
702
867
 
703
868
  const requireActive = () => {
704
869
  if (released) throw new Error('Captain session lease was already released');
@@ -736,47 +901,333 @@ function createLease({
736
901
  return record;
737
902
  });
738
903
 
739
- const initializeSettled = ({
904
+ const freshSettledRecord = ({
740
905
  cwd,
741
906
  structuralProjection,
742
907
  executionProjection,
743
908
  snapshot,
744
- } = {}) =>
745
- runExclusive(async () => {
746
- const initial = validateFreshBoundary({
747
- cwd,
748
- structuralProjection,
749
- snapshot,
909
+ } = {}) => {
910
+ const initial = validateFreshBoundary({
911
+ cwd,
912
+ structuralProjection,
913
+ snapshot,
914
+ });
915
+ const applied = assertCaptainSessionExecutionCompatible(
916
+ initial.structuralProjection,
917
+ executionProjection,
918
+ );
919
+ const createdAt = timestampFrom(now(), 'session timestamp');
920
+ const updatedAt = nextTimestamp(now(), createdAt);
921
+ return validateCaptainSessionRecord({
922
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
923
+ kind: CAPTAIN_SESSION_RECORD_KIND,
924
+ state: 'settled',
925
+ sessionId,
926
+ createdAt,
927
+ updatedAt,
928
+ cwd: initial.cwd,
929
+ structuralProjection: initial.structuralProjection,
930
+ lastAppliedExecutionProjection: applied,
931
+ snapshot: initial.snapshot,
932
+ effectLedger: emptyPlaybookEffectLedger(),
933
+ unresolvedEffects: [],
934
+ retainedGenerations: {},
935
+ });
936
+ };
937
+
938
+ const laterTimestamp = (left, right) =>
939
+ right !== undefined && Date.parse(right) > Date.parse(left) ? right : left;
940
+
941
+ const emptyFreshTargetAfter = (target, predecessorUpdatedAt) => {
942
+ if (predecessorUpdatedAt === undefined) return target;
943
+ return settledRecordWithRetainedGenerations(
944
+ target,
945
+ {},
946
+ nextTimestamp(
947
+ now(),
948
+ laterTimestamp(target.updatedAt, predecessorUpdatedAt),
949
+ ),
950
+ );
951
+ };
952
+
953
+ const publishEmptyFreshTarget = async (target) => {
954
+ try {
955
+ await assertOwnerUnchecked();
956
+ await writeRecord(target, {
957
+ noReplace: true,
750
958
  });
751
- const applied = assertCaptainSessionExecutionCompatible(
752
- initial.structuralProjection,
753
- executionProjection,
959
+ } catch (cause) {
960
+ throw new Error(
961
+ `cannot publish empty Captain session adoption target: ${errorMessage(cause)}`,
962
+ { cause },
754
963
  );
755
- const createdAt = timestampFrom(now(), 'session timestamp');
756
- const updatedAt = nextTimestamp(now(), createdAt);
757
- const record = validateCaptainSessionRecord({
758
- schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
759
- kind: CAPTAIN_SESSION_RECORD_KIND,
760
- state: 'settled',
761
- sessionId,
762
- createdAt,
763
- updatedAt,
764
- cwd: initial.cwd,
765
- structuralProjection: initial.structuralProjection,
766
- lastAppliedExecutionProjection: applied,
767
- snapshot: initial.snapshot,
768
- });
964
+ }
965
+ };
966
+
967
+ const useAcquiredSession = async (sourceSessionId, operation) => {
968
+ const sourceLease = await acquireSession(sourceSessionId);
969
+ let result;
970
+ let operationFailed = false;
971
+ let operationError;
972
+ try {
973
+ result = await operation(sourceLease);
974
+ } catch (cause) {
975
+ operationFailed = true;
976
+ operationError = cause;
977
+ }
978
+ try {
979
+ await sourceLease.release();
980
+ } catch (releaseError) {
981
+ if (operationFailed) {
982
+ throw new AggregateError(
983
+ [operationError, releaseError],
984
+ 'Captain session adoption predecessor operation failed and its lease could not be released',
985
+ );
986
+ }
987
+ throw releaseError;
988
+ }
989
+ if (operationFailed) throw operationError;
990
+ return result;
991
+ };
992
+
993
+ const useAvailableSession = async (sourceSessionId, operation) => {
994
+ try {
995
+ return await useAcquiredSession(sourceSessionId, operation);
996
+ } catch (error) {
997
+ if (error instanceof CaptainSessionLeaseActiveError) return undefined;
998
+ throw error;
999
+ }
1000
+ };
1001
+
1002
+ const initializeSettledWithPredecessor = (options = {}) =>
1003
+ runExclusive(async () => {
1004
+ const target = freshSettledRecord(options);
1005
+ await assertOwnerUnchecked();
1006
+ if (
1007
+ (await readRecord(sessionId, { missing: 'undefined' })) !== undefined
1008
+ ) {
1009
+ throw new Error(
1010
+ `Captain session ${JSON.stringify(sessionId)} already exists`,
1011
+ );
1012
+ }
1013
+ const reportedSkippedRecords = new Set();
1014
+ const reportOnce = (observer, label) => {
1015
+ if (observer === undefined) return undefined;
1016
+ if (typeof observer !== 'function') {
1017
+ throw new Error(`Captain session ${label} observer must be a function`);
1018
+ }
1019
+ return async (record) => {
1020
+ const key = `${record.sessionId}\u0000${record.path}`;
1021
+ if (reportedSkippedRecords.has(key)) return;
1022
+ reportedSkippedRecords.add(key);
1023
+ await observer(record);
1024
+ };
1025
+ };
1026
+ const predecessorScanOptions = {
1027
+ onLegacyRecord: reportOnce(
1028
+ options.onLegacyRecord,
1029
+ 'legacy-record',
1030
+ ),
1031
+ onInvalidRecord: reportOnce(
1032
+ options.onInvalidRecord,
1033
+ 'invalid-record',
1034
+ ),
1035
+ };
1036
+ const initialScan = await scanAdoptionPredecessor(
1037
+ target,
1038
+ predecessorScanOptions,
1039
+ );
1040
+ if (initialScan.adoptionDeclined) {
1041
+ const emptyTarget = emptyFreshTargetAfter(
1042
+ target,
1043
+ initialScan.newestBoundaryUpdatedAt,
1044
+ );
1045
+ await publishEmptyFreshTarget(emptyTarget);
1046
+ await assertOwnerUnchecked();
1047
+ return emptyTarget;
1048
+ }
1049
+ const selected = initialScan.candidate;
1050
+ if (selected === undefined) {
1051
+ await publishEmptyFreshTarget(target);
1052
+ await assertOwnerUnchecked();
1053
+ return target;
1054
+ }
1055
+
1056
+ let result = await useAvailableSession(
1057
+ selected.sessionId,
1058
+ async (sourceLease) => {
1059
+ const source = await sourceLease.read();
1060
+ if (source === undefined) {
1061
+ const currentScan = await scanAdoptionPredecessor(
1062
+ target,
1063
+ predecessorScanOptions,
1064
+ );
1065
+ return {
1066
+ kind: 'declined',
1067
+ predecessorUpdatedAt: laterTimestamp(
1068
+ selected.updatedAt,
1069
+ currentScan.newestBoundaryUpdatedAt,
1070
+ ),
1071
+ };
1072
+ }
1073
+ await assertOwnerUnchecked();
1074
+ if (
1075
+ (await readRecord(sessionId, { missing: 'undefined' })) !==
1076
+ undefined
1077
+ ) {
1078
+ throw new Error('Captain session adoption target changed');
1079
+ }
1080
+ const currentScan = await scanAdoptionPredecessor(
1081
+ target,
1082
+ predecessorScanOptions,
1083
+ );
1084
+ if (currentScan.candidate?.sessionId !== source.sessionId) {
1085
+ return {
1086
+ kind: 'declined',
1087
+ predecessorUpdatedAt: laterTimestamp(
1088
+ source.updatedAt,
1089
+ currentScan.newestBoundaryUpdatedAt,
1090
+ ),
1091
+ };
1092
+ }
1093
+
1094
+ const retainedGenerations = source.retainedGenerations ?? {};
1095
+ try {
1096
+ assertAdoptionTransferEnvelope(
1097
+ source.structuralProjection,
1098
+ target.structuralProjection,
1099
+ retainedGenerations,
1100
+ source.effectLedger,
1101
+ );
1102
+ } catch {
1103
+ return {
1104
+ kind: 'declined',
1105
+ predecessorUpdatedAt: source.updatedAt,
1106
+ };
1107
+ }
1108
+ if (Object.keys(retainedGenerations).length === 0) {
1109
+ return {
1110
+ kind: 'empty',
1111
+ predecessorUpdatedAt: source.updatedAt,
1112
+ };
1113
+ }
1114
+
1115
+ const sourceNext = settledRecordWithRetainedGenerations(
1116
+ source,
1117
+ {},
1118
+ nextTimestamp(now(), source.updatedAt),
1119
+ );
1120
+ const targetTimestampFloor =
1121
+ Date.parse(target.updatedAt) > Date.parse(sourceNext.updatedAt)
1122
+ ? target.updatedAt
1123
+ : sourceNext.updatedAt;
1124
+ const targetNext = settledRecordWithRetainedGenerations(
1125
+ target,
1126
+ retainedGenerations,
1127
+ nextTimestamp(now(), targetTimestampFloor),
1128
+ source.effectLedger,
1129
+ );
1130
+
1131
+ await sourceLease.assertOwner();
1132
+ await assertOwnerUnchecked();
1133
+ let sourcePublished = false;
1134
+ try {
1135
+ await writeRecord(sourceNext, {
1136
+ noReplace: false,
1137
+ onPublished: () => {
1138
+ sourcePublished = true;
1139
+ },
1140
+ });
1141
+ } catch (cause) {
1142
+ throw new Error(
1143
+ `cannot clear Captain session adoption predecessor: ${errorMessage(cause)}`,
1144
+ { cause },
1145
+ );
1146
+ }
1147
+ if (!sourcePublished) {
1148
+ throw new Error(
1149
+ 'Captain session adoption predecessor clear did not publish',
1150
+ );
1151
+ }
1152
+ await sourceLease.assertOwner();
1153
+ await assertOwnerUnchecked();
1154
+
1155
+ let targetPublished = false;
1156
+ try {
1157
+ await writeRecord(targetNext, {
1158
+ noReplace: true,
1159
+ onPublished: () => {
1160
+ targetPublished = true;
1161
+ },
1162
+ });
1163
+ } catch (cause) {
1164
+ if (!targetPublished) {
1165
+ try {
1166
+ await sourceLease.assertOwner();
1167
+ await assertOwnerUnchecked();
1168
+ await writeRecord(source, { noReplace: false });
1169
+ await sourceLease.assertOwner();
1170
+ await assertOwnerUnchecked();
1171
+ } catch (rollbackError) {
1172
+ throw new AggregateError(
1173
+ [cause, rollbackError],
1174
+ 'Captain session adoption transfer failed and its predecessor could not be restored',
1175
+ );
1176
+ }
1177
+ }
1178
+ throw new Error(
1179
+ `cannot install Captain session adoption generations: ${errorMessage(cause)}`,
1180
+ { cause },
1181
+ );
1182
+ }
1183
+ await sourceLease.assertOwner();
1184
+ await assertOwnerUnchecked();
1185
+ return { kind: 'transferred', target: targetNext };
1186
+ },
1187
+ );
1188
+ if (result === undefined) {
1189
+ result = {
1190
+ kind: 'declined',
1191
+ predecessorUpdatedAt: selected.updatedAt,
1192
+ };
1193
+ }
1194
+
1195
+ if (result.kind === 'transferred') return result.target;
1196
+ await assertOwnerUnchecked();
1197
+ if (
1198
+ (await readRecord(sessionId, { missing: 'undefined' })) !== undefined
1199
+ ) {
1200
+ throw new Error('Captain session adoption target changed');
1201
+ }
1202
+ const emptyTarget = emptyFreshTargetAfter(
1203
+ target,
1204
+ result.predecessorUpdatedAt,
1205
+ );
1206
+ await publishEmptyFreshTarget(emptyTarget);
1207
+ await assertOwnerUnchecked();
1208
+ return emptyTarget;
1209
+ });
1210
+
1211
+ const abandonFreshSettled = ({ expected: value } = {}) =>
1212
+ runExclusive(async () => {
1213
+ const expected = validateCaptainSessionRecord(value);
1214
+ if (expected.sessionId !== sessionId) {
1215
+ throw new Error('fresh Captain session abandonment id is mismatched');
1216
+ }
1217
+ assertFreshAdoptionTarget(expected);
769
1218
  await assertOwnerUnchecked();
770
- await writeRecord(record, { noReplace: true });
1219
+ const current = await readRecord(sessionId, { missing: 'undefined' });
1220
+ if (!isDeepStrictEqual(current, expected)) return false;
771
1221
  await assertOwnerUnchecked();
772
- return record;
1222
+ await deleteRecord(sessionId);
1223
+ await assertOwnerUnchecked();
1224
+ return true;
773
1225
  });
774
1226
 
775
1227
  const beginTurn = ({
776
1228
  input,
777
1229
  attemptId,
778
1230
  attemptedExecutionProjection,
779
- fresh,
780
1231
  } = {}) =>
781
1232
  runExclusive(async () => {
782
1233
  assertAcceptedInput(input);
@@ -787,67 +1238,40 @@ function createLease({
787
1238
  );
788
1239
  await assertOwnerUnchecked();
789
1240
  const prior = await readRecord(sessionId, { missing: 'undefined' });
790
- let record;
791
- if (fresh !== undefined) {
792
- if (prior !== undefined) {
793
- throw new Error('fresh Captain session record already exists');
794
- }
795
- const initial = validateFreshBoundary(fresh);
796
- const timestamp = timestampFrom(now(), 'session timestamp');
797
- record = validateCaptainSessionRecord({
798
- schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
799
- kind: CAPTAIN_SESSION_RECORD_KIND,
800
- state: 'uncertain',
801
- sessionId,
802
- createdAt: timestamp,
803
- updatedAt: timestamp,
804
- cwd: initial.cwd,
805
- structuralProjection: initial.structuralProjection,
806
- lastAppliedExecutionProjection: attempted,
807
- snapshot: initial.snapshot,
808
- uncertain: {
809
- baseUpdatedAt: null,
810
- input,
811
- attemptId,
812
- attemptNumber: 1,
813
- markedAt: timestamp,
814
- attemptedExecutionProjection: attempted,
815
- },
816
- });
817
- await assertOwnerUnchecked();
818
- await writeRecord(record, { noReplace: true });
819
- } else {
820
- if (prior === undefined) {
821
- throw new Error('Captain session does not exist for continuation');
822
- }
823
- if (prior.state !== 'settled') {
824
- throw new Error('Captain session already has an uncertain turn');
825
- }
826
- const timestamp = nextTimestamp(now(), prior.updatedAt);
827
- record = validateCaptainSessionRecord({
828
- schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
829
- kind: CAPTAIN_SESSION_RECORD_KIND,
830
- state: 'uncertain',
831
- sessionId,
832
- createdAt: prior.createdAt,
833
- updatedAt: timestamp,
834
- cwd: prior.cwd,
835
- structuralProjection: prior.structuralProjection,
836
- lastAppliedExecutionProjection:
837
- prior.lastAppliedExecutionProjection,
838
- snapshot: prior.snapshot,
839
- uncertain: {
840
- baseUpdatedAt: prior.updatedAt,
841
- input,
842
- attemptId,
843
- attemptNumber: 1,
844
- markedAt: timestamp,
845
- attemptedExecutionProjection: attempted,
846
- },
847
- });
848
- await assertOwnerUnchecked();
849
- await writeRecord(record, { noReplace: false });
1241
+ if (prior === undefined) {
1242
+ throw new Error('Captain session does not exist for continuation');
1243
+ }
1244
+ if (prior.state !== 'settled') {
1245
+ throw new Error('Captain session already has an uncertain turn');
850
1246
  }
1247
+ const timestamp = nextTimestamp(now(), prior.updatedAt);
1248
+ const record = validateCaptainSessionRecord({
1249
+ schemaVersion: prior.schemaVersion,
1250
+ kind: CAPTAIN_SESSION_RECORD_KIND,
1251
+ state: 'uncertain',
1252
+ sessionId,
1253
+ createdAt: prior.createdAt,
1254
+ updatedAt: timestamp,
1255
+ cwd: prior.cwd,
1256
+ structuralProjection: prior.structuralProjection,
1257
+ lastAppliedExecutionProjection:
1258
+ prior.lastAppliedExecutionProjection,
1259
+ snapshot: prior.snapshot,
1260
+ effectLedger: prior.effectLedger,
1261
+ unresolvedEffects: prior.unresolvedEffects,
1262
+ ...retainedGenerationsMember(prior),
1263
+ ...settledAbandonmentMember(prior),
1264
+ uncertain: {
1265
+ baseUpdatedAt: prior.updatedAt,
1266
+ input,
1267
+ attemptId,
1268
+ attemptNumber: 1,
1269
+ markedAt: timestamp,
1270
+ attemptedExecutionProjection: attempted,
1271
+ },
1272
+ });
1273
+ await assertOwnerUnchecked();
1274
+ await writeRecord(record, { noReplace: false });
851
1275
  await assertOwnerUnchecked();
852
1276
  return record;
853
1277
  });
@@ -864,12 +1288,17 @@ function createLease({
864
1288
  await readRecord(sessionId, { missing: 'undefined' }),
865
1289
  expectedAttemptId,
866
1290
  );
1291
+ if (Object.hasOwn(prior.uncertain, 'abandonment')) {
1292
+ throw new Error(
1293
+ 'Captain session unresolved-effect abandonment must recover before retry',
1294
+ );
1295
+ }
867
1296
  if (!Number.isSafeInteger(prior.uncertain.attemptNumber + 1)) {
868
1297
  throw new Error('Captain session attempt number cannot be incremented');
869
1298
  }
870
1299
  const timestamp = nextTimestamp(now(), prior.updatedAt);
871
1300
  const record = validateCaptainSessionRecord({
872
- schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
1301
+ schemaVersion: prior.schemaVersion,
873
1302
  kind: CAPTAIN_SESSION_RECORD_KIND,
874
1303
  state: 'uncertain',
875
1304
  sessionId,
@@ -880,6 +1309,10 @@ function createLease({
880
1309
  lastAppliedExecutionProjection:
881
1310
  prior.lastAppliedExecutionProjection,
882
1311
  snapshot: prior.snapshot,
1312
+ effectLedger: prior.effectLedger,
1313
+ unresolvedEffects: prior.unresolvedEffects,
1314
+ ...retainedGenerationsMember(prior),
1315
+ ...settledAbandonmentMember(prior),
883
1316
  uncertain: {
884
1317
  baseUpdatedAt: prior.uncertain.baseUpdatedAt,
885
1318
  input: prior.uncertain.input,
@@ -896,27 +1329,117 @@ function createLease({
896
1329
  return record;
897
1330
  });
898
1331
 
899
- const settle = ({ attemptId, snapshot } = {}) =>
1332
+ const requireAbandonmentSettlement = (
1333
+ abandonment,
1334
+ unresolvedEffects,
1335
+ retentionUpdates,
1336
+ snapshot,
1337
+ retainedGenerations,
1338
+ ) => {
1339
+ if (
1340
+ !isDeepStrictEqual(
1341
+ abandonment.unresolvedEffects,
1342
+ unresolvedEffects,
1343
+ )
1344
+ ) {
1345
+ throw new Error(
1346
+ 'Captain session abandonment settlement differs from its durable unresolved effects',
1347
+ );
1348
+ }
1349
+ if (
1350
+ !retentionUpdates.some(
1351
+ (update) =>
1352
+ update.kind === 'clear' &&
1353
+ update.rootPlaybookId === abandonment.rootPlaybookId,
1354
+ ) ||
1355
+ retentionUpdates.some(
1356
+ (update) =>
1357
+ update.kind !== 'clear' ||
1358
+ (update.rootPlaybookId !== abandonment.rootPlaybookId &&
1359
+ Object.hasOwn(retainedGenerations, update.rootPlaybookId)),
1360
+ )
1361
+ ) {
1362
+ throw new Error(
1363
+ 'Captain session abandonment settlement must clear its durable root without changing another retained generation',
1364
+ );
1365
+ }
1366
+ if (
1367
+ snapshot.mode !== 'chat' ||
1368
+ snapshot.lastAction !== 'runtime' ||
1369
+ snapshot.lastSettlementStatus !== 'ok'
1370
+ ) {
1371
+ throw new Error(
1372
+ 'Captain session abandonment settlement requires a successful host-disposal snapshot',
1373
+ );
1374
+ }
1375
+ };
1376
+
1377
+ const unresolvedEffectAbandonmentInput = (value) => {
1378
+ const input = requireRecord(
1379
+ snapshotJsonValue(value, 'Captain unresolved-effect abandonment'),
1380
+ 'Captain unresolved-effect abandonment',
1381
+ );
1382
+ rejectUnknownOrMissingKeys(
1383
+ input,
1384
+ ['rootPlaybookId', 'unresolvedEffects'],
1385
+ 'Captain unresolved-effect abandonment',
1386
+ );
1387
+ const rootPlaybookId = requireCanonicalNonblank(
1388
+ input.rootPlaybookId,
1389
+ 'Captain unresolved-effect abandonment.rootPlaybookId',
1390
+ );
1391
+ const unresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
1392
+ input.unresolvedEffects,
1393
+ );
1394
+ if (unresolvedEffects.length === 0) {
1395
+ throw new Error(
1396
+ 'Captain unresolved-effect abandonment requires nonempty unresolved effects',
1397
+ );
1398
+ }
1399
+ return { rootPlaybookId, unresolvedEffects };
1400
+ };
1401
+
1402
+ const beginUnresolvedEffectAbandonment = (value) =>
900
1403
  runExclusive(async () => {
901
- assertUuid(attemptId, 'Captain session attempt id');
1404
+ const input = unresolvedEffectAbandonmentInput(value);
902
1405
  await assertOwnerUnchecked();
903
1406
  const prior = await requireUncertainRecord(
904
1407
  await readRecord(sessionId, { missing: 'undefined' }),
905
- attemptId,
906
1408
  );
907
- const timestamp = nextTimestamp(now(), prior.updatedAt);
1409
+ if (Object.hasOwn(prior.uncertain, 'abandonment')) {
1410
+ const existing = prior.uncertain.abandonment;
1411
+ if (
1412
+ existing.phase === 'started' &&
1413
+ existing.rootPlaybookId === input.rootPlaybookId &&
1414
+ isDeepStrictEqual(existing.unresolvedEffects, input.unresolvedEffects)
1415
+ ) {
1416
+ await assertOwnerUnchecked();
1417
+ await syncRecordDirectory();
1418
+ await assertOwnerUnchecked();
1419
+ return prior;
1420
+ }
1421
+ throw new Error(
1422
+ 'Captain session unresolved-effect abandonment is already in progress',
1423
+ );
1424
+ }
1425
+ if (
1426
+ prior.snapshot.mode !== 'engaged.parked' ||
1427
+ prior.snapshot.frames[0]?.playbookId !== input.rootPlaybookId
1428
+ ) {
1429
+ throw new Error(
1430
+ 'Captain session unresolved-effect abandonment root does not match its durable engagement',
1431
+ );
1432
+ }
908
1433
  const record = validateCaptainSessionRecord({
909
- schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
910
- kind: CAPTAIN_SESSION_RECORD_KIND,
911
- state: 'settled',
912
- sessionId,
913
- createdAt: prior.createdAt,
914
- updatedAt: timestamp,
915
- cwd: prior.cwd,
916
- structuralProjection: prior.structuralProjection,
917
- lastAppliedExecutionProjection:
918
- prior.uncertain.attemptedExecutionProjection,
919
- snapshot,
1434
+ ...prior,
1435
+ uncertain: {
1436
+ ...prior.uncertain,
1437
+ abandonment: {
1438
+ phase: 'started',
1439
+ rootPlaybookId: input.rootPlaybookId,
1440
+ unresolvedEffects: input.unresolvedEffects,
1441
+ },
1442
+ },
920
1443
  });
921
1444
  await assertOwnerUnchecked();
922
1445
  await writeRecord(record, { noReplace: false });
@@ -924,24 +1447,384 @@ function createLease({
924
1447
  return record;
925
1448
  });
926
1449
 
927
- const discard = ({ attemptId } = {}) =>
1450
+ const completeUnresolvedEffectAbandonment = (value) =>
928
1451
  runExclusive(async () => {
929
- assertUuid(attemptId, 'Captain session attempt id');
1452
+ const input = unresolvedEffectAbandonmentInput(value);
930
1453
  await assertOwnerUnchecked();
931
1454
  const prior = await requireUncertainRecord(
932
1455
  await readRecord(sessionId, { missing: 'undefined' }),
933
- attemptId,
934
1456
  );
935
- await assertOwnerUnchecked();
936
- if (prior.uncertain.baseUpdatedAt === null) {
937
- await deleteRecord(sessionId);
1457
+ const abandonment = prior.uncertain.abandonment;
1458
+ if (
1459
+ abandonment === undefined ||
1460
+ abandonment.rootPlaybookId !== input.rootPlaybookId ||
1461
+ !isDeepStrictEqual(
1462
+ abandonment.unresolvedEffects,
1463
+ input.unresolvedEffects,
1464
+ )
1465
+ ) {
1466
+ throw new Error(
1467
+ 'Captain session unresolved-effect abandonment completion differs from its durable start',
1468
+ );
1469
+ }
1470
+ if (abandonment.phase === 'disposed') {
1471
+ await assertOwnerUnchecked();
1472
+ await syncRecordDirectory();
1473
+ await assertOwnerUnchecked();
1474
+ return prior;
1475
+ }
1476
+ const record = validateCaptainSessionRecord({
1477
+ ...prior,
1478
+ unresolvedEffects: input.unresolvedEffects,
1479
+ retainedGenerations: applyRetainedGenerationUpdates(
1480
+ prior.retainedGenerations ?? {},
1481
+ [{ kind: 'clear', rootPlaybookId: input.rootPlaybookId }],
1482
+ prior.structuralProjection,
1483
+ ),
1484
+ uncertain: {
1485
+ ...prior.uncertain,
1486
+ abandonment: {
1487
+ phase: 'disposed',
1488
+ rootPlaybookId: input.rootPlaybookId,
1489
+ unresolvedEffects: input.unresolvedEffects,
1490
+ },
1491
+ },
1492
+ });
1493
+ await assertOwnerUnchecked();
1494
+ await writeRecord(record, { noReplace: false });
1495
+ await assertOwnerUnchecked();
1496
+ return record;
1497
+ });
1498
+
1499
+ const recoverUnresolvedEffectAbandonment = () =>
1500
+ runExclusive(async () => {
1501
+ await assertOwnerUnchecked();
1502
+ const prior = await readRecord(sessionId, { missing: 'undefined' });
1503
+ if (
1504
+ prior === undefined ||
1505
+ prior.state !== 'uncertain' ||
1506
+ !Object.hasOwn(prior.uncertain, 'abandonment')
1507
+ ) {
1508
+ await assertOwnerUnchecked();
1509
+ if (
1510
+ prior?.state === 'settled' &&
1511
+ Object.hasOwn(prior, 'settledAbandonment')
1512
+ ) {
1513
+ await syncRecordDirectory();
1514
+ await assertOwnerUnchecked();
1515
+ }
1516
+ return prior;
1517
+ }
1518
+ const abandonment = prior.uncertain.abandonment;
1519
+ const {
1520
+ frames: _frames,
1521
+ retainedEffectReconciliation: _retainedEffectReconciliation,
1522
+ pendingBossQuestions: _pendingBossQuestions,
1523
+ lastError: _lastError,
1524
+ ...snapshotCommon
1525
+ } = prior.snapshot;
1526
+ const snapshot = assertPlaybookCaptainShellSnapshot({
1527
+ ...snapshotCommon,
1528
+ effectLedger: prior.effectLedger,
1529
+ mode: 'chat',
1530
+ lastAction: 'runtime',
1531
+ lastSettlementStatus: 'ok',
1532
+ });
1533
+ const retainedGenerations = applyRetainedGenerationUpdates(
1534
+ prior.retainedGenerations ?? {},
1535
+ [{ kind: 'clear', rootPlaybookId: abandonment.rootPlaybookId }],
1536
+ prior.structuralProjection,
1537
+ );
1538
+ const timestamp = nextTimestamp(now(), prior.updatedAt);
1539
+ const record = validateCaptainSessionRecord({
1540
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
1541
+ kind: CAPTAIN_SESSION_RECORD_KIND,
1542
+ state: 'settled',
1543
+ sessionId,
1544
+ createdAt: prior.createdAt,
1545
+ updatedAt: timestamp,
1546
+ cwd: prior.cwd,
1547
+ structuralProjection: prior.structuralProjection,
1548
+ lastAppliedExecutionProjection:
1549
+ prior.uncertain.attemptedExecutionProjection,
1550
+ snapshot,
1551
+ effectLedger: prior.effectLedger,
1552
+ unresolvedEffects: abandonment.unresolvedEffects,
1553
+ retainedGenerations,
1554
+ settledAbandonment: {
1555
+ phase: 'recovered',
1556
+ attemptId: prior.uncertain.attemptId,
1557
+ rootPlaybookId: abandonment.rootPlaybookId,
1558
+ unresolvedEffects: abandonment.unresolvedEffects,
1559
+ },
1560
+ });
1561
+ await assertOwnerUnchecked();
1562
+ await writeRecord(record, { noReplace: false });
1563
+ await assertOwnerUnchecked();
1564
+ return record;
1565
+ });
1566
+
1567
+ const settle = ({
1568
+ attemptId,
1569
+ snapshot,
1570
+ unresolvedEffects,
1571
+ retentionUpdates = [],
1572
+ } = {}) =>
1573
+ runExclusive(async () => {
1574
+ assertUuid(attemptId, 'Captain session attempt id');
1575
+ const updates = validateRetainedGenerationUpdates(retentionUpdates);
1576
+ const settledUnresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
1577
+ unresolvedEffects,
1578
+ );
1579
+ await assertOwnerUnchecked();
1580
+ const current = await readRecord(sessionId, { missing: 'undefined' });
1581
+ const settledAbandonment =
1582
+ current?.state === 'settled' &&
1583
+ Object.hasOwn(current, 'settledAbandonment')
1584
+ ? current.settledAbandonment
1585
+ : undefined;
1586
+ const prior =
1587
+ settledAbandonment === undefined
1588
+ ? await requireUncertainRecord(current, attemptId)
1589
+ : undefined;
1590
+ if (
1591
+ settledAbandonment !== undefined &&
1592
+ settledAbandonment.attemptId !== attemptId
1593
+ ) {
1594
+ throw new Error(
1595
+ 'Captain session abandonment settlement attempt differs from its durable marker',
1596
+ );
1597
+ }
1598
+ const settledSnapshot = assertPlaybookCaptainShellSnapshot(snapshot);
1599
+ if (settledAbandonment !== undefined) {
1600
+ requireAbandonmentSettlement(
1601
+ settledAbandonment,
1602
+ settledUnresolvedEffects,
1603
+ updates,
1604
+ settledSnapshot,
1605
+ current.retainedGenerations ?? {},
1606
+ );
1607
+ if (
1608
+ !isDeepStrictEqual(
1609
+ settledSnapshot.effectLedger,
1610
+ current.effectLedger,
1611
+ )
1612
+ ) {
1613
+ throw new Error(
1614
+ 'Captain session settlement snapshot effect ledger differs from its durable ledger',
1615
+ );
1616
+ }
1617
+ const retainedGenerations = applyRetainedGenerationUpdates(
1618
+ current.retainedGenerations ?? {},
1619
+ updates,
1620
+ current.structuralProjection,
1621
+ );
1622
+ const settlementIsExact =
1623
+ isDeepStrictEqual(current.snapshot, settledSnapshot) &&
1624
+ isDeepStrictEqual(
1625
+ current.unresolvedEffects,
1626
+ settledUnresolvedEffects,
1627
+ ) &&
1628
+ isDeepStrictEqual(
1629
+ current.retainedGenerations ?? {},
1630
+ retainedGenerations,
1631
+ );
1632
+ if (
1633
+ settledAbandonment.phase === 'final' &&
1634
+ settlementIsExact
1635
+ ) {
1636
+ await assertOwnerUnchecked();
1637
+ await syncRecordDirectory();
1638
+ await assertOwnerUnchecked();
1639
+ return current;
1640
+ }
1641
+ if (settledAbandonment.phase === 'final') {
1642
+ throw new Error(
1643
+ 'Captain session abandonment settlement differs from its finalized durable record',
1644
+ );
1645
+ }
1646
+ const record = validateCaptainSessionRecord({
1647
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
1648
+ kind: CAPTAIN_SESSION_RECORD_KIND,
1649
+ state: 'settled',
1650
+ sessionId,
1651
+ createdAt: current.createdAt,
1652
+ updatedAt: nextTimestamp(now(), current.updatedAt),
1653
+ cwd: current.cwd,
1654
+ structuralProjection: current.structuralProjection,
1655
+ lastAppliedExecutionProjection:
1656
+ current.lastAppliedExecutionProjection,
1657
+ snapshot: settledSnapshot,
1658
+ effectLedger: current.effectLedger,
1659
+ unresolvedEffects: settledUnresolvedEffects,
1660
+ retainedGenerations,
1661
+ settledAbandonment: {
1662
+ ...settledAbandonment,
1663
+ phase: 'final',
1664
+ },
1665
+ });
1666
+ await assertOwnerUnchecked();
1667
+ await writeRecord(record, { noReplace: false });
1668
+ await assertOwnerUnchecked();
1669
+ return record;
1670
+ }
1671
+ if (Object.hasOwn(prior.uncertain, 'abandonment')) {
1672
+ const abandonment = prior.uncertain.abandonment;
1673
+ if (abandonment.phase !== 'disposed') {
1674
+ throw new Error(
1675
+ 'Captain session unresolved-effect abandonment has not completed disposal',
1676
+ );
1677
+ }
1678
+ requireAbandonmentSettlement(
1679
+ abandonment,
1680
+ settledUnresolvedEffects,
1681
+ updates,
1682
+ settledSnapshot,
1683
+ prior.retainedGenerations ?? {},
1684
+ );
1685
+ }
1686
+ if (!isDeepStrictEqual(settledSnapshot.effectLedger, prior.effectLedger)) {
1687
+ throw new Error(
1688
+ 'Captain session settlement snapshot effect ledger differs from its durable ledger',
1689
+ );
1690
+ }
1691
+ const timestamp = nextTimestamp(now(), prior.updatedAt);
1692
+ const record = validateCaptainSessionRecord({
1693
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
1694
+ kind: CAPTAIN_SESSION_RECORD_KIND,
1695
+ state: 'settled',
1696
+ sessionId,
1697
+ createdAt: prior.createdAt,
1698
+ updatedAt: timestamp,
1699
+ cwd: prior.cwd,
1700
+ structuralProjection: prior.structuralProjection,
1701
+ lastAppliedExecutionProjection:
1702
+ prior.uncertain.attemptedExecutionProjection,
1703
+ snapshot: settledSnapshot,
1704
+ effectLedger: prior.effectLedger,
1705
+ unresolvedEffects: settledUnresolvedEffects,
1706
+ retainedGenerations: applyRetainedGenerationUpdates(
1707
+ prior.retainedGenerations ?? {},
1708
+ updates,
1709
+ prior.structuralProjection,
1710
+ ),
1711
+ ...(Object.hasOwn(prior.uncertain, 'abandonment')
1712
+ ? {
1713
+ settledAbandonment: {
1714
+ phase: 'final',
1715
+ attemptId: prior.uncertain.attemptId,
1716
+ rootPlaybookId:
1717
+ prior.uncertain.abandonment.rootPlaybookId,
1718
+ unresolvedEffects:
1719
+ prior.uncertain.abandonment.unresolvedEffects,
1720
+ },
1721
+ }
1722
+ : {}),
1723
+ });
1724
+ await assertOwnerUnchecked();
1725
+ await writeRecord(record, { noReplace: false });
1726
+ await assertOwnerUnchecked();
1727
+ return record;
1728
+ });
1729
+
1730
+ const writeEffectLedger = (authorityValue, commandsValue) =>
1731
+ runExclusive(async () => {
1732
+ await assertOwnerUnchecked();
1733
+ const prior = await readRecord(sessionId, { missing: 'undefined' });
1734
+ if (prior === undefined) {
1735
+ throw new Error('Captain session does not exist for effect-ledger write-ahead');
1736
+ }
1737
+ const settledRecovery =
1738
+ prior.state === 'settled' &&
1739
+ prior.snapshot.mode === 'chat' &&
1740
+ (Object.keys(prior.retainedGenerations ?? {}).length > 0 ||
1741
+ prior.unresolvedEffects.length > 0);
1742
+ if (prior.state !== 'uncertain' && !settledRecovery) {
1743
+ throw new Error(
1744
+ 'Captain session effect-ledger write-ahead requires an uncertain turn or a settled chat recovery boundary',
1745
+ );
1746
+ }
1747
+ const authority = validateEffectLedgerAuthority(
1748
+ authorityValue,
1749
+ prior,
1750
+ owner,
1751
+ );
1752
+ const commands = snapshotJsonValue(
1753
+ commandsValue,
1754
+ 'effect-ledger write commands',
1755
+ );
1756
+ if (settledRecovery) assertSettledEffectRecoveryCommands(commands);
1757
+ if (
1758
+ indeterminateEffectLedgerWrite !== undefined &&
1759
+ isDeepStrictEqual(commands, indeterminateEffectLedgerWrite.commands) &&
1760
+ isDeepStrictEqual(
1761
+ prior.effectLedger,
1762
+ indeterminateEffectLedgerWrite.ledger,
1763
+ )
1764
+ ) {
1765
+ await assertOwnerUnchecked();
1766
+ await syncRecordDirectory();
1767
+ await assertOwnerUnchecked();
1768
+ indeterminateEffectLedgerWrite = undefined;
1769
+ return prior.effectLedger;
1770
+ }
1771
+ indeterminateEffectLedgerWrite = undefined;
1772
+ const nextLedger = applyEffectLedgerCommands(
1773
+ prior.effectLedger,
1774
+ authority,
1775
+ prior.uncertain,
1776
+ commands,
1777
+ );
1778
+ if (isDeepStrictEqual(nextLedger, prior.effectLedger)) {
1779
+ await assertOwnerUnchecked();
1780
+ return prior.effectLedger;
1781
+ }
1782
+ const record = validateCaptainSessionRecord({
1783
+ ...prior,
1784
+ effectLedger: nextLedger,
1785
+ ...(settledRecovery
1786
+ ? { snapshot: { ...prior.snapshot, effectLedger: nextLedger } }
1787
+ : {}),
1788
+ });
1789
+ indeterminateEffectLedgerWrite = {
1790
+ commands,
1791
+ ledger: record.effectLedger,
1792
+ };
1793
+ await assertOwnerUnchecked();
1794
+ await writeRecord(record, { noReplace: false });
1795
+ await assertOwnerUnchecked();
1796
+ indeterminateEffectLedgerWrite = undefined;
1797
+ return record.effectLedger;
1798
+ });
1799
+
1800
+ const discard = ({ attemptId } = {}) =>
1801
+ runExclusive(async () => {
1802
+ assertUuid(attemptId, 'Captain session attempt id');
1803
+ await assertOwnerUnchecked();
1804
+ const prior = await requireUncertainRecord(
1805
+ await readRecord(sessionId, { missing: 'undefined' }),
1806
+ attemptId,
1807
+ );
1808
+ if (Object.hasOwn(prior.uncertain, 'abandonment')) {
1809
+ throw new Error(
1810
+ 'Captain session unresolved-effect abandonment must recover before discard',
1811
+ );
1812
+ }
1813
+ if (!isDeepStrictEqual(prior.effectLedger, prior.snapshot.effectLedger)) {
1814
+ throw new Error(
1815
+ 'Captain session uncertain effect ledger differs from its pre-turn checkpoint and cannot be discarded',
1816
+ );
1817
+ }
1818
+ await assertOwnerUnchecked();
1819
+ if (prior.uncertain.baseUpdatedAt === null) {
1820
+ await deleteRecord(sessionId);
938
1821
  await assertOwnerUnchecked();
939
1822
  return undefined;
940
1823
  }
941
1824
  // writeRecord's stable key order reconstructs the exact prior settled
942
1825
  // bytes from the baseline carried by the uncertain record.
943
1826
  const record = validateCaptainSessionRecord({
944
- schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
1827
+ schemaVersion: prior.schemaVersion,
945
1828
  kind: CAPTAIN_SESSION_RECORD_KIND,
946
1829
  state: 'settled',
947
1830
  sessionId,
@@ -952,6 +1835,10 @@ function createLease({
952
1835
  lastAppliedExecutionProjection:
953
1836
  prior.lastAppliedExecutionProjection,
954
1837
  snapshot: prior.snapshot,
1838
+ effectLedger: prior.effectLedger,
1839
+ unresolvedEffects: prior.unresolvedEffects,
1840
+ ...retainedGenerationsMember(prior),
1841
+ ...settledAbandonmentMember(prior),
955
1842
  });
956
1843
  await writeRecord(record, { noReplace: false });
957
1844
  await assertOwnerUnchecked();
@@ -969,9 +1856,14 @@ function createLease({
969
1856
  sessionId,
970
1857
  ownerToken: owner.ownerToken,
971
1858
  read,
972
- initializeSettled,
1859
+ initializeSettledWithPredecessor,
1860
+ abandonFreshSettled,
973
1861
  beginTurn,
974
1862
  beginRetry,
1863
+ beginUnresolvedEffectAbandonment,
1864
+ completeUnresolvedEffectAbandonment,
1865
+ recoverUnresolvedEffectAbandonment,
1866
+ writeEffectLedger,
975
1867
  settle,
976
1868
  discard,
977
1869
  assertOwner,
@@ -982,23 +1874,66 @@ function createLease({
982
1874
  export function validateCaptainSessionExecutionProjection(
983
1875
  value,
984
1876
  path = 'Captain session execution projection',
1877
+ ) {
1878
+ return validateCaptainSessionExecutionProjectionWithSchemas(
1879
+ value,
1880
+ path,
1881
+ CURRENT_ARTIFACT_SCHEMAS,
1882
+ );
1883
+ }
1884
+
1885
+ function validateCaptainSessionExecutionProjectionWithSchemas(
1886
+ value,
1887
+ path,
1888
+ artifactSchemas,
985
1889
  ) {
986
1890
  const projection = requireRecord(snapshotJsonValue(value, path), path);
987
- validateCaptainSessionProjection(projection, { path, structural: false });
1891
+ validateCaptainSessionProjection(projection, {
1892
+ path,
1893
+ structural: false,
1894
+ artifactSchemas,
1895
+ });
988
1896
  return projection;
989
1897
  }
990
1898
 
991
1899
  export function validateCaptainSessionStructuralProjection(
992
1900
  value,
993
1901
  path = 'Captain session structural projection',
1902
+ ) {
1903
+ return validateCaptainSessionStructuralProjectionWithSchemas(
1904
+ value,
1905
+ path,
1906
+ CURRENT_ARTIFACT_SCHEMAS,
1907
+ );
1908
+ }
1909
+
1910
+ function validateCaptainSessionStructuralProjectionWithSchemas(
1911
+ value,
1912
+ path,
1913
+ artifactSchemas,
994
1914
  ) {
995
1915
  const projection = requireRecord(snapshotJsonValue(value, path), path);
996
- validateCaptainSessionProjection(projection, { path, structural: true });
1916
+ validateCaptainSessionProjection(projection, {
1917
+ path,
1918
+ structural: true,
1919
+ artifactSchemas,
1920
+ });
997
1921
  return projection;
998
1922
  }
999
1923
 
1000
1924
  export function projectCaptainSessionStructure(value) {
1001
- const execution = validateCaptainSessionExecutionProjection(value);
1925
+ return projectCaptainSessionStructureWithSchemas(
1926
+ value,
1927
+ CURRENT_ARTIFACT_SCHEMAS,
1928
+ );
1929
+ }
1930
+
1931
+ function projectCaptainSessionStructureWithSchemas(value, artifactSchemas) {
1932
+ const execution = validateCaptainSessionExecutionProjectionWithSchemas(
1933
+ value,
1934
+ 'Captain session execution projection',
1935
+ artifactSchemas,
1936
+ );
1002
1937
  const fixedAgent = (agent) => ({
1003
1938
  adapter: agent.adapter,
1004
1939
  ...(agent.instruction === undefined
@@ -1008,49 +1943,72 @@ export function projectCaptainSessionStructure(value) {
1008
1943
  ? {}
1009
1944
  : { permissions: agent.permissions }),
1010
1945
  });
1011
- return validateCaptainSessionStructuralProjection({
1012
- schemaVersion: CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION,
1013
- captain: fixedAgent(execution.captain),
1014
- players: execution.players.map(({ id, ...agent }) => ({
1015
- id,
1016
- ...fixedAgent(agent),
1017
- })),
1018
- catalog: Object.fromEntries(
1019
- Object.entries(execution.catalog).map(([id, item]) => [
1946
+ return validateCaptainSessionStructuralProjectionWithSchemas(
1947
+ {
1948
+ schemaVersion: CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION,
1949
+ captain: fixedAgent(execution.captain),
1950
+ players: execution.players.map(({ id, ...agent }) => ({
1020
1951
  id,
1021
- {
1022
- id: item.id,
1023
- from: item.from,
1024
- manifestCommand: item.manifestCommand,
1025
- command: item.command,
1026
- intent: item.intent,
1027
- artifactSchema: item.artifactSchema,
1028
- requiredRoleIds: item.requiredRoleIds,
1029
- concurrentRoleSets: item.concurrentRoleSets,
1030
- roles: Object.fromEntries(
1031
- Object.entries(item.roles).map(([roleId, binding]) => [
1032
- roleId,
1033
- { playerId: binding.playerId },
1034
- ]),
1035
- ),
1036
- options: item.options,
1037
- },
1038
- ]),
1039
- ),
1040
- });
1952
+ ...fixedAgent(agent),
1953
+ })),
1954
+ catalog: Object.fromEntries(
1955
+ Object.entries(execution.catalog).map(([id, item]) => [
1956
+ id,
1957
+ {
1958
+ id: item.id,
1959
+ from: item.from,
1960
+ manifestCommand: item.manifestCommand,
1961
+ command: item.command,
1962
+ intent: item.intent,
1963
+ artifactSchema: item.artifactSchema,
1964
+ requiredRoleIds: item.requiredRoleIds,
1965
+ concurrentRoleSets: item.concurrentRoleSets,
1966
+ roles: Object.fromEntries(
1967
+ Object.entries(item.roles).map(([roleId, binding]) => [
1968
+ roleId,
1969
+ { playerId: binding.playerId },
1970
+ ]),
1971
+ ),
1972
+ options: item.options,
1973
+ },
1974
+ ]),
1975
+ ),
1976
+ },
1977
+ 'Captain session structural projection',
1978
+ artifactSchemas,
1979
+ );
1041
1980
  }
1042
1981
 
1043
1982
  export function assertCaptainSessionExecutionCompatible(
1044
1983
  structuralProjection,
1045
1984
  executionProjection,
1046
1985
  ) {
1047
- const structural = validateCaptainSessionStructuralProjection(
1986
+ return assertCaptainSessionExecutionCompatibleWithSchemas(
1987
+ structuralProjection,
1988
+ executionProjection,
1989
+ CURRENT_ARTIFACT_SCHEMAS,
1990
+ );
1991
+ }
1992
+
1993
+ function assertCaptainSessionExecutionCompatibleWithSchemas(
1994
+ structuralProjection,
1995
+ executionProjection,
1996
+ artifactSchemas,
1997
+ ) {
1998
+ const structural = validateCaptainSessionStructuralProjectionWithSchemas(
1048
1999
  structuralProjection,
2000
+ 'Captain session structural projection',
2001
+ artifactSchemas,
1049
2002
  );
1050
- const execution = validateCaptainSessionExecutionProjection(
2003
+ const execution = validateCaptainSessionExecutionProjectionWithSchemas(
1051
2004
  executionProjection,
2005
+ 'Captain session execution projection',
2006
+ artifactSchemas,
2007
+ );
2008
+ const projected = projectCaptainSessionStructureWithSchemas(
2009
+ execution,
2010
+ artifactSchemas,
1052
2011
  );
1053
- const projected = projectCaptainSessionStructure(execution);
1054
2012
  if (!isDeepStrictEqual(projected, structural)) {
1055
2013
  throw new Error(
1056
2014
  'Captain session execution projection does not reproduce the stored structural projection',
@@ -1075,29 +2033,101 @@ export function validateCaptainSessionRecord(value) {
1075
2033
  snapshotJsonValue(value, 'Captain session record'),
1076
2034
  'Captain session record',
1077
2035
  );
1078
- if (record.schemaVersion !== CAPTAIN_SESSION_RECORD_SCHEMA_VERSION) {
1079
- if (record.schemaVersion === 2) {
1080
- assertReleasedSchema2CaptainSessionRecord(record);
1081
- throw new CaptainSessionRecordSchemaError(
1082
- record.schemaVersion,
1083
- 'Captain session record schema 2 has incompatible root-owned player identity; schema 3 is required',
2036
+ if (record.schemaVersion === 2) {
2037
+ assertReleasedSchema2CaptainSessionRecord(record);
2038
+ throw new CaptainSessionRecordNonresumableError(
2039
+ record.schemaVersion,
2040
+ 'Captain session record schema 2 has incompatible root-owned player identity; schema 6 is required',
2041
+ captainSessionOrderingBoundary(record),
2042
+ );
2043
+ }
2044
+ if (
2045
+ record.schemaVersion === LEGACY_CAPTAIN_SESSION_RECORD_SCHEMA_VERSION ||
2046
+ record.schemaVersion === COMPATIBLE_RETENTION_RECORD_SCHEMA_VERSION
2047
+ ) {
2048
+ const projectedRecord = validateCanonicalCaptainSessionRecord(
2049
+ projectPreEffectCaptainSessionRecordForValidation(record),
2050
+ PRE_EFFECT_ARTIFACT_SCHEMAS,
2051
+ );
2052
+ throw new CaptainSessionRecordNonresumableError(
2053
+ record.schemaVersion,
2054
+ `Captain session record schema ${record.schemaVersion} predates the artifact-schema-3 effect-authority cutover and is not resumable`,
2055
+ captainSessionOrderingBoundary(projectedRecord),
2056
+ );
2057
+ }
2058
+ if (
2059
+ record.schemaVersion === PRE_UNRESOLVED_EFFECTS_RECORD_SCHEMA_VERSION
2060
+ ) {
2061
+ const hasUnresolvedEffects = Object.hasOwn(record, 'unresolvedEffects');
2062
+ if (
2063
+ !hasUnresolvedEffects &&
2064
+ (Object.hasOwn(record, 'settledAbandonment') ||
2065
+ (typeof record.uncertain === 'object' &&
2066
+ record.uncertain !== null &&
2067
+ Object.hasOwn(record.uncertain, 'abandonment')))
2068
+ ) {
2069
+ throw new Error(
2070
+ 'Captain session record schema 5 pre-unresolved-effects shape has an unknown abandonment field',
1084
2071
  );
1085
2072
  }
2073
+ const projectedRecord = validateCanonicalCaptainSessionRecord(
2074
+ {
2075
+ ...record,
2076
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
2077
+ ...(hasUnresolvedEffects ? {} : { unresolvedEffects: [] }),
2078
+ },
2079
+ hasUnresolvedEffects
2080
+ ? CURRENT_ARTIFACT_SCHEMAS
2081
+ : PRE_EFFECT_ARTIFACT_SCHEMAS,
2082
+ );
2083
+ throw new CaptainSessionRecordNonresumableError(
2084
+ record.schemaVersion,
2085
+ 'Captain session record schema 5 predates the canonical schema-6 unresolved-effect settlement boundary for the artifact-schema-3 effect-authority cutover and is not resumable',
2086
+ captainSessionOrderingBoundary(projectedRecord),
2087
+ );
2088
+ }
2089
+ if (record.schemaVersion !== CAPTAIN_SESSION_RECORD_SCHEMA_VERSION) {
1086
2090
  throw new CaptainSessionRecordSchemaError(
1087
2091
  record.schemaVersion,
1088
2092
  `Captain session record schema ${JSON.stringify(record.schemaVersion)} is not supported`,
1089
2093
  );
1090
2094
  }
2095
+ return validateCanonicalCaptainSessionRecord(record);
2096
+ }
2097
+
2098
+ function captainSessionOrderingBoundary(record) {
2099
+ return Object.freeze({
2100
+ sessionId: record.sessionId,
2101
+ state: record.state,
2102
+ cwd: record.cwd,
2103
+ updatedAt: record.updatedAt,
2104
+ });
2105
+ }
2106
+
2107
+ function validateCanonicalCaptainSessionRecord(
2108
+ record,
2109
+ artifactSchemas = CURRENT_ARTIFACT_SCHEMAS,
2110
+ ) {
2111
+ record = requireRecord(
2112
+ snapshotJsonValue(record, 'Captain session record'),
2113
+ 'Captain session record',
2114
+ );
1091
2115
  if (record.state !== 'settled' && record.state !== 'uncertain') {
1092
2116
  throw new Error('Captain session record state is not supported');
1093
2117
  }
1094
- rejectUnknownOrMissingKeys(
2118
+ exactOptionalKeys(
1095
2119
  record,
1096
2120
  record.state === 'uncertain'
1097
2121
  ? [...COMMON_RECORD_KEYS, 'uncertain']
1098
2122
  : COMMON_RECORD_KEYS,
2123
+ ['retainedGenerations', 'settledAbandonment'],
1099
2124
  'Captain session record',
1100
2125
  );
2126
+ if (record.schemaVersion !== CAPTAIN_SESSION_RECORD_SCHEMA_VERSION) {
2127
+ throw new Error(
2128
+ `Captain session record schemaVersion must be ${CAPTAIN_SESSION_RECORD_SCHEMA_VERSION}`,
2129
+ );
2130
+ }
1101
2131
  if (record.kind !== CAPTAIN_SESSION_RECORD_KIND) {
1102
2132
  throw new Error('Captain session record kind is not supported');
1103
2133
  }
@@ -1118,17 +2148,52 @@ export function validateCaptainSessionRecord(value) {
1118
2148
  if (resolve(record.cwd) !== record.cwd) {
1119
2149
  throw new Error('Captain session record cwd must be normalized');
1120
2150
  }
1121
- const structural = validateCaptainSessionStructuralProjection(
2151
+ const structural = validateCaptainSessionStructuralProjectionWithSchemas(
1122
2152
  record.structuralProjection,
1123
2153
  'Captain session record structuralProjection',
2154
+ artifactSchemas,
1124
2155
  );
1125
- const lastApplied = assertCaptainSessionExecutionCompatible(
2156
+ const lastApplied = assertCaptainSessionExecutionCompatibleWithSchemas(
1126
2157
  structural,
1127
2158
  record.lastAppliedExecutionProjection,
2159
+ artifactSchemas,
1128
2160
  );
1129
2161
  void lastApplied;
1130
2162
  const snapshot = assertPlaybookCaptainShellSnapshot(record.snapshot);
1131
- assertSnapshotMatchesStructure(snapshot, structural);
2163
+ assertSnapshotMatchesStructure(snapshot, structural, artifactSchemas);
2164
+ const effectLedger = assertPlaybookEffectLedger(
2165
+ record.effectLedger,
2166
+ 'Captain session record effectLedger',
2167
+ );
2168
+ const unresolvedEffects = assertPlaybookCaptainUnresolvedEffects(
2169
+ record.unresolvedEffects,
2170
+ );
2171
+ void unresolvedEffects;
2172
+ assertEffectLedgerMatchesCatalog(effectLedger, structural.catalog);
2173
+ if (record.state === 'settled') {
2174
+ if (!isDeepStrictEqual(effectLedger, snapshot.effectLedger)) {
2175
+ throw new Error(
2176
+ 'settled Captain session effect ledger differs from its shell snapshot mirror',
2177
+ );
2178
+ }
2179
+ } else if (
2180
+ !isPlaybookEffectLedgerMonotonicExtension(
2181
+ snapshot.effectLedger,
2182
+ effectLedger,
2183
+ )
2184
+ ) {
2185
+ throw new Error(
2186
+ 'uncertain Captain session effect ledger is not a monotonic extension of its pre-turn shell snapshot mirror',
2187
+ );
2188
+ }
2189
+ if (Object.hasOwn(record, 'retainedGenerations')) {
2190
+ validateRetainedGenerations(
2191
+ record.retainedGenerations,
2192
+ structural,
2193
+ effectLedger,
2194
+ artifactSchemas,
2195
+ );
2196
+ }
1132
2197
  if (
1133
2198
  snapshot.captain.sessionId === record.sessionId ||
1134
2199
  snapshot.issuedSessionIds.includes(record.sessionId)
@@ -1143,9 +2208,10 @@ export function validateCaptainSessionRecord(value) {
1143
2208
  record.uncertain,
1144
2209
  'Captain session record uncertain',
1145
2210
  );
1146
- rejectUnknownOrMissingKeys(
2211
+ exactOptionalKeys(
1147
2212
  uncertain,
1148
2213
  UNCERTAIN_KEYS,
2214
+ ['abandonment'],
1149
2215
  'Captain session record uncertain',
1150
2216
  );
1151
2217
  if (uncertain.baseUpdatedAt !== null) {
@@ -1174,33 +2240,1077 @@ export function validateCaptainSessionRecord(value) {
1174
2240
  ) {
1175
2241
  throw new Error('Captain session attempt number must be a positive integer');
1176
2242
  }
1177
- const markedAt = canonicalTimestamp(
1178
- uncertain.markedAt,
1179
- 'uncertain.markedAt',
2243
+ const newBoundaries = effectLedger.boundaries.slice(
2244
+ snapshot.effectLedger.boundaries.length,
1180
2245
  );
1181
- if (markedAt !== updatedAt) {
2246
+ const attemptIds = new Map();
2247
+ const attemptNumbersById = new Map();
2248
+ for (const boundary of newBoundaries) {
2249
+ if (boundary.attemptNumber > uncertain.attemptNumber) {
2250
+ throw new Error(
2251
+ 'Captain session post-checkpoint effect boundary names a future uncertain attempt',
2252
+ );
2253
+ }
2254
+ const priorId = attemptIds.get(boundary.attemptNumber);
2255
+ if (priorId !== undefined && priorId !== boundary.attemptId) {
2256
+ throw new Error(
2257
+ 'Captain session post-checkpoint effect boundaries use inconsistent ids for one attempt number',
2258
+ );
2259
+ }
2260
+ const priorNumber = attemptNumbersById.get(boundary.attemptId);
2261
+ if (priorNumber !== undefined && priorNumber !== boundary.attemptNumber) {
2262
+ throw new Error(
2263
+ 'Captain session post-checkpoint effect attempts reuse one id across attempt numbers',
2264
+ );
2265
+ }
2266
+ attemptIds.set(boundary.attemptNumber, boundary.attemptId);
2267
+ attemptNumbersById.set(boundary.attemptId, boundary.attemptNumber);
2268
+ if (
2269
+ boundary.attemptNumber === uncertain.attemptNumber
2270
+ ? boundary.attemptId !== uncertain.attemptId
2271
+ : boundary.attemptId === uncertain.attemptId
2272
+ ) {
2273
+ throw new Error(
2274
+ 'Captain session post-checkpoint effect boundary attempt identity conflicts with the current uncertain marker',
2275
+ );
2276
+ }
2277
+ }
2278
+ const markedAt = canonicalTimestamp(
2279
+ uncertain.markedAt,
2280
+ 'uncertain.markedAt',
2281
+ );
2282
+ if (markedAt !== updatedAt) {
2283
+ throw new Error(
2284
+ 'Captain session uncertain markedAt must equal updatedAt',
2285
+ );
2286
+ }
2287
+ const attempted = assertCaptainSessionExecutionCompatibleWithSchemas(
2288
+ structural,
2289
+ uncertain.attemptedExecutionProjection,
2290
+ artifactSchemas,
2291
+ );
2292
+ if (uncertain.baseUpdatedAt === null) {
2293
+ if (!isDeepStrictEqual(lastApplied, attempted)) {
2294
+ throw new Error(
2295
+ 'fresh Captain session baseline and attempted execution projections must match',
2296
+ );
2297
+ }
2298
+ assertTurnZeroSnapshot(
2299
+ snapshot,
2300
+ 'fresh Captain session record snapshot',
2301
+ );
2302
+ }
2303
+ if (Object.hasOwn(uncertain, 'abandonment')) {
2304
+ const abandonment = requireRecord(
2305
+ uncertain.abandonment,
2306
+ 'Captain session record uncertain.abandonment',
2307
+ );
2308
+ rejectUnknownOrMissingKeys(
2309
+ abandonment,
2310
+ UNCERTAIN_ABANDONMENT_KEYS,
2311
+ 'Captain session record uncertain.abandonment',
2312
+ );
2313
+ if (abandonment.phase !== 'started' && abandonment.phase !== 'disposed') {
2314
+ throw new Error(
2315
+ 'Captain session record uncertain.abandonment.phase must be "started" or "disposed"',
2316
+ );
2317
+ }
2318
+ const rootPlaybookId = requireCanonicalNonblank(
2319
+ abandonment.rootPlaybookId,
2320
+ 'Captain session record uncertain.abandonment.rootPlaybookId',
2321
+ );
2322
+ if (!Object.hasOwn(structural.catalog, rootPlaybookId)) {
2323
+ throw new Error(
2324
+ `Captain session unresolved-effect abandonment names unknown stored playbook ${JSON.stringify(rootPlaybookId)}`,
2325
+ );
2326
+ }
2327
+ if (
2328
+ snapshot.mode !== 'engaged.parked' ||
2329
+ snapshot.frames[0]?.playbookId !== rootPlaybookId
2330
+ ) {
2331
+ throw new Error(
2332
+ 'Captain session unresolved-effect abandonment root differs from its durable engagement',
2333
+ );
2334
+ }
2335
+ const abandonmentEffects = assertPlaybookCaptainUnresolvedEffects(
2336
+ abandonment.unresolvedEffects,
2337
+ );
2338
+ if (abandonmentEffects.length === 0) {
2339
+ throw new Error(
2340
+ 'Captain session unresolved-effect abandonment requires nonempty unresolved effects',
2341
+ );
2342
+ }
2343
+ if (abandonment.phase === 'disposed') {
2344
+ if (!isDeepStrictEqual(unresolvedEffects, abandonmentEffects)) {
2345
+ throw new Error(
2346
+ 'Captain session disposed abandonment unresolved effects differ from the record settlement evidence',
2347
+ );
2348
+ }
2349
+ if (Object.hasOwn(record.retainedGenerations ?? {}, rootPlaybookId)) {
2350
+ throw new Error(
2351
+ 'Captain session disposed abandonment retained its cleared root generation',
2352
+ );
2353
+ }
2354
+ }
2355
+ }
2356
+ }
2357
+ if (Object.hasOwn(record, 'settledAbandonment')) {
2358
+ const settledAbandonment = requireRecord(
2359
+ record.settledAbandonment,
2360
+ 'Captain session record settledAbandonment',
2361
+ );
2362
+ rejectUnknownOrMissingKeys(
2363
+ settledAbandonment,
2364
+ ['phase', 'attemptId', 'rootPlaybookId', 'unresolvedEffects'],
2365
+ 'Captain session record settledAbandonment',
2366
+ );
2367
+ if (
2368
+ settledAbandonment.phase !== 'recovered' &&
2369
+ settledAbandonment.phase !== 'final'
2370
+ ) {
2371
+ throw new Error(
2372
+ 'Captain session record settledAbandonment.phase must be "recovered" or "final"',
2373
+ );
2374
+ }
2375
+ assertUuid(
2376
+ settledAbandonment.attemptId,
2377
+ 'Captain session record settledAbandonment.attemptId',
2378
+ );
2379
+ const rootPlaybookId = requireCanonicalNonblank(
2380
+ settledAbandonment.rootPlaybookId,
2381
+ 'Captain session record settledAbandonment.rootPlaybookId',
2382
+ );
2383
+ if (!Object.hasOwn(structural.catalog, rootPlaybookId)) {
2384
+ throw new Error(
2385
+ `Captain session recovered unresolved-effect abandonment names unknown stored playbook ${JSON.stringify(rootPlaybookId)}`,
2386
+ );
2387
+ }
2388
+ const settledEffects = assertPlaybookCaptainUnresolvedEffects(
2389
+ settledAbandonment.unresolvedEffects,
2390
+ );
2391
+ if (
2392
+ settledEffects.length === 0 ||
2393
+ !isDeepStrictEqual(unresolvedEffects, settledEffects)
2394
+ ) {
2395
+ throw new Error(
2396
+ 'Captain session settled unresolved-effect abandonment must preserve its nonempty settlement evidence',
2397
+ );
2398
+ }
2399
+ if (
2400
+ snapshot.mode !== 'chat' ||
2401
+ snapshot.lastAction !== 'runtime' ||
2402
+ snapshot.lastSettlementStatus !== 'ok' ||
2403
+ Object.hasOwn(record.retainedGenerations ?? {}, rootPlaybookId)
2404
+ ) {
2405
+ throw new Error(
2406
+ 'Captain session settled unresolved-effect abandonment must carry a successful host-disposal chat snapshot with its root generation cleared',
2407
+ );
2408
+ }
2409
+ }
2410
+
2411
+ return record;
2412
+ }
2413
+
2414
+ function projectPreEffectCaptainSessionRecordForValidation(record) {
2415
+ if (record.state !== 'settled' && record.state !== 'uncertain') {
2416
+ throw new Error('Captain session record state is not supported');
2417
+ }
2418
+ const recordKeys =
2419
+ record.schemaVersion === COMPATIBLE_RETENTION_RECORD_SCHEMA_VERSION
2420
+ ? [...LEGACY_COMMON_RECORD_KEYS, 'retainedGenerations']
2421
+ : LEGACY_COMMON_RECORD_KEYS;
2422
+ exactOptionalKeys(
2423
+ record,
2424
+ record.state === 'uncertain'
2425
+ ? [...recordKeys, 'uncertain']
2426
+ : recordKeys,
2427
+ record.schemaVersion === LEGACY_CAPTAIN_SESSION_RECORD_SCHEMA_VERSION
2428
+ ? ['retainedGenerations']
2429
+ : [],
2430
+ 'Captain session record',
2431
+ );
2432
+ validateCaptainSessionStructuralProjectionWithSchemas(
2433
+ record.structuralProjection,
2434
+ 'Captain session record structuralProjection',
2435
+ PRE_EFFECT_ARTIFACT_SCHEMAS,
2436
+ );
2437
+ const effectLedger = emptyPlaybookEffectLedger();
2438
+ const migrated = {
2439
+ ...record,
2440
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
2441
+ snapshot: migratePreEffectShellSnapshot(record.snapshot, effectLedger),
2442
+ effectLedger,
2443
+ unresolvedEffects: [],
2444
+ ...(Object.hasOwn(record, 'retainedGenerations')
2445
+ ? {
2446
+ retainedGenerations: migratePreEffectRetainedGenerations(
2447
+ record.retainedGenerations,
2448
+ ),
2449
+ }
2450
+ : {}),
2451
+ };
2452
+ return migrated;
2453
+ }
2454
+
2455
+ function migratePreEffectShellSnapshot(value, effectLedger) {
2456
+ const snapshot = requireRecord(value, 'legacy Captain shell snapshot');
2457
+ if (snapshot.schemaVersion !== 3) {
2458
+ throw new CaptainSessionRecordSchemaError(
2459
+ snapshot.schemaVersion,
2460
+ `legacy Captain shell snapshot schema ${JSON.stringify(snapshot.schemaVersion)} cannot migrate to schema 4`,
2461
+ );
2462
+ }
2463
+ if (Object.hasOwn(snapshot, 'effectLedger')) {
2464
+ throw new CaptainSessionRecordSchemaError(
2465
+ snapshot.schemaVersion,
2466
+ 'legacy Captain shell snapshot must not contain an effect ledger',
2467
+ );
2468
+ }
2469
+ const captain = requireRecord(
2470
+ snapshot.captain,
2471
+ 'legacy Captain shell snapshot.captain',
2472
+ );
2473
+ const frames =
2474
+ snapshot.frames === undefined
2475
+ ? undefined
2476
+ : Array.isArray(snapshot.frames)
2477
+ ? snapshot.frames.map((frame, index) => {
2478
+ const captured = requireRecord(
2479
+ frame,
2480
+ `legacy Captain shell snapshot.frames[${index}]`,
2481
+ );
2482
+ return {
2483
+ ...captured,
2484
+ runtime: migratePreEffectRuntimeSnapshot(
2485
+ captured.runtime,
2486
+ `legacy Captain shell snapshot.frames[${index}].runtime`,
2487
+ ),
2488
+ };
2489
+ })
2490
+ : snapshot.frames;
2491
+ return {
2492
+ ...snapshot,
2493
+ schemaVersion: 4,
2494
+ captain: {
2495
+ ...captain,
2496
+ runtime: migratePreEffectRuntimeSnapshot(
2497
+ captain.runtime,
2498
+ 'legacy Captain shell snapshot.captain.runtime',
2499
+ ),
2500
+ },
2501
+ effectLedger,
2502
+ ...(frames === undefined ? {} : { frames }),
2503
+ };
2504
+ }
2505
+
2506
+ function migratePreEffectRuntimeSnapshot(value, path) {
2507
+ const snapshot = requireRecord(value, path);
2508
+ if (snapshot.schemaVersion !== 3) {
2509
+ throw new CaptainSessionRecordSchemaError(
2510
+ snapshot.schemaVersion,
2511
+ `${path} schema ${JSON.stringify(snapshot.schemaVersion)} cannot migrate to schema 4`,
2512
+ );
2513
+ }
2514
+ if (Object.hasOwn(snapshot, 'effectLedger')) {
2515
+ throw new CaptainSessionRecordSchemaError(
2516
+ snapshot.schemaVersion,
2517
+ `${path} must not contain an effect ledger`,
2518
+ );
2519
+ }
2520
+ return {
2521
+ ...snapshot,
2522
+ schemaVersion: 4,
2523
+ effectLedger: emptyPlaybookEffectLedger(),
2524
+ };
2525
+ }
2526
+
2527
+ function migratePreEffectRetainedGenerations(value) {
2528
+ const retained = requireRecord(
2529
+ value,
2530
+ 'legacy Captain session retainedGenerations',
2531
+ );
2532
+ return Object.fromEntries(
2533
+ Object.entries(retained).map(([rootPlaybookId, rawGeneration]) => {
2534
+ const path =
2535
+ 'legacy Captain session retainedGenerations' +
2536
+ `[${JSON.stringify(rootPlaybookId)}]`;
2537
+ const generation = requireRecord(rawGeneration, path);
2538
+ if (
2539
+ Object.hasOwn(generation, 'effectLedger') ||
2540
+ Object.hasOwn(generation, 'retainedEffectReconciliation')
2541
+ ) {
2542
+ throw new Error(
2543
+ `${path} must not contain pre-migration effect evidence`,
2544
+ );
2545
+ }
2546
+ if (!Array.isArray(generation.frames)) {
2547
+ return [
2548
+ rootPlaybookId,
2549
+ { ...generation, effectLedger: emptyPlaybookEffectLedger() },
2550
+ ];
2551
+ }
2552
+ return [
2553
+ rootPlaybookId,
2554
+ {
2555
+ ...generation,
2556
+ effectLedger: emptyPlaybookEffectLedger(),
2557
+ frames: generation.frames.map((rawFrame, index) => {
2558
+ const frame = requireRecord(rawFrame, `${path}.frames[${index}]`);
2559
+ return {
2560
+ ...frame,
2561
+ runtime: migratePreEffectRuntimeSnapshot(
2562
+ frame.runtime,
2563
+ `${path}.frames[${index}].runtime`,
2564
+ ),
2565
+ };
2566
+ }),
2567
+ },
2568
+ ];
2569
+ }),
2570
+ );
2571
+ }
2572
+
2573
+ const EFFECT_AUTHORITY_KEYS = [
2574
+ 'playbookId',
2575
+ 'artifactSchema',
2576
+ 'cwd',
2577
+ 'sessionId',
2578
+ 'leaseOwnerToken',
2579
+ 'canonicalWorktree',
2580
+ 'requiredRoleIds',
2581
+ 'concurrentRoleSets',
2582
+ ];
2583
+ const EFFECT_BOUNDARY_START_KEYS = [
2584
+ 'boundaryId',
2585
+ 'playbookId',
2586
+ 'runtimeSessionId',
2587
+ 'turnId',
2588
+ 'callId',
2589
+ 'roleId',
2590
+ 'sourceStateId',
2591
+ 'sourceOutcomeSchema',
2592
+ 'dispositions',
2593
+ 'canonicalWorktree',
2594
+ 'baseline',
2595
+ 'correctionBudget',
2596
+ ];
2597
+ const EFFECT_BOUNDARY_IDENTITY_KEYS = [
2598
+ 'sequence',
2599
+ 'boundaryId',
2600
+ 'attemptId',
2601
+ 'attemptNumber',
2602
+ 'playbookId',
2603
+ 'runtimeSessionId',
2604
+ 'turnId',
2605
+ 'callId',
2606
+ 'roleId',
2607
+ 'sourceStateId',
2608
+ 'sourceOutcomeSchema',
2609
+ 'dispositions',
2610
+ 'canonicalWorktree',
2611
+ 'baseline',
2612
+ ];
2613
+ const EFFECT_BOUNDARY_OPTIONAL_KEYS = [
2614
+ 'after',
2615
+ 'physicalReceipt',
2616
+ 'finalText',
2617
+ 'semanticCandidate',
2618
+ 'initialSemanticCandidate',
2619
+ 'cohortId',
2620
+ 'logicalOperationId',
2621
+ ];
2622
+ const EFFECT_LOGICAL_START_KEYS = [
2623
+ 'operationId',
2624
+ 'playbookId',
2625
+ 'runtimeSessionId',
2626
+ 'boundaryIds',
2627
+ 'originalBaseline',
2628
+ 'checkpointRestorationEligible',
2629
+ ];
2630
+ const EFFECT_LOGICAL_OPTIONAL_KEYS = [
2631
+ 'checkpoint',
2632
+ 'pendingQuestion',
2633
+ 'playerContinuation',
2634
+ 'logicalReceipt',
2635
+ ];
2636
+
2637
+ function assertEffectLedgerMatchesCatalog(ledger, catalog) {
2638
+ const cohorts = new Map();
2639
+ for (const boundary of ledger.boundaries) {
2640
+ const entry = catalog[boundary.playbookId];
2641
+ if (
2642
+ entry?.artifactSchema !== 3 ||
2643
+ !entry.requiredRoleIds.includes(boundary.roleId)
2644
+ ) {
2645
+ throw new Error(
2646
+ `Captain session effect boundary ${JSON.stringify(boundary.boundaryId)} does not match its stored schema-3 catalog role`,
2647
+ );
2648
+ }
2649
+ if (boundary.cohortId === undefined) continue;
2650
+ const members = cohorts.get(boundary.cohortId) ?? [];
2651
+ members.push(boundary);
2652
+ cohorts.set(boundary.cohortId, members);
2653
+ }
2654
+ for (const [cohortId, members] of cohorts) {
2655
+ const entry = catalog[members[0].playbookId];
2656
+ const roles = members.map((boundary) => boundary.roleId);
2657
+ if (
2658
+ !members.every(
2659
+ (boundary) => boundary.playbookId === members[0].playbookId,
2660
+ ) ||
2661
+ !entry.concurrentRoleSets.some((candidate) =>
2662
+ isDeepStrictEqual(candidate, roles),
2663
+ )
2664
+ ) {
2665
+ throw new Error(
2666
+ `Captain session effect cohort ${JSON.stringify(cohortId)} is not one stored declared concurrent role set`,
2667
+ );
2668
+ }
2669
+ }
2670
+ }
2671
+
2672
+ function validateEffectLedgerAuthority(value, record, owner) {
2673
+ const authority = requireRecord(
2674
+ snapshotJsonValue(value, 'effect-ledger write authority'),
2675
+ 'effect-ledger write authority',
2676
+ );
2677
+ rejectUnknownOrMissingKeys(
2678
+ authority,
2679
+ EFFECT_AUTHORITY_KEYS,
2680
+ 'effect-ledger write authority',
2681
+ );
2682
+ const identity = requireRecord(
2683
+ authority.canonicalWorktree,
2684
+ 'effect-ledger write authority.canonicalWorktree',
2685
+ );
2686
+ rejectUnknownOrMissingKeys(
2687
+ identity,
2688
+ ['worktree', 'gitDir'],
2689
+ 'effect-ledger write authority.canonicalWorktree',
2690
+ );
2691
+ const entry = record.structuralProjection.catalog[authority.playbookId];
2692
+ if (
2693
+ authority.artifactSchema !== 3 ||
2694
+ authority.sessionId !== record.sessionId ||
2695
+ authority.sessionId !== owner.sessionId ||
2696
+ authority.leaseOwnerToken !== owner.ownerToken ||
2697
+ authority.cwd !== record.cwd ||
2698
+ entry?.artifactSchema !== 3 ||
2699
+ entry.id !== authority.playbookId ||
2700
+ !isDeepStrictEqual(authority.requiredRoleIds, entry.requiredRoleIds) ||
2701
+ !isDeepStrictEqual(
2702
+ authority.concurrentRoleSets,
2703
+ entry.concurrentRoleSets,
2704
+ )
2705
+ ) {
2706
+ throw new Error(
2707
+ 'effect-ledger write authority does not match the current Captain session lease and stored schema-3 playbook',
2708
+ );
2709
+ }
2710
+ for (const [key, path] of [
2711
+ ['worktree', 'effect-ledger write authority canonical worktree'],
2712
+ ['gitDir', 'effect-ledger write authority canonical Git directory'],
2713
+ ]) {
2714
+ if (
2715
+ typeof identity[key] !== 'string' ||
2716
+ !isAbsolute(identity[key]) ||
2717
+ resolve(identity[key]) !== identity[key]
2718
+ ) {
2719
+ throw new Error(`${path} must be a normalized absolute path`);
2720
+ }
2721
+ }
2722
+ return authority;
2723
+ }
2724
+
2725
+ function applyEffectLedgerCommands(
2726
+ ledgerValue,
2727
+ authority,
2728
+ uncertain,
2729
+ commandsValue,
2730
+ ) {
2731
+ const previous = assertPlaybookEffectLedger(
2732
+ ledgerValue,
2733
+ 'Captain session effect ledger',
2734
+ );
2735
+ const commands = requireNonemptyBatch(
2736
+ snapshotJsonValue(commandsValue, 'effect-ledger write commands'),
2737
+ 'effect-ledger write commands',
2738
+ );
2739
+ let candidate = previous;
2740
+ for (const [index, commandValue] of commands.entries()) {
2741
+ candidate = applyEffectLedgerCommand(
2742
+ candidate,
2743
+ authority,
2744
+ uncertain,
2745
+ commandValue,
2746
+ `effect-ledger write commands[${index}]`,
2747
+ );
2748
+ }
2749
+ if (isDeepStrictEqual(candidate, previous)) return previous;
2750
+ return validateNextEffectLedger(previous, {
2751
+ ...candidate,
2752
+ revision: previous.revision + 1,
2753
+ });
2754
+ }
2755
+
2756
+ function assertSettledEffectRecoveryCommands(commands) {
2757
+ if (!Array.isArray(commands) || commands.length === 0) {
2758
+ throw new Error(
2759
+ 'settled Captain session effect-ledger recovery requires boundary completion evidence',
2760
+ );
2761
+ }
2762
+ for (const command of commands) {
2763
+ if (
2764
+ command === null ||
2765
+ typeof command !== 'object' ||
2766
+ Array.isArray(command) ||
2767
+ command.kind !== 'replace-boundaries' ||
2768
+ !Array.isArray(command.replacements) ||
2769
+ command.replacements.length === 0
2770
+ ) {
2771
+ throw new Error(
2772
+ 'settled Captain session effect-ledger recovery may only complete existing physical boundaries',
2773
+ );
2774
+ }
2775
+ for (const replacement of command.replacements) {
2776
+ const expected = replacement?.expected;
2777
+ const next = replacement?.next;
2778
+ if (
2779
+ expected === null ||
2780
+ typeof expected !== 'object' ||
2781
+ Array.isArray(expected) ||
2782
+ next === null ||
2783
+ typeof next !== 'object' ||
2784
+ Array.isArray(next) ||
2785
+ expected.physicalReceipt !== undefined ||
2786
+ next.physicalReceipt === undefined
2787
+ ) {
2788
+ throw new Error(
2789
+ 'settled Captain session effect-ledger recovery requires one new physical receipt for each incomplete boundary',
2790
+ );
2791
+ }
2792
+ const preserved = { ...next };
2793
+ delete preserved.after;
2794
+ delete preserved.physicalReceipt;
2795
+ if (!isDeepStrictEqual(preserved, expected)) {
2796
+ throw new Error(
2797
+ 'settled Captain session effect-ledger recovery cannot change boundary semantics or identity',
2798
+ );
2799
+ }
2800
+ }
2801
+ }
2802
+ }
2803
+
2804
+ function applyEffectLedgerCommand(
2805
+ ledger,
2806
+ authority,
2807
+ uncertain,
2808
+ commandValue,
2809
+ path,
2810
+ ) {
2811
+ const command = requireRecord(
2812
+ commandValue,
2813
+ path,
2814
+ );
2815
+ if (command.kind === 'start-boundaries') {
2816
+ rejectUnknownOrMissingKeys(
2817
+ command,
2818
+ ['kind', 'boundaries'],
2819
+ path,
2820
+ );
2821
+ return startEffectBoundaries(ledger, authority, uncertain, command.boundaries);
2822
+ }
2823
+ if (command.kind === 'replace-boundaries') {
2824
+ rejectUnknownOrMissingKeys(
2825
+ command,
2826
+ ['kind', 'replacements'],
2827
+ path,
2828
+ );
2829
+ return replaceEffectBoundaries(ledger, authority, command.replacements);
2830
+ }
2831
+ if (command.kind === 'append-logical-operations') {
2832
+ rejectUnknownOrMissingKeys(
2833
+ command,
2834
+ ['kind', 'operations'],
2835
+ path,
2836
+ );
2837
+ return appendEffectLogicalOperations(
2838
+ ledger,
2839
+ authority,
2840
+ command.operations,
2841
+ );
2842
+ }
2843
+ if (command.kind === 'replace-logical-operations') {
2844
+ rejectUnknownOrMissingKeys(
2845
+ command,
2846
+ ['kind', 'replacements'],
2847
+ path,
2848
+ );
2849
+ return replaceEffectLogicalOperations(
2850
+ ledger,
2851
+ authority,
2852
+ command.replacements,
2853
+ );
2854
+ }
2855
+ throw new Error(
2856
+ `${path}.kind ${JSON.stringify(command.kind)} is not supported`,
2857
+ );
2858
+ }
2859
+
2860
+ function requireNonemptyBatch(value, path) {
2861
+ if (!Array.isArray(value) || value.length === 0) {
2862
+ throw new Error(`${path} must be a nonempty array`);
2863
+ }
2864
+ return value;
2865
+ }
2866
+
2867
+ function startEffectBoundaries(ledger, authority, uncertain, value) {
2868
+ const inputs = requireNonemptyBatch(
2869
+ value,
2870
+ 'effect-ledger start command boundaries',
2871
+ ).map((raw, index) => {
2872
+ const path = `effect-ledger start command boundaries[${index}]`;
2873
+ const input = requireRecord(raw, path);
2874
+ exactOptionalKeys(
2875
+ input,
2876
+ EFFECT_BOUNDARY_START_KEYS,
2877
+ ['cohortId', 'logicalOperationId'],
2878
+ path,
2879
+ );
2880
+ assertBoundaryAuthority(input, authority, path);
2881
+ return input;
2882
+ });
2883
+ assertDistinctCommandIdentities(inputs, 'boundaryId', 'boundary start');
2884
+ const existingById = new Map(
2885
+ ledger.boundaries.map((boundary) => [boundary.boundaryId, boundary]),
2886
+ );
2887
+ const existing = inputs.map((input) => existingById.get(input.boundaryId));
2888
+ if (existing.some((boundary) => boundary !== undefined)) {
2889
+ if (
2890
+ existing.every((boundary) => boundary !== undefined) &&
2891
+ existing.every((boundary, index) => {
2892
+ const payload = { ...boundary };
2893
+ const sequence = payload.sequence;
2894
+ delete payload.sequence;
2895
+ delete payload.attemptId;
2896
+ delete payload.attemptNumber;
2897
+ return (
2898
+ sequence === ledger.boundaries.length - inputs.length + index + 1 &&
2899
+ isDeepStrictEqual(payload, inputs[index])
2900
+ );
2901
+ })
2902
+ ) {
2903
+ return ledger;
2904
+ }
2905
+ throw new Error(
2906
+ 'effect-ledger boundary start reuses an existing boundary id with different data',
2907
+ );
2908
+ }
2909
+ const cohorts = new Map();
2910
+ for (const input of inputs) {
2911
+ if (input.cohortId === undefined) continue;
2912
+ const members = cohorts.get(input.cohortId) ?? [];
2913
+ members.push(input);
2914
+ cohorts.set(input.cohortId, members);
2915
+ }
2916
+ for (const [cohortId, members] of cohorts) {
2917
+ if (
2918
+ members.length < 2 ||
2919
+ !authority.concurrentRoleSets.some((roles) =>
2920
+ isDeepStrictEqual(
2921
+ roles,
2922
+ members.map((boundary) => boundary.roleId),
2923
+ ),
2924
+ ) ||
2925
+ ledger.boundaries.some((boundary) => boundary.cohortId === cohortId)
2926
+ ) {
2927
+ throw new Error(
2928
+ `effect-ledger boundary cohort ${JSON.stringify(cohortId)} is not one new declared concurrent role set`,
2929
+ );
2930
+ }
2931
+ }
2932
+ let sequence = ledger.boundaries.at(-1)?.sequence ?? 0;
2933
+ const additions = inputs.map((input) => ({
2934
+ sequence: ++sequence,
2935
+ boundaryId: input.boundaryId,
2936
+ attemptId: uncertain.attemptId,
2937
+ attemptNumber: uncertain.attemptNumber,
2938
+ playbookId: input.playbookId,
2939
+ runtimeSessionId: input.runtimeSessionId,
2940
+ turnId: input.turnId,
2941
+ callId: input.callId,
2942
+ roleId: input.roleId,
2943
+ sourceStateId: input.sourceStateId,
2944
+ sourceOutcomeSchema: input.sourceOutcomeSchema,
2945
+ dispositions: input.dispositions,
2946
+ canonicalWorktree: input.canonicalWorktree,
2947
+ baseline: input.baseline,
2948
+ correctionBudget: input.correctionBudget,
2949
+ ...(input.cohortId === undefined ? {} : { cohortId: input.cohortId }),
2950
+ ...(input.logicalOperationId === undefined
2951
+ ? {}
2952
+ : { logicalOperationId: input.logicalOperationId }),
2953
+ }));
2954
+ return {
2955
+ ...ledger,
2956
+ boundaries: [...ledger.boundaries, ...additions],
2957
+ };
2958
+ }
2959
+
2960
+ function replaceEffectBoundaries(ledger, authority, value) {
2961
+ const replacements = captureReplacements(
2962
+ value,
2963
+ 'boundaryId',
2964
+ 'effect-ledger boundary replacement command',
2965
+ );
2966
+ const byId = new Map(
2967
+ ledger.boundaries.map((boundary, index) => [
2968
+ boundary.boundaryId,
2969
+ { boundary, index },
2970
+ ]),
2971
+ );
2972
+ let alreadyApplied = true;
2973
+ const nextBoundaries = [...ledger.boundaries];
2974
+ for (const [index, replacement] of replacements.entries()) {
2975
+ const path = `effect-ledger boundary replacement command.replacements[${index}]`;
2976
+ assertBoundaryCommandShape(replacement.expected, `${path}.expected`);
2977
+ assertBoundaryCommandShape(replacement.next, `${path}.next`);
2978
+ assertBoundaryAuthority(replacement.expected, authority, `${path}.expected`);
2979
+ assertBoundaryAuthority(replacement.next, authority, `${path}.next`);
2980
+ assertMonotonicBoundaryReplacement(
2981
+ replacement.expected,
2982
+ replacement.next,
2983
+ path,
2984
+ );
2985
+ const current = byId.get(replacement.expected.boundaryId);
2986
+ if (current === undefined) {
2987
+ throw new Error(`${path} names an absent boundary`);
2988
+ }
2989
+ if (!isDeepStrictEqual(current.boundary, replacement.expected)) {
2990
+ throw new Error(`${path}.expected does not match the durable boundary`);
2991
+ }
2992
+ if (isDeepStrictEqual(current.boundary, replacement.next)) continue;
2993
+ alreadyApplied = false;
2994
+ nextBoundaries[current.index] = replacement.next;
2995
+ }
2996
+ if (alreadyApplied) return ledger;
2997
+ return {
2998
+ ...ledger,
2999
+ boundaries: nextBoundaries,
3000
+ };
3001
+ }
3002
+
3003
+ function appendEffectLogicalOperations(ledger, authority, value) {
3004
+ const inputs = requireNonemptyBatch(
3005
+ value,
3006
+ 'effect-ledger logical-operation append command operations',
3007
+ ).map((raw, index) => {
3008
+ const path = `effect-ledger logical-operation append command operations[${index}]`;
3009
+ const input = requireRecord(raw, path);
3010
+ exactOptionalKeys(
3011
+ input,
3012
+ EFFECT_LOGICAL_START_KEYS,
3013
+ EFFECT_LOGICAL_OPTIONAL_KEYS,
3014
+ path,
3015
+ );
3016
+ assertLogicalOperationAuthority(input, authority, path);
3017
+ return input;
3018
+ });
3019
+ assertDistinctCommandIdentities(
3020
+ inputs,
3021
+ 'operationId',
3022
+ 'logical-operation append',
3023
+ );
3024
+ const existingById = new Map(
3025
+ ledger.logicalOperations.map((operation) => [
3026
+ operation.operationId,
3027
+ operation,
3028
+ ]),
3029
+ );
3030
+ const existing = inputs.map((input) => existingById.get(input.operationId));
3031
+ if (existing.some((operation) => operation !== undefined)) {
3032
+ if (
3033
+ existing.every((operation) => operation !== undefined) &&
3034
+ existing.every((operation, index) => {
3035
+ const { sequence, ...payload } = operation;
3036
+ return (
3037
+ sequence ===
3038
+ ledger.logicalOperations.length - inputs.length + index + 1 &&
3039
+ isDeepStrictEqual(payload, inputs[index])
3040
+ );
3041
+ })
3042
+ ) {
3043
+ return ledger;
3044
+ }
3045
+ throw new Error(
3046
+ 'effect-ledger logical-operation append reuses an existing operation id with different data',
3047
+ );
3048
+ }
3049
+ let sequence = ledger.logicalOperations.at(-1)?.sequence ?? 0;
3050
+ const additions = inputs.map((input) => ({ sequence: ++sequence, ...input }));
3051
+ return {
3052
+ ...ledger,
3053
+ logicalOperations: [...ledger.logicalOperations, ...additions],
3054
+ };
3055
+ }
3056
+
3057
+ function replaceEffectLogicalOperations(ledger, authority, value) {
3058
+ const replacements = captureReplacements(
3059
+ value,
3060
+ 'operationId',
3061
+ 'effect-ledger logical-operation replacement command',
3062
+ );
3063
+ const byId = new Map(
3064
+ ledger.logicalOperations.map((operation, index) => [
3065
+ operation.operationId,
3066
+ { operation, index },
3067
+ ]),
3068
+ );
3069
+ let alreadyApplied = true;
3070
+ const nextOperations = [...ledger.logicalOperations];
3071
+ for (const [index, replacement] of replacements.entries()) {
3072
+ const path = `effect-ledger logical-operation replacement command.replacements[${index}]`;
3073
+ assertLogicalOperationCommandShape(
3074
+ replacement.expected,
3075
+ `${path}.expected`,
3076
+ );
3077
+ assertLogicalOperationCommandShape(replacement.next, `${path}.next`);
3078
+ assertLogicalOperationAuthority(
3079
+ replacement.expected,
3080
+ authority,
3081
+ `${path}.expected`,
3082
+ );
3083
+ assertLogicalOperationAuthority(
3084
+ replacement.next,
3085
+ authority,
3086
+ `${path}.next`,
3087
+ );
3088
+ assertMonotonicLogicalReplacement(
3089
+ replacement.expected,
3090
+ replacement.next,
3091
+ path,
3092
+ );
3093
+ const current = byId.get(replacement.expected.operationId);
3094
+ if (current === undefined) {
3095
+ throw new Error(`${path} names an absent logical operation`);
3096
+ }
3097
+ if (!isDeepStrictEqual(current.operation, replacement.expected)) {
3098
+ throw new Error(`${path}.expected does not match the durable logical operation`);
3099
+ }
3100
+ if (isDeepStrictEqual(current.operation, replacement.next)) continue;
3101
+ alreadyApplied = false;
3102
+ nextOperations[current.index] = replacement.next;
3103
+ }
3104
+ if (alreadyApplied) return ledger;
3105
+ return {
3106
+ ...ledger,
3107
+ logicalOperations: nextOperations,
3108
+ };
3109
+ }
3110
+
3111
+ function captureReplacements(value, identityKey, path) {
3112
+ const replacements = requireNonemptyBatch(value, `${path}.replacements`).map(
3113
+ (raw, index) => {
3114
+ const replacementPath = `${path}.replacements[${index}]`;
3115
+ const replacement = requireRecord(raw, replacementPath);
3116
+ rejectUnknownOrMissingKeys(
3117
+ replacement,
3118
+ ['expected', 'next'],
3119
+ replacementPath,
3120
+ );
3121
+ const expected = requireRecord(
3122
+ replacement.expected,
3123
+ `${replacementPath}.expected`,
3124
+ );
3125
+ const next = requireRecord(replacement.next, `${replacementPath}.next`);
3126
+ if (expected[identityKey] !== next[identityKey]) {
3127
+ throw new Error(
3128
+ `${replacementPath} cannot change ${identityKey}`,
3129
+ );
3130
+ }
3131
+ return { expected, next };
3132
+ },
3133
+ );
3134
+ assertDistinctCommandIdentities(
3135
+ replacements.map(({ expected }) => expected),
3136
+ identityKey,
3137
+ path,
3138
+ );
3139
+ return replacements;
3140
+ }
3141
+
3142
+ function assertBoundaryAuthority(boundary, authority, path) {
3143
+ if (
3144
+ boundary.playbookId !== authority.playbookId ||
3145
+ !isDeepStrictEqual(
3146
+ boundary.canonicalWorktree,
3147
+ authority.canonicalWorktree,
3148
+ ) ||
3149
+ !authority.requiredRoleIds.includes(boundary.roleId)
3150
+ ) {
3151
+ throw new Error(`${path} does not match its schema-3 host authority`);
3152
+ }
3153
+ }
3154
+
3155
+ function assertBoundaryCommandShape(boundary, path) {
3156
+ exactOptionalKeys(
3157
+ boundary,
3158
+ [...EFFECT_BOUNDARY_IDENTITY_KEYS, 'correctionBudget'],
3159
+ EFFECT_BOUNDARY_OPTIONAL_KEYS,
3160
+ path,
3161
+ );
3162
+ }
3163
+
3164
+ function assertLogicalOperationAuthority(operation, authority, path) {
3165
+ if (operation.playbookId !== authority.playbookId) {
3166
+ throw new Error(`${path} does not match its schema-3 host authority`);
3167
+ }
3168
+ }
3169
+
3170
+ function assertLogicalOperationCommandShape(operation, path) {
3171
+ exactOptionalKeys(
3172
+ operation,
3173
+ ['sequence', ...EFFECT_LOGICAL_START_KEYS],
3174
+ EFFECT_LOGICAL_OPTIONAL_KEYS,
3175
+ path,
3176
+ );
3177
+ }
3178
+
3179
+ function assertDistinctCommandIdentities(values, key, label) {
3180
+ const identities = values.map((value) => value[key]);
3181
+ if (new Set(identities).size !== identities.length) {
3182
+ throw new Error(`effect-ledger ${label} contains duplicate ${key}`);
3183
+ }
3184
+ }
3185
+
3186
+ function assertMonotonicBoundaryReplacement(expected, next, path) {
3187
+ for (const key of EFFECT_BOUNDARY_IDENTITY_KEYS) {
3188
+ if (!isDeepStrictEqual(expected[key], next[key])) {
3189
+ throw new Error(`${path}.next cannot change boundary field ${key}`);
3190
+ }
3191
+ }
3192
+ for (const key of [
3193
+ 'after',
3194
+ 'physicalReceipt',
3195
+ 'finalText',
3196
+ 'initialSemanticCandidate',
3197
+ ]) {
3198
+ if (
3199
+ expected[key] !== undefined &&
3200
+ !isDeepStrictEqual(expected[key], next[key])
3201
+ ) {
3202
+ throw new Error(`${path}.next cannot remove or replace boundary field ${key}`);
3203
+ }
3204
+ }
3205
+ if (
3206
+ expected.cohortId !== next.cohortId
3207
+ ) {
3208
+ throw new Error(`${path}.next cannot change boundary field cohortId`);
3209
+ }
3210
+ if (
3211
+ expected.logicalOperationId !== undefined &&
3212
+ expected.logicalOperationId !== next.logicalOperationId
3213
+ ) {
3214
+ throw new Error(
3215
+ `${path}.next cannot remove or replace boundary field logicalOperationId`,
3216
+ );
3217
+ }
3218
+ const expectedBudget = requireRecord(
3219
+ expected.correctionBudget,
3220
+ `${path}.expected.correctionBudget`,
3221
+ );
3222
+ const nextBudget = requireRecord(
3223
+ next.correctionBudget,
3224
+ `${path}.next.correctionBudget`,
3225
+ );
3226
+ if (
3227
+ expectedBudget.limit !== nextBudget.limit ||
3228
+ (expectedBudget.spent === true && nextBudget.spent !== true)
3229
+ ) {
3230
+ throw new Error(`${path}.next cannot replenish its correction budget`);
3231
+ }
3232
+ const candidateChanged =
3233
+ expected.semanticCandidate !== undefined &&
3234
+ !isDeepStrictEqual(
3235
+ expected.semanticCandidate,
3236
+ next.semanticCandidate,
3237
+ );
3238
+ if (expected.semanticCandidate === undefined) {
3239
+ if (next.initialSemanticCandidate !== undefined) {
3240
+ throw new Error(
3241
+ `${path}.next cannot add initialSemanticCandidate without replacing a prior candidate`,
3242
+ );
3243
+ }
3244
+ } else if (!candidateChanged) {
3245
+ if (
3246
+ expected.initialSemanticCandidate === undefined &&
3247
+ next.initialSemanticCandidate !== undefined
3248
+ ) {
1182
3249
  throw new Error(
1183
- 'Captain session uncertain markedAt must equal updatedAt',
3250
+ `${path}.next cannot add initialSemanticCandidate without replacing semanticCandidate`,
1184
3251
  );
1185
3252
  }
1186
- const attempted = assertCaptainSessionExecutionCompatible(
1187
- structural,
1188
- uncertain.attemptedExecutionProjection,
3253
+ } else if (
3254
+ next.semanticCandidate === undefined ||
3255
+ expected.initialSemanticCandidate !== undefined ||
3256
+ expectedBudget.spent !== true ||
3257
+ nextBudget.spent !== true ||
3258
+ !isDeepStrictEqual(
3259
+ next.initialSemanticCandidate,
3260
+ expected.semanticCandidate,
3261
+ )
3262
+ ) {
3263
+ throw new Error(
3264
+ `${path}.next cannot replace semanticCandidate without its spent one-way correction provenance`,
1189
3265
  );
1190
- if (uncertain.baseUpdatedAt === null) {
1191
- if (!isDeepStrictEqual(lastApplied, attempted)) {
1192
- throw new Error(
1193
- 'fresh Captain session baseline and attempted execution projections must match',
1194
- );
1195
- }
1196
- assertTurnZeroSnapshot(
1197
- snapshot,
1198
- 'fresh Captain session record snapshot',
1199
- );
3266
+ }
3267
+ }
3268
+
3269
+ function assertMonotonicLogicalReplacement(expected, next, path) {
3270
+ for (const key of [
3271
+ 'sequence',
3272
+ 'operationId',
3273
+ 'playbookId',
3274
+ 'runtimeSessionId',
3275
+ 'originalBaseline',
3276
+ ]) {
3277
+ if (!isDeepStrictEqual(expected[key], next[key])) {
3278
+ throw new Error(`${path}.next cannot change logical-operation field ${key}`);
1200
3279
  }
1201
3280
  }
3281
+ if (
3282
+ !Array.isArray(expected.boundaryIds) ||
3283
+ !Array.isArray(next.boundaryIds) ||
3284
+ expected.boundaryIds.some(
3285
+ (boundaryId, index) => next.boundaryIds[index] !== boundaryId,
3286
+ )
3287
+ ) {
3288
+ throw new Error(
3289
+ `${path}.next boundaryIds must preserve the existing ordered prefix`,
3290
+ );
3291
+ }
3292
+ if (
3293
+ expected.logicalReceipt !== undefined &&
3294
+ !isDeepStrictEqual(expected.logicalReceipt, next.logicalReceipt)
3295
+ ) {
3296
+ throw new Error(`${path}.next cannot remove or replace logicalReceipt`);
3297
+ }
3298
+ }
1202
3299
 
1203
- return record;
3300
+ function validateNextEffectLedger(previous, candidate) {
3301
+ if (!Number.isSafeInteger(previous.revision + 1)) {
3302
+ throw new Error('Captain session effect-ledger revision cannot advance');
3303
+ }
3304
+ const next = assertPlaybookEffectLedger(
3305
+ candidate,
3306
+ 'Captain session next effect ledger',
3307
+ );
3308
+ if (!isPlaybookEffectLedgerMonotonicExtension(previous, next)) {
3309
+ throw new Error(
3310
+ 'Captain session next effect ledger is not a monotonic extension',
3311
+ );
3312
+ }
3313
+ return next;
1204
3314
  }
1205
3315
 
1206
3316
  function assertReleasedSchema2CaptainSessionRecord(record) {
@@ -1323,7 +3433,7 @@ function assertTurnZeroSnapshot(snapshot, path) {
1323
3433
 
1324
3434
  function validateCaptainSessionProjection(
1325
3435
  projection,
1326
- { path, structural },
3436
+ { path, structural, artifactSchemas },
1327
3437
  ) {
1328
3438
  rejectUnknownOrMissingKeys(
1329
3439
  projection,
@@ -1414,8 +3524,24 @@ function validateCaptainSessionProjection(
1414
3524
  if (typeof item.intent !== 'string') {
1415
3525
  throw new Error(`${itemPath}.intent must be a string`);
1416
3526
  }
1417
- if (item.artifactSchema !== 2) {
1418
- throw new Error(`${itemPath}.artifactSchema must be exactly 2`);
3527
+ if (!artifactSchemas.has(item.artifactSchema)) {
3528
+ const expected = artifactSchemas === CURRENT_ARTIFACT_SCHEMAS
3529
+ ? '3'
3530
+ : '2 or 3';
3531
+ throw new Error(`${itemPath}.artifactSchema must be ${expected}`);
3532
+ }
3533
+ if (
3534
+ item.options !== null &&
3535
+ typeof item.options === 'object' &&
3536
+ !Array.isArray(item.options) &&
3537
+ Object.prototype.hasOwnProperty.call(
3538
+ item.options,
3539
+ HOST_CAPABILITIES_OPTION_KEY,
3540
+ )
3541
+ ) {
3542
+ throw new Error(
3543
+ `${itemPath}.options.${HOST_CAPABILITIES_OPTION_KEY} is host-owned and cannot be persisted`,
3544
+ );
1419
3545
  }
1420
3546
  const requiredRoleIds = validateRoleIds(
1421
3547
  item.requiredRoleIds,
@@ -1628,7 +3754,11 @@ function referencedPlayerIds(catalog) {
1628
3754
  return ids;
1629
3755
  }
1630
3756
 
1631
- function assertSnapshotMatchesStructure(snapshot, structural) {
3757
+ function assertSnapshotMatchesStructure(
3758
+ snapshot,
3759
+ structural,
3760
+ artifactSchemas = CURRENT_ARTIFACT_SCHEMAS,
3761
+ ) {
1632
3762
  if (!isDeepStrictEqual(snapshot.captain.agent, structural.captain)) {
1633
3763
  throw new Error(
1634
3764
  'Captain session snapshot Captain envelope differs from structuralProjection',
@@ -1675,6 +3805,541 @@ function assertSnapshotMatchesStructure(snapshot, structural) {
1675
3805
  `Captain session snapshot frame ${JSON.stringify(frame.playbookId)} role bindings differ from structuralProjection`,
1676
3806
  );
1677
3807
  }
3808
+ const expectedLedger =
3809
+ artifactSchemas === PRE_EFFECT_ARTIFACT_SCHEMAS &&
3810
+ item.artifactSchema === 2
3811
+ ? emptyPlaybookEffectLedger()
3812
+ : snapshot.effectLedger;
3813
+ if (!isDeepStrictEqual(frame.runtime.effectLedger, expectedLedger)) {
3814
+ throw new Error(
3815
+ `Captain session snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger differs from its structural schema authority`,
3816
+ );
3817
+ }
3818
+ }
3819
+ }
3820
+
3821
+ function validateRetainedGenerationUpdates(value) {
3822
+ const updates = snapshotJsonValue(
3823
+ value,
3824
+ 'Captain session retained-generation updates',
3825
+ );
3826
+ if (!Array.isArray(updates)) {
3827
+ throw new Error(
3828
+ 'Captain session retained-generation updates must be an array',
3829
+ );
3830
+ }
3831
+ const roots = new Set();
3832
+ for (const [index, value] of updates.entries()) {
3833
+ const path = `Captain session retained-generation updates[${index}]`;
3834
+ const update = requireRecord(value, path);
3835
+ if (update.kind === 'retain') {
3836
+ rejectUnknownOrMissingKeys(
3837
+ update,
3838
+ ['kind', 'rootPlaybookId', 'generation'],
3839
+ path,
3840
+ );
3841
+ requireRecord(update.generation, `${path}.generation`);
3842
+ } else if (update.kind === 'clear') {
3843
+ rejectUnknownOrMissingKeys(
3844
+ update,
3845
+ ['kind', 'rootPlaybookId'],
3846
+ path,
3847
+ );
3848
+ } else {
3849
+ throw new Error(`${path}.kind must be "retain" or "clear"`);
3850
+ }
3851
+ const rootPlaybookId = requireCanonicalNonblank(
3852
+ update.rootPlaybookId,
3853
+ `${path}.rootPlaybookId`,
3854
+ );
3855
+ if (roots.has(rootPlaybookId)) {
3856
+ throw new Error(
3857
+ 'Captain session retained-generation updates contain a duplicate root playbook id',
3858
+ );
3859
+ }
3860
+ roots.add(rootPlaybookId);
3861
+ }
3862
+ return updates;
3863
+ }
3864
+
3865
+ function applyRetainedGenerationUpdates(
3866
+ retainedGenerations,
3867
+ updates,
3868
+ structural,
3869
+ ) {
3870
+ const next = new Map(Object.entries(retainedGenerations));
3871
+ for (const update of updates) {
3872
+ if (!Object.hasOwn(structural.catalog, update.rootPlaybookId)) {
3873
+ throw new Error(
3874
+ `Captain session retained-generation update names unknown stored playbook ${JSON.stringify(update.rootPlaybookId)}`,
3875
+ );
3876
+ }
3877
+ if (update.kind === 'clear') {
3878
+ next.delete(update.rootPlaybookId);
3879
+ } else {
3880
+ next.set(update.rootPlaybookId, update.generation);
3881
+ }
3882
+ }
3883
+ return Object.fromEntries(next);
3884
+ }
3885
+
3886
+ function retainedGenerationsMember(record) {
3887
+ return Object.hasOwn(record, 'retainedGenerations')
3888
+ ? { retainedGenerations: record.retainedGenerations }
3889
+ : {};
3890
+ }
3891
+
3892
+ function settledAbandonmentMember(record) {
3893
+ return Object.hasOwn(record, 'settledAbandonment')
3894
+ ? { settledAbandonment: record.settledAbandonment }
3895
+ : {};
3896
+ }
3897
+
3898
+ function sortCaptainSessionRecords(records) {
3899
+ return [...records].sort((left, right) => {
3900
+ const byUpdated = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
3901
+ if (byUpdated !== 0) return byUpdated;
3902
+ if (right.sessionId === left.sessionId) return 0;
3903
+ return right.sessionId < left.sessionId ? -1 : 1;
3904
+ });
3905
+ }
3906
+
3907
+ function assertFreshAdoptionTarget(record) {
3908
+ if (record === undefined) {
3909
+ throw new Error('Captain session adoption target does not exist');
3910
+ }
3911
+ if (Object.keys(record.retainedGenerations ?? {}).length !== 0) {
3912
+ throw new Error(
3913
+ 'Captain session adoption target already retains generations',
3914
+ );
3915
+ }
3916
+ if (record.snapshot.mode !== 'chat') {
3917
+ throw new Error(
3918
+ 'Captain session adoption target must have no live engagement',
3919
+ );
3920
+ }
3921
+ if (record.state !== 'settled') {
3922
+ throw new Error(
3923
+ 'Captain session adoption target must be settled at turn zero',
3924
+ );
3925
+ }
3926
+ assertTurnZeroSnapshot(
3927
+ record.snapshot,
3928
+ 'Captain session adoption target snapshot',
3929
+ );
3930
+ }
3931
+
3932
+ function assertAdoptionTransferEnvelope(
3933
+ sourceStructural,
3934
+ targetStructural,
3935
+ retainedGenerations,
3936
+ effectLedger,
3937
+ ) {
3938
+ assertEffectLedgerMatchesCatalog(effectLedger, targetStructural.catalog);
3939
+ for (const [rootPlaybookId, generation] of Object.entries(
3940
+ retainedGenerations,
3941
+ )) {
3942
+ const sourceRoot = sourceStructural.catalog[rootPlaybookId];
3943
+ const targetRoot = targetStructural.catalog[rootPlaybookId];
3944
+ if (targetRoot === undefined) {
3945
+ throw new Error(
3946
+ `Captain session adoption target has no root playbook ${JSON.stringify(rootPlaybookId)}`,
3947
+ );
3948
+ }
3949
+ const rootEnvelope = (item) => ({
3950
+ from: item.from,
3951
+ manifestCommand: item.manifestCommand,
3952
+ options: item.options,
3953
+ requiredRoleIds: item.requiredRoleIds,
3954
+ concurrentRoleSets: item.concurrentRoleSets,
3955
+ });
3956
+ if (
3957
+ !isDeepStrictEqual(
3958
+ rootEnvelope(sourceRoot),
3959
+ rootEnvelope(targetRoot),
3960
+ )
3961
+ ) {
3962
+ throw new Error(
3963
+ `Captain session adoption target root envelope differs for ${JSON.stringify(rootPlaybookId)}`,
3964
+ );
3965
+ }
3966
+ for (const frame of generation.frames) {
3967
+ const sourceFrame = sourceStructural.catalog[frame.playbookId];
3968
+ const targetFrame = targetStructural.catalog[frame.playbookId];
3969
+ if (targetFrame === undefined) {
3970
+ throw new Error(
3971
+ `Captain session adoption target has no frame playbook ${JSON.stringify(frame.playbookId)}`,
3972
+ );
3973
+ }
3974
+ if (targetFrame.artifactSchema !== sourceFrame.artifactSchema) {
3975
+ throw new Error(
3976
+ `Captain session adoption target artifact schema differs for ${JSON.stringify(frame.playbookId)}`,
3977
+ );
3978
+ }
3979
+ }
3980
+ }
3981
+ }
3982
+
3983
+ function settledRecordWithRetainedGenerations(
3984
+ record,
3985
+ retainedGenerations,
3986
+ updatedAt,
3987
+ effectLedger = record.effectLedger,
3988
+ ) {
3989
+ if (record.state !== 'settled') {
3990
+ throw new Error('Captain session adoption exchange requires settled records');
3991
+ }
3992
+ return validateCaptainSessionRecord({
3993
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
3994
+ kind: record.kind,
3995
+ state: record.state,
3996
+ sessionId: record.sessionId,
3997
+ createdAt: record.createdAt,
3998
+ updatedAt,
3999
+ cwd: record.cwd,
4000
+ structuralProjection: record.structuralProjection,
4001
+ lastAppliedExecutionProjection: record.lastAppliedExecutionProjection,
4002
+ snapshot: isDeepStrictEqual(record.snapshot.effectLedger, effectLedger)
4003
+ ? record.snapshot
4004
+ : { ...record.snapshot, effectLedger },
4005
+ effectLedger,
4006
+ unresolvedEffects: record.unresolvedEffects,
4007
+ retainedGenerations,
4008
+ ...(Object.hasOwn(record, 'settledAbandonment')
4009
+ ? { settledAbandonment: record.settledAbandonment }
4010
+ : {}),
4011
+ });
4012
+ }
4013
+
4014
+ function validateRetainedGenerations(
4015
+ value,
4016
+ structural,
4017
+ authoritativeLedger,
4018
+ artifactSchemas = CURRENT_ARTIFACT_SCHEMAS,
4019
+ ) {
4020
+ const retained = requireRecord(
4021
+ value,
4022
+ 'Captain session record retainedGenerations',
4023
+ );
4024
+ const sessionIds = new Set();
4025
+ for (const [rootPlaybookId, value] of Object.entries(retained)) {
4026
+ const path =
4027
+ `Captain session record retainedGenerations` +
4028
+ `[${JSON.stringify(rootPlaybookId)}]`;
4029
+ if (!Object.hasOwn(structural.catalog, rootPlaybookId)) {
4030
+ throw new Error(
4031
+ `Captain session retained generation names unknown stored playbook ${JSON.stringify(rootPlaybookId)}`,
4032
+ );
4033
+ }
4034
+ const generation = requireRecord(value, path);
4035
+ exactOptionalKeys(
4036
+ generation,
4037
+ ['effectLedger', 'frames'],
4038
+ ['retainedEffectReconciliation', 'rootStateDescription'],
4039
+ path,
4040
+ );
4041
+ const generationLedger = assertPlaybookEffectLedger(
4042
+ generation.effectLedger,
4043
+ `${path}.effectLedger`,
4044
+ );
4045
+ if (
4046
+ generationLedger.boundaries.some(
4047
+ ({ physicalReceipt }) => physicalReceipt === undefined,
4048
+ )
4049
+ ) {
4050
+ throw new Error(
4051
+ `${path}.effectLedger contains an incomplete physical boundary`,
4052
+ );
4053
+ }
4054
+ assertEffectLedgerMatchesCatalog(generationLedger, structural.catalog);
4055
+ if (
4056
+ !isPlaybookEffectLedgerMonotonicExtension(
4057
+ generationLedger,
4058
+ authoritativeLedger,
4059
+ )
4060
+ ) {
4061
+ throw new Error(
4062
+ `${path}.effectLedger is not a monotonic checkpoint of the authoritative Captain session effect ledger`,
4063
+ );
4064
+ }
4065
+ const generationReconciliation =
4066
+ generation.retainedEffectReconciliation === undefined
4067
+ ? undefined
4068
+ : requireRecord(
4069
+ generation.retainedEffectReconciliation,
4070
+ `${path}.retainedEffectReconciliation`,
4071
+ );
4072
+ if (generationReconciliation !== undefined) {
4073
+ rejectUnknownOrMissingKeys(
4074
+ generationReconciliation,
4075
+ ['sourceGenerationId'],
4076
+ `${path}.retainedEffectReconciliation`,
4077
+ );
4078
+ assertUuid(
4079
+ generationReconciliation.sourceGenerationId,
4080
+ `${path}.retainedEffectReconciliation.sourceGenerationId`,
4081
+ );
4082
+ }
4083
+ if (generation.rootStateDescription !== undefined) {
4084
+ requireNonblank(
4085
+ generation.rootStateDescription,
4086
+ `${path}.rootStateDescription`,
4087
+ );
4088
+ }
4089
+ if (!Array.isArray(generation.frames) || generation.frames.length === 0) {
4090
+ throw new Error(`${path}.frames must be a non-empty array`);
4091
+ }
4092
+ const frames = generation.frames.map((value, index) =>
4093
+ validateRetainedGenerationFrame(
4094
+ value,
4095
+ index,
4096
+ rootPlaybookId,
4097
+ structural,
4098
+ generationLedger,
4099
+ authoritativeLedger,
4100
+ sessionIds,
4101
+ `${path}.frames[${index}]`,
4102
+ artifactSchemas,
4103
+ ),
4104
+ );
4105
+ const schema3Frames = frames.filter(
4106
+ ({ playbookId }) =>
4107
+ structural.catalog[playbookId].artifactSchema === 3,
4108
+ );
4109
+ const markedFrames = frames.filter(
4110
+ ({ runtime }) =>
4111
+ runtime.retainedEffectReconciliation !== undefined,
4112
+ );
4113
+ const markedCaptureLedger = markedFrames[0]?.runtime.effectLedger;
4114
+ const sourceGenerationId =
4115
+ generationReconciliation?.sourceGenerationId;
4116
+ if (
4117
+ markedCaptureLedger !== undefined &&
4118
+ markedFrames.some(
4119
+ ({ runtime }) =>
4120
+ !isDeepStrictEqual(runtime.effectLedger, markedCaptureLedger),
4121
+ )
4122
+ ) {
4123
+ throw new Error(
4124
+ `${path} schema-3 frames differ from the marked generation capture mirror`,
4125
+ );
4126
+ }
4127
+ if (
4128
+ (sourceGenerationId === undefined && markedFrames.length !== 0) ||
4129
+ (sourceGenerationId !== undefined &&
4130
+ markedFrames.length !== schema3Frames.length) ||
4131
+ (sourceGenerationId !== undefined &&
4132
+ structural.catalog[frames[0].playbookId].artifactSchema === 3 &&
4133
+ frames[0].runtime.retainedEffectReconciliation?.sourceSessionId !==
4134
+ sourceGenerationId)
4135
+ ) {
4136
+ throw new Error(
4137
+ `${path}.retainedEffectReconciliation is inconsistent with its schema-3 frame markers`,
4138
+ );
4139
+ }
4140
+ validateRetainedGenerationPath(frames, rootPlaybookId, path);
4141
+ }
4142
+ return retained;
4143
+ }
4144
+
4145
+ function validateRetainedGenerationFrame(
4146
+ value,
4147
+ index,
4148
+ rootPlaybookId,
4149
+ structural,
4150
+ generationLedger,
4151
+ authoritativeLedger,
4152
+ sessionIds,
4153
+ path,
4154
+ artifactSchemas = CURRENT_ARTIFACT_SCHEMAS,
4155
+ ) {
4156
+ const frame = requireRecord(value, path);
4157
+ exactOptionalKeys(
4158
+ frame,
4159
+ [
4160
+ 'playbookId',
4161
+ 'sessionId',
4162
+ 'rootSessionId',
4163
+ 'depth',
4164
+ 'options',
4165
+ 'roleBindings',
4166
+ 'runtime',
4167
+ ],
4168
+ ['parentSessionId', 'parentCallId'],
4169
+ path,
4170
+ );
4171
+ const playbookId = requireCanonicalNonblank(
4172
+ frame.playbookId,
4173
+ `${path}.playbookId`,
4174
+ );
4175
+ const catalogItem = structural.catalog[playbookId];
4176
+ if (catalogItem === undefined) {
4177
+ throw new Error(
4178
+ `${path}.playbookId names unknown stored playbook ${JSON.stringify(playbookId)}`,
4179
+ );
4180
+ }
4181
+ assertUuid(frame.sessionId, `${path}.sessionId`);
4182
+ assertUuid(frame.rootSessionId, `${path}.rootSessionId`);
4183
+ if (sessionIds.has(frame.sessionId)) {
4184
+ throw new Error(
4185
+ 'Captain session retained generation frame session ids must be unique',
4186
+ );
4187
+ }
4188
+ sessionIds.add(frame.sessionId);
4189
+ if (!Number.isSafeInteger(frame.depth) || frame.depth !== index) {
4190
+ throw new Error(`${path}.depth must equal its root-to-leaf index`);
4191
+ }
4192
+ if (frame.parentSessionId !== undefined) {
4193
+ assertUuid(frame.parentSessionId, `${path}.parentSessionId`);
4194
+ }
4195
+ if (frame.parentCallId !== undefined) {
4196
+ requireNonblank(frame.parentCallId, `${path}.parentCallId`);
4197
+ }
4198
+ const roleBindings = requireRecord(
4199
+ frame.roleBindings,
4200
+ `${path}.roleBindings`,
4201
+ );
4202
+ rejectUnknownOrMissingKeys(
4203
+ roleBindings,
4204
+ catalogItem.requiredRoleIds,
4205
+ `${path}.roleBindings`,
4206
+ );
4207
+ for (const roleId of catalogItem.requiredRoleIds) {
4208
+ assertPlayerId(roleBindings[roleId], `${path}.roleBindings.${roleId}`);
4209
+ }
4210
+ let runtime;
4211
+ try {
4212
+ runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, {
4213
+ allowSuspendedCall: true,
4214
+ });
4215
+ } catch (cause) {
4216
+ throw new Error(`${path}.runtime is invalid: ${errorMessage(cause)}`, {
4217
+ cause,
4218
+ });
4219
+ }
4220
+ const runtimeReconciliation = runtime.retainedEffectReconciliation;
4221
+ if (
4222
+ artifactSchemas === PRE_EFFECT_ARTIFACT_SCHEMAS &&
4223
+ catalogItem.artifactSchema === 2 &&
4224
+ (!isDeepStrictEqual(runtime.effectLedger, emptyPlaybookEffectLedger()) ||
4225
+ runtimeReconciliation !== undefined ||
4226
+ runtime.retainedEffectSourceSessionId !== undefined)
4227
+ ) {
4228
+ throw new Error(
4229
+ `${path}.runtime schema-2 effect state must be empty`,
4230
+ );
4231
+ } else if (
4232
+ runtimeReconciliation === undefined &&
4233
+ !isDeepStrictEqual(runtime.effectLedger, generationLedger)
4234
+ ) {
4235
+ throw new Error(
4236
+ `${path}.runtime effect ledger differs from its retained-generation checkpoint`,
4237
+ );
4238
+ } else if (
4239
+ runtimeReconciliation !== undefined &&
4240
+ (!isDeepStrictEqual(runtimeReconciliation.checkpoint, generationLedger) ||
4241
+ isDeepStrictEqual(runtime.effectLedger, generationLedger) ||
4242
+ !isPlaybookEffectLedgerMonotonicExtension(
4243
+ runtime.effectLedger,
4244
+ authoritativeLedger,
4245
+ ))
4246
+ ) {
4247
+ throw new Error(
4248
+ `${path}.runtime retained-effect evidence differs from its generation checkpoint or capture-time authority`,
4249
+ );
4250
+ }
4251
+ if (
4252
+ runtime.state.status !== 'active' ||
4253
+ !runtime.state.quiescent ||
4254
+ runtime.state.stateId === undefined
4255
+ ) {
4256
+ throw new Error(
4257
+ `${path}.runtime must capture one active quiescent pre-terminal state`,
4258
+ );
4259
+ }
4260
+ for (const roleId of Object.keys(runtime.roleResumeTokens)) {
4261
+ if (!Object.hasOwn(roleBindings, roleId)) {
4262
+ throw new Error(
4263
+ `${path}.runtime role token names an unbound role ${JSON.stringify(roleId)}`,
4264
+ );
4265
+ }
4266
+ }
4267
+ for (const question of runtime.pendingBossQuestions) {
4268
+ if (
4269
+ question.asker.kind === 'role' &&
4270
+ !Object.hasOwn(roleBindings, question.asker.roleId)
4271
+ ) {
4272
+ throw new Error(
4273
+ `${path}.runtime pending question names an unbound role ${JSON.stringify(question.asker.roleId)}`,
4274
+ );
4275
+ }
4276
+ }
4277
+ if (index === 0 && playbookId !== rootPlaybookId) {
4278
+ throw new Error(
4279
+ `${path}.playbookId must equal retained root ${JSON.stringify(rootPlaybookId)}`,
4280
+ );
4281
+ }
4282
+ return {
4283
+ ...frame,
4284
+ playbookId,
4285
+ runtime,
4286
+ };
4287
+ }
4288
+
4289
+ function validateRetainedGenerationPath(frames, rootPlaybookId, path) {
4290
+ const rootSessionId = frames[0].sessionId;
4291
+ const playbookIds = new Set();
4292
+ for (const [index, frame] of frames.entries()) {
4293
+ if (playbookIds.has(frame.playbookId)) {
4294
+ throw new Error(`${path}.frames must not contain a playbook cycle`);
4295
+ }
4296
+ playbookIds.add(frame.playbookId);
4297
+ if (frame.rootSessionId !== rootSessionId) {
4298
+ throw new Error(
4299
+ `${path}.frames[${index}].rootSessionId must equal the root session id`,
4300
+ );
4301
+ }
4302
+ if (index === 0) {
4303
+ if (
4304
+ frame.sessionId !== frame.rootSessionId ||
4305
+ frame.parentSessionId !== undefined ||
4306
+ frame.parentCallId !== undefined
4307
+ ) {
4308
+ throw new Error(
4309
+ `${path}.frames[0] has child-only or inconsistent root identity`,
4310
+ );
4311
+ }
4312
+ continue;
4313
+ }
4314
+ const parent = frames[index - 1];
4315
+ const suspendedCall = parent.runtime.suspendedCall;
4316
+ if (
4317
+ frame.parentSessionId !== parent.sessionId ||
4318
+ frame.parentCallId === undefined
4319
+ ) {
4320
+ throw new Error(
4321
+ `${path}.frames[${index}] does not identify its immediate parent`,
4322
+ );
4323
+ }
4324
+ if (
4325
+ suspendedCall === undefined ||
4326
+ suspendedCall.callId !== frame.parentCallId ||
4327
+ suspendedCall.playbookId !== frame.playbookId ||
4328
+ suspendedCall.childSessionId !== frame.sessionId
4329
+ ) {
4330
+ throw new Error(
4331
+ `${path}.frames[${index - 1}] suspended call does not match its child edge`,
4332
+ );
4333
+ }
4334
+ }
4335
+ const leaf = frames.at(-1).runtime;
4336
+ if (
4337
+ leaf.suspendedCall !== undefined ||
4338
+ !leaf.state.tags.includes('playbook.parked')
4339
+ ) {
4340
+ throw new Error(
4341
+ `Captain session retained generation ${JSON.stringify(rootPlaybookId)} leaf must be parked without a suspended child call`,
4342
+ );
1678
4343
  }
1679
4344
  }
1680
4345
 
@@ -1711,7 +4376,10 @@ async function requireUncertainRecord(record, attemptId) {
1711
4376
  if (record.state !== 'uncertain') {
1712
4377
  throw new Error('Captain session has no uncertain turn');
1713
4378
  }
1714
- if (record.uncertain.attemptId !== attemptId) {
4379
+ if (
4380
+ attemptId !== undefined &&
4381
+ record.uncertain.attemptId !== attemptId
4382
+ ) {
1715
4383
  throw new Error('Captain session uncertain attempt id changed');
1716
4384
  }
1717
4385
  return record;