@sublang/playbook 8.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 (66) hide show
  1. package/README.md +3 -3
  2. package/docs/cli.md +66 -23
  3. package/docs/configuration.md +13 -8
  4. package/docs/embedding.md +45 -14
  5. package/package.json +7 -3
  6. package/reference/sdlc/captain.md +14 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  10. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +151 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +200 -14
  14. package/reference/sdlc/code.md +0 -1
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
  17. package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
  18. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  19. package/reference/sdlc/code.playbook/bin/run.js +365 -63
  20. package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
  21. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -1
  22. package/reference/sdlc/code.playbook/code.fsm.js +85 -29
  23. package/reference/sdlc/code.playbook/code.fsm.ts +95 -33
  24. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  25. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  26. package/reference/sdlc/code.playbook/code.playbook.js +67 -4
  27. package/reference/sdlc/code.playbook/code.playbook.ts +87 -8
  28. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  29. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  31. package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
  32. package/reference/sdlc/code.playbook/playbook-captain.js +1894 -82
  33. package/reference/sdlc/code.playbook/playbook-captain.ts +2809 -109
  34. package/reference/sdlc/decide.md +0 -1
  35. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +8 -1
  36. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  37. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  38. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  39. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +15 -5
  40. package/reference/sdlc/decide.playbook/decide.playbook.js +1994 -191
  41. package/reference/sdlc/decide.playbook/decide.playbook.ts +3209 -404
  42. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  43. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  44. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  45. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  46. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  47. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  48. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  49. package/reference/sdlc/review.playbook/review.playbook.js +78 -4
  50. package/reference/sdlc/review.playbook/review.playbook.ts +95 -8
  51. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  52. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  53. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  54. package/slc/gears2fsm.md +25 -7
  55. package/slc/link.md +727 -82
  56. package/src/accepted-outcome.d.ts +18 -0
  57. package/src/accepted-outcome.js +94 -0
  58. package/src/accepted-outcome.ts +140 -0
  59. package/src/runtime.d.ts +165 -3
  60. package/src/runtime.ts +214 -2
  61. package/src/xstate-playbook-runtime.d.ts +162 -13
  62. package/src/xstate-playbook-runtime.js +3344 -564
  63. package/src/xstate-playbook-runtime.ts +4873 -637
  64. package/src/xstate-runtime.d.ts +76 -8
  65. package/src/xstate-runtime.js +1001 -64
  66. package/src/xstate-runtime.ts +1640 -91
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ import { createHash } from 'node:crypto';
3
4
  import { fromPromise, waitFor, } from 'xstate';
4
5
  // DR-019: the generic linked-runtime factory and its strategy helpers live
5
6
  // in the sibling module and are re-exported here so linked artifacts import
@@ -31,9 +32,19 @@ function withAbort(promise, signal) {
31
32
  });
32
33
  });
33
34
  }
35
+ // slc/link.md §Abort: cancellation is causal identity with the applicable
36
+ // signal's reason; an `AbortError`-named rejection that is not that exact
37
+ // reason is a control-plane failure to surface, never an abort to swallow.
34
38
  function isAbortReason(error, signal) {
35
- return (signal.aborted &&
36
- (error === signal.reason || normalizeError(error).name === 'AbortError'));
39
+ return signal.aborted && Object.is(error, signal.reason);
40
+ }
41
+ function createAbortReasonClassifier(...sources) {
42
+ const captured = Object.freeze(sources.filter((source) => source !== undefined));
43
+ return Object.freeze({
44
+ isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
45
+ ? isAbortReason(error, source)
46
+ : source.isAbortReason(error)),
47
+ });
37
48
  }
38
49
  const NEVER_ABORTED_SIGNAL = new AbortController().signal;
39
50
  /**
@@ -73,7 +84,7 @@ export function registerPlaybookAbortCleanup(signal, cleanup) {
73
84
  // the bridge's allSettled drain observes its outcome.
74
85
  void cleanup.catch(() => undefined);
75
86
  }
76
- async function drainPlaybookAbortCleanups(signal) {
87
+ async function drainPlaybookAbortCleanups(signal, aborts) {
77
88
  const failures = [];
78
89
  while (true) {
79
90
  const pending = abortCleanups.get(signal);
@@ -83,8 +94,10 @@ async function drainPlaybookAbortCleanups(signal) {
83
94
  pending.clear();
84
95
  const outcomes = await Promise.allSettled(batch);
85
96
  for (const outcome of outcomes) {
86
- if (outcome.status === 'rejected')
97
+ if (outcome.status === 'rejected' &&
98
+ !aborts.isAbortReason(outcome.reason)) {
87
99
  failures.push(outcome.reason);
100
+ }
88
101
  }
89
102
  }
90
103
  abortCleanups.delete(signal);
@@ -484,12 +497,23 @@ export function activePlaybookStateMetadata(snapshot) {
484
497
  throw new TypeError(`${nodeId}.meta.playbook must be an object`);
485
498
  }
486
499
  const stateId = requireNonEmptyString(meta.playbook.stateId, `${nodeId}.meta.playbook.stateId`);
487
- const description = requireNonEmptyString(meta.playbook.description, `${nodeId}.meta.playbook.description`);
500
+ // Description is optional: a state may declare none and stay fully
501
+ // usable, merely carrying no `stateDescription` downstream. A declared
502
+ // description must still be a nonempty string.
503
+ const description = meta.playbook.description === undefined
504
+ ? undefined
505
+ : requireNonEmptyString(meta.playbook.description, `${nodeId}.meta.playbook.description`);
488
506
  const previous = byStateId.get(stateId);
489
- if (previous && previous.description !== description) {
507
+ if (previous?.description !== undefined &&
508
+ description !== undefined &&
509
+ previous.description !== description) {
490
510
  throw new TypeError(`active state id ${stateId} has conflicting descriptions`);
491
511
  }
492
- byStateId.set(stateId, { stateId, description });
512
+ const effective = description ?? previous?.description;
513
+ byStateId.set(stateId, {
514
+ stateId,
515
+ ...(effective === undefined ? {} : { description: effective }),
516
+ });
493
517
  }
494
518
  return [...byStateId.values()].sort((left, right) => left.stateId.localeCompare(right.stateId));
495
519
  }
@@ -550,12 +574,790 @@ const SNAPSHOT_SEQUENCE_KEYS = [
550
574
  'playerCall',
551
575
  'playbookCall',
552
576
  ];
577
+ const EFFECT_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
578
+ const EFFECT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
579
+ const EFFECT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
580
+ const EFFECT_DISPOSITIONS = new Set([
581
+ 'unchanged',
582
+ 'one-descendant-commit',
583
+ 'deferred',
584
+ ]);
585
+ const EFFECT_RECEIPT_CLASSIFICATIONS = new Set([
586
+ 'unchanged',
587
+ 'one-descendant-commit',
588
+ 'multiple-commits',
589
+ 'rewritten-or-non-descendant',
590
+ 'worktree-only-change',
591
+ 'concurrent-or-foreign-change',
592
+ 'observation-ambiguous',
593
+ ]);
594
+ function effectUuid(value, path) {
595
+ const id = requireNonEmptyString(value, path);
596
+ if (!EFFECT_UUID_PATTERN.test(id)) {
597
+ throw new TypeError(`${path} must be a canonical UUID`);
598
+ }
599
+ return id;
600
+ }
601
+ function effectInteger(value, path, minimum = 0) {
602
+ if (!Number.isSafeInteger(value) || value < minimum) {
603
+ throw new TypeError(`${path} must be an integer greater than or equal to ${minimum}`);
604
+ }
605
+ return value;
606
+ }
607
+ function jsonValuesEqual(left, right) {
608
+ if (Object.is(left, right))
609
+ return true;
610
+ if (Array.isArray(left) || Array.isArray(right)) {
611
+ return (Array.isArray(left) &&
612
+ Array.isArray(right) &&
613
+ left.length === right.length &&
614
+ left.every((entry, index) => jsonValuesEqual(entry, right[index])));
615
+ }
616
+ if (isRecord(left) && isRecord(right)) {
617
+ const leftKeys = Object.keys(left).sort();
618
+ const rightKeys = Object.keys(right).sort();
619
+ return (leftKeys.length === rightKeys.length &&
620
+ leftKeys.every((key, index) => key === rightKeys[index] &&
621
+ jsonValuesEqual(left[key], right[key])));
622
+ }
623
+ return false;
624
+ }
625
+ function projectionText(projection) {
626
+ return JSON.stringify(projection);
627
+ }
628
+ function projectionsEqual(left, right) {
629
+ return (left.projectionDigest === right.projectionDigest &&
630
+ projectionText(left.projection) === projectionText(right.projection));
631
+ }
632
+ function projectionPreservesBaseline(baseline, after) {
633
+ return Object.entries(baseline.projection).every(([path, entry]) => own(after.projection, path) &&
634
+ JSON.stringify(entry) === JSON.stringify(after.projection[path]));
635
+ }
636
+ function effectObservation(value, path) {
637
+ if (!isRecord(value))
638
+ throw new TypeError(`${path} must be an object`);
639
+ rejectUnknownKeys(value, ['worktree', 'gitDir', 'head', 'projection', 'projectionDigest'], path);
640
+ requireNonEmptyString(value.worktree, `${path}.worktree`);
641
+ requireNonEmptyString(value.gitDir, `${path}.gitDir`);
642
+ const head = requireNonEmptyString(value.head, `${path}.head`);
643
+ if (!EFFECT_OID_PATTERN.test(head)) {
644
+ throw new TypeError(`${path}.head must be a canonical Git commit OID`);
645
+ }
646
+ if (!isRecord(value.projection)) {
647
+ throw new TypeError(`${path}.projection must be a path-keyed object`);
648
+ }
649
+ for (const key of Object.keys(value.projection)) {
650
+ if (key.length === 0) {
651
+ throw new TypeError(`${path}.projection must not contain an empty path`);
652
+ }
653
+ }
654
+ const projectionDigest = requireNonEmptyString(value.projectionDigest, `${path}.projectionDigest`);
655
+ if (!EFFECT_DIGEST_PATTERN.test(projectionDigest)) {
656
+ throw new TypeError(`${path}.projectionDigest must be a canonical SHA-256 identity`);
657
+ }
658
+ const expectedDigest = `sha256:${createHash('sha256')
659
+ .update(JSON.stringify(value.projection))
660
+ .digest('hex')}`;
661
+ if (projectionDigest !== expectedDigest) {
662
+ throw new TypeError(`${path}.projectionDigest does not match its projection`);
663
+ }
664
+ return value;
665
+ }
666
+ function assertObservationIdentity(observation, identity, path) {
667
+ if (observation.worktree !== identity.worktree ||
668
+ observation.gitDir !== identity.gitDir) {
669
+ throw new TypeError(`${path} does not match its canonical worktree identity`);
670
+ }
671
+ }
672
+ function effectReceipt(value, path, expectedBaseline, expectedAfter, matchExpectedAfter = false, dispositions) {
673
+ if (!isRecord(value))
674
+ throw new TypeError(`${path} must be an object`);
675
+ rejectUnknownKeys(value, ['classification', 'baseline', 'after', 'commitOid'], path);
676
+ if (typeof value.classification !== 'string' ||
677
+ !EFFECT_RECEIPT_CLASSIFICATIONS.has(value.classification)) {
678
+ throw new TypeError(`${path}.classification is not supported`);
679
+ }
680
+ const classification = value.classification;
681
+ const baseline = effectObservation(value.baseline, `${path}.baseline`);
682
+ const after = own(value, 'after')
683
+ ? effectObservation(value.after, `${path}.after`)
684
+ : undefined;
685
+ assertObservationIdentity(after ?? baseline, baseline, `${path}.after`);
686
+ if (expectedBaseline !== undefined &&
687
+ !jsonValuesEqual(baseline, expectedBaseline)) {
688
+ throw new TypeError(`${path}.baseline does not match its boundary baseline`);
689
+ }
690
+ if (matchExpectedAfter) {
691
+ if (expectedAfter !== undefined &&
692
+ (after === undefined ||
693
+ !jsonValuesEqual(after, expectedAfter))) {
694
+ throw new TypeError(`${path}.after does not match its expected observation`);
695
+ }
696
+ if (expectedAfter === undefined && after !== undefined) {
697
+ throw new TypeError(`${path}.after has no matching expected observation`);
698
+ }
699
+ }
700
+ if (classification !== 'observation-ambiguous' && after === undefined) {
701
+ throw new TypeError(`${path}.after is required for ${classification}`);
702
+ }
703
+ const commitOid = own(value, 'commitOid')
704
+ ? requireNonEmptyString(value.commitOid, `${path}.commitOid`)
705
+ : undefined;
706
+ if (classification === 'one-descendant-commit') {
707
+ if (commitOid === undefined ||
708
+ !EFFECT_OID_PATTERN.test(commitOid) ||
709
+ after?.head !== commitOid) {
710
+ throw new TypeError(`${path}.commitOid must equal the after HEAD for one-descendant-commit`);
711
+ }
712
+ }
713
+ else if (commitOid !== undefined) {
714
+ throw new TypeError(`${path}.commitOid is permitted only for one-descendant-commit`);
715
+ }
716
+ if (classification === 'unchanged' &&
717
+ after !== undefined &&
718
+ !jsonValuesEqual(baseline, after)) {
719
+ throw new TypeError(`${path} classified unchanged observations that differ`);
720
+ }
721
+ if (classification === 'one-descendant-commit' &&
722
+ after !== undefined &&
723
+ (baseline.head === after.head ||
724
+ !projectionsEqual(baseline, after))) {
725
+ throw new TypeError(`${path} one-descendant-commit must change HEAD and preserve the projection`);
726
+ }
727
+ if (classification === 'worktree-only-change' &&
728
+ after !== undefined &&
729
+ (baseline.head !== after.head ||
730
+ projectionsEqual(baseline, after) ||
731
+ !projectionPreservesBaseline(baseline, after))) {
732
+ throw new TypeError(`${path} worktree-only-change must preserve HEAD and change the projection`);
733
+ }
734
+ if ((classification === 'multiple-commits' ||
735
+ classification === 'rewritten-or-non-descendant') &&
736
+ after?.head === baseline.head) {
737
+ throw new TypeError(`${path} ${classification} must change HEAD`);
738
+ }
739
+ if ((classification === 'one-descendant-commit' ||
740
+ classification === 'multiple-commits' ||
741
+ classification === 'rewritten-or-non-descendant' ||
742
+ classification === 'worktree-only-change') &&
743
+ !dispositions?.includes('one-descendant-commit')) {
744
+ throw new TypeError(`${path}.classification is incompatible with its boundary dispositions`);
745
+ }
746
+ if (classification === 'concurrent-or-foreign-change' &&
747
+ (!dispositions?.every((value) => value === 'unchanged') ||
748
+ (after !== undefined &&
749
+ baseline.head === after.head &&
750
+ projectionsEqual(baseline, after)))) {
751
+ throw new TypeError(`${path}.classification is incompatible with its boundary dispositions`);
752
+ }
753
+ if (classification === 'observation-ambiguous' &&
754
+ after !== undefined &&
755
+ baseline.head === after.head &&
756
+ projectionsEqual(baseline, after)) {
757
+ throw new TypeError(`${path} observation-ambiguous requires an absent or changed after observation`);
758
+ }
759
+ return value;
760
+ }
761
+ /**
762
+ * A judge candidate is structurally invalid, rather than merely awaiting or
763
+ * conflicting with effect evidence. Callers use this distinction to spend at
764
+ * most one durable correction budget before asking another hidden judge.
765
+ */
766
+ export class PlaybookSemanticCandidateStructureError extends TypeError {
767
+ constructor(message) {
768
+ super(`semantic candidate ${message}`);
769
+ this.name = 'PlaybookSemanticCandidateStructureError';
770
+ }
771
+ }
772
+ function semanticCandidateStructureError(message) {
773
+ throw new PlaybookSemanticCandidateStructureError(message);
774
+ }
775
+ function snapshotSemanticCandidate(value, outcomes) {
776
+ let detached;
777
+ try {
778
+ detached = snapshotJsonValue(value, 'semantic candidate');
779
+ }
780
+ catch (error) {
781
+ const detail = error instanceof Error ? `: ${error.message}` : '';
782
+ semanticCandidateStructureError(`must be detached plain JSON${detail}`);
783
+ }
784
+ if (!isRecord(detached)) {
785
+ semanticCandidateStructureError('must be an object');
786
+ }
787
+ const guard = detached.guard;
788
+ if (typeof guard !== 'string' || guard.length === 0) {
789
+ semanticCandidateStructureError('must contain a nonempty string guard');
790
+ }
791
+ if (!own(outcomes, guard)) {
792
+ semanticCandidateStructureError(`guard ${JSON.stringify(guard)} is not declared`);
793
+ }
794
+ const outcome = outcomes[guard];
795
+ const semanticFields = Object.entries(outcome.fields)
796
+ .filter(([, authority]) => authority === 'semantic')
797
+ .map(([field]) => field);
798
+ const expected = new Set(['guard', ...semanticFields]);
799
+ const actual = Object.keys(detached);
800
+ const missing = [...expected].filter((field) => !actual.includes(field));
801
+ const extra = actual.filter((field) => !expected.has(field));
802
+ if (missing.length > 0 || extra.length > 0) {
803
+ semanticCandidateStructureError('must contain exactly its guard and semantic-owned fields' +
804
+ (missing.length === 0 ? '' : `; missing ${missing.join(', ')}`) +
805
+ (extra.length === 0 ? '' : `; extra or wrongly owned ${extra.join(', ')}`));
806
+ }
807
+ for (const field of semanticFields) {
808
+ if (typeof detached[field] !== 'string') {
809
+ semanticCandidateStructureError(`field ${JSON.stringify(field)} must be a string`);
810
+ }
811
+ }
812
+ return {
813
+ candidate: detached,
814
+ outcome,
815
+ };
816
+ }
817
+ function retainedSemanticEvidence(candidate, finalText) {
818
+ return Object.freeze({
819
+ semanticCandidate: candidate,
820
+ ...(typeof finalText === 'string' ? { finalText } : {}),
821
+ });
822
+ }
823
+ function unresolvedSemanticEvidence(reason, evidence) {
824
+ return Object.freeze({ status: 'unresolved', reason, evidence });
825
+ }
826
+ /**
827
+ * Reconcile one exact semantic candidate with host-owned effect evidence.
828
+ * Presentation prose remains opaque: it is retained verbatim and only its
829
+ * trimmed value is copied into linker-declared presentation fields. No
830
+ * repository fact is inferred from that prose or from the semantic candidate.
831
+ */
832
+ export function reconcilePlaybookSemanticEvidence(input) {
833
+ const { candidate, outcome } = snapshotSemanticCandidate(input.semanticCandidate, input.outcomes);
834
+ const evidence = retainedSemanticEvidence(candidate, input.finalText);
835
+ const finalText = typeof input.finalText === 'string' ? input.finalText.trim() : undefined;
836
+ if (finalText === undefined || finalText.length === 0) {
837
+ return unresolvedSemanticEvidence('missing-presentation-evidence', evidence);
838
+ }
839
+ if (input.receipt === undefined) {
840
+ return unresolvedSemanticEvidence('missing-repository-receipt', evidence);
841
+ }
842
+ let receipt;
843
+ try {
844
+ const detachedReceipt = snapshotJsonValue(input.receipt, 'semantic reconciliation receipt');
845
+ receipt = effectReceipt(detachedReceipt, 'semantic reconciliation receipt', undefined, undefined, false, Object.values(input.outcomes).map(({ repositoryDisposition }) => repositoryDisposition));
846
+ }
847
+ catch {
848
+ return unresolvedSemanticEvidence('invalid-repository-receipt', evidence);
849
+ }
850
+ const disposition = outcome.repositoryDisposition;
851
+ const dispositionMatches = (disposition === 'unchanged' && receipt.classification === 'unchanged') ||
852
+ (disposition === 'one-descendant-commit' &&
853
+ receipt.classification === 'one-descendant-commit') ||
854
+ (disposition === 'deferred' &&
855
+ candidate.guard === 'needsBossReply' &&
856
+ Object.entries(input.outcomes).some(([guard, declared]) => guard !== candidate.guard &&
857
+ declared.repositoryDisposition === 'one-descendant-commit') &&
858
+ (receipt.classification === 'unchanged' ||
859
+ receipt.classification === 'worktree-only-change') &&
860
+ receipt.after !== undefined &&
861
+ receipt.after.head === receipt.baseline.head);
862
+ if (!dispositionMatches) {
863
+ return unresolvedSemanticEvidence('repository-disposition-mismatch', evidence);
864
+ }
865
+ let runtimeFields = Object.freeze({});
866
+ if (input.runtimeFields !== undefined) {
867
+ try {
868
+ const detached = snapshotJsonValue(input.runtimeFields, 'semantic reconciliation runtime fields');
869
+ if (!isRecord(detached)) {
870
+ return unresolvedSemanticEvidence('inconsistent-runtime-evidence', evidence);
871
+ }
872
+ runtimeFields = detached;
873
+ }
874
+ catch {
875
+ return unresolvedSemanticEvidence('inconsistent-runtime-evidence', evidence);
876
+ }
877
+ }
878
+ const declaredRuntimeFields = Object.entries(outcome.fields)
879
+ .filter(([, authority]) => authority === 'runtime')
880
+ .map(([field]) => field);
881
+ if (Object.keys(runtimeFields).some((field) => !declaredRuntimeFields.includes(field))) {
882
+ return unresolvedSemanticEvidence('inconsistent-runtime-evidence', evidence);
883
+ }
884
+ const output = {};
885
+ defineEnumerableDataProperty(output, 'guard', candidate.guard);
886
+ for (const [field, authority] of Object.entries(outcome.fields)) {
887
+ let value;
888
+ if (authority === 'semantic') {
889
+ value = candidate[field];
890
+ }
891
+ else if (authority === 'presentation') {
892
+ value = finalText;
893
+ }
894
+ else if (authority === 'effect') {
895
+ value = field === 'latestCommit' ? receipt.commitOid : undefined;
896
+ if (value === undefined) {
897
+ return unresolvedSemanticEvidence('missing-effect-evidence', evidence);
898
+ }
899
+ }
900
+ else {
901
+ const runtimeValue = runtimeFields[field];
902
+ if (runtimeValue === undefined) {
903
+ return unresolvedSemanticEvidence('missing-runtime-evidence', evidence);
904
+ }
905
+ if (typeof runtimeValue !== 'string') {
906
+ return unresolvedSemanticEvidence('inconsistent-runtime-evidence', evidence);
907
+ }
908
+ value = runtimeValue;
909
+ }
910
+ defineEnumerableDataProperty(output, field, value);
911
+ }
912
+ const detachedOutput = snapshotJsonValue(output, 'reconciled semantic output');
913
+ return Object.freeze({
914
+ status: disposition === 'deferred' ? 'deferred' : 'resolved',
915
+ output: detachedOutput,
916
+ evidence,
917
+ });
918
+ }
919
+ function effectPendingQuestion(value, path) {
920
+ if (!isRecord(value))
921
+ throw new TypeError(`${path} must be an object`);
922
+ rejectUnknownKeys(value, ['questionId', 'asker', 'question', 'sourceItem'], path);
923
+ const questionId = requireNonEmptyString(value.questionId, `${path}.questionId`);
924
+ const question = requireNonEmptyString(value.question, `${path}.question`);
925
+ if (!isRecord(value.asker)) {
926
+ throw new TypeError(`${path}.asker must be an object`);
927
+ }
928
+ let asker;
929
+ if (value.asker.kind === 'captain') {
930
+ rejectUnknownKeys(value.asker, ['kind'], `${path}.asker`);
931
+ asker = { kind: 'captain' };
932
+ }
933
+ else if (value.asker.kind === 'role') {
934
+ rejectUnknownKeys(value.asker, ['kind', 'roleId'], `${path}.asker`);
935
+ asker = {
936
+ kind: 'role',
937
+ roleId: requireNonEmptyString(value.asker.roleId, `${path}.asker.roleId`),
938
+ };
939
+ }
940
+ else {
941
+ throw new TypeError(`${path}.asker.kind must be "captain" or "role"`);
942
+ }
943
+ return Object.freeze({
944
+ questionId,
945
+ asker: Object.freeze(asker),
946
+ question,
947
+ ...(own(value, 'sourceItem')
948
+ ? {
949
+ sourceItem: requireNonEmptyString(value.sourceItem, `${path}.sourceItem`),
950
+ }
951
+ : {}),
952
+ });
953
+ }
954
+ function effectBoundary(value, index) {
955
+ const path = `effect ledger boundaries[${index}]`;
956
+ if (!isRecord(value))
957
+ throw new TypeError(`${path} must be an object`);
958
+ rejectUnknownKeys(value, [
959
+ 'sequence',
960
+ 'boundaryId',
961
+ 'attemptId',
962
+ 'attemptNumber',
963
+ 'playbookId',
964
+ 'runtimeSessionId',
965
+ 'turnId',
966
+ 'callId',
967
+ 'roleId',
968
+ 'sourceStateId',
969
+ 'sourceOutcomeSchema',
970
+ 'dispositions',
971
+ 'canonicalWorktree',
972
+ 'baseline',
973
+ 'after',
974
+ 'physicalReceipt',
975
+ 'finalText',
976
+ 'semanticCandidate',
977
+ 'initialSemanticCandidate',
978
+ 'correctionBudget',
979
+ 'cohortId',
980
+ 'logicalOperationId',
981
+ ], path);
982
+ const sequence = effectInteger(value.sequence, `${path}.sequence`, 1);
983
+ if (sequence !== index + 1) {
984
+ throw new TypeError('effect ledger boundary sequence must be contiguous from one');
985
+ }
986
+ effectUuid(value.boundaryId, `${path}.boundaryId`);
987
+ effectUuid(value.attemptId, `${path}.attemptId`);
988
+ effectInteger(value.attemptNumber, `${path}.attemptNumber`, 1);
989
+ requireNonEmptyString(value.playbookId, `${path}.playbookId`);
990
+ effectUuid(value.runtimeSessionId, `${path}.runtimeSessionId`);
991
+ effectInteger(value.turnId, `${path}.turnId`, 1);
992
+ requireNonEmptyString(value.callId, `${path}.callId`);
993
+ requireNonEmptyString(value.roleId, `${path}.roleId`);
994
+ requireNonEmptyString(value.sourceStateId, `${path}.sourceStateId`);
995
+ if (!own(value, 'sourceOutcomeSchema')) {
996
+ throw new TypeError(`${path}.sourceOutcomeSchema is required`);
997
+ }
998
+ if (!Array.isArray(value.dispositions) || value.dispositions.length === 0) {
999
+ throw new TypeError(`${path}.dispositions must be a nonempty array`);
1000
+ }
1001
+ for (const [dispositionIndex, disposition] of value.dispositions.entries()) {
1002
+ if (typeof disposition !== 'string' || !EFFECT_DISPOSITIONS.has(disposition)) {
1003
+ throw new TypeError(`${path}.dispositions[${dispositionIndex}] is not supported`);
1004
+ }
1005
+ }
1006
+ if (new Set(value.dispositions).size !== value.dispositions.length) {
1007
+ throw new TypeError(`${path}.dispositions must not contain duplicates`);
1008
+ }
1009
+ if (!isRecord(value.canonicalWorktree)) {
1010
+ throw new TypeError(`${path}.canonicalWorktree must be an object`);
1011
+ }
1012
+ rejectUnknownKeys(value.canonicalWorktree, ['worktree', 'gitDir'], `${path}.canonicalWorktree`);
1013
+ const canonicalWorktree = {
1014
+ worktree: requireNonEmptyString(value.canonicalWorktree.worktree, `${path}.canonicalWorktree.worktree`),
1015
+ gitDir: requireNonEmptyString(value.canonicalWorktree.gitDir, `${path}.canonicalWorktree.gitDir`),
1016
+ };
1017
+ const baseline = effectObservation(value.baseline, `${path}.baseline`);
1018
+ assertObservationIdentity(baseline, canonicalWorktree, `${path}.baseline`);
1019
+ const after = own(value, 'after')
1020
+ ? effectObservation(value.after, `${path}.after`)
1021
+ : undefined;
1022
+ if (after !== undefined) {
1023
+ assertObservationIdentity(after, canonicalWorktree, `${path}.after`);
1024
+ }
1025
+ if (after !== undefined && !own(value, 'physicalReceipt')) {
1026
+ throw new TypeError(`${path}.after requires an atomic physicalReceipt`);
1027
+ }
1028
+ if (own(value, 'physicalReceipt')) {
1029
+ effectReceipt(value.physicalReceipt, `${path}.physicalReceipt`, baseline, after, true, value.dispositions);
1030
+ }
1031
+ if (own(value, 'finalText') && typeof value.finalText !== 'string') {
1032
+ throw new TypeError(`${path}.finalText must be a string`);
1033
+ }
1034
+ if (own(value, 'initialSemanticCandidate')) {
1035
+ if (!own(value, 'semanticCandidate') ||
1036
+ !isRecord(value.correctionBudget) ||
1037
+ value.correctionBudget.spent !== true ||
1038
+ jsonValuesEqual(value.initialSemanticCandidate, value.semanticCandidate)) {
1039
+ throw new TypeError(`${path}.initialSemanticCandidate requires a spent budget and a distinct current semanticCandidate`);
1040
+ }
1041
+ }
1042
+ if (own(value, 'cohortId')) {
1043
+ effectUuid(value.cohortId, `${path}.cohortId`);
1044
+ }
1045
+ if (!isRecord(value.correctionBudget)) {
1046
+ throw new TypeError(`${path}.correctionBudget must be an object`);
1047
+ }
1048
+ rejectUnknownKeys(value.correctionBudget, ['limit', 'spent'], `${path}.correctionBudget`);
1049
+ if (value.correctionBudget.limit !== 1 ||
1050
+ typeof value.correctionBudget.spent !== 'boolean') {
1051
+ throw new TypeError(`${path}.correctionBudget must contain exactly limit 1 and a boolean spent`);
1052
+ }
1053
+ if (own(value, 'logicalOperationId')) {
1054
+ effectUuid(value.logicalOperationId, `${path}.logicalOperationId`);
1055
+ }
1056
+ return value;
1057
+ }
1058
+ function effectLogicalOperation(value, index, boundaryById) {
1059
+ const path = `effect ledger logicalOperations[${index}]`;
1060
+ if (!isRecord(value))
1061
+ throw new TypeError(`${path} must be an object`);
1062
+ rejectUnknownKeys(value, [
1063
+ 'sequence',
1064
+ 'operationId',
1065
+ 'playbookId',
1066
+ 'runtimeSessionId',
1067
+ 'boundaryIds',
1068
+ 'originalBaseline',
1069
+ 'checkpoint',
1070
+ 'pendingQuestion',
1071
+ 'playerContinuation',
1072
+ 'checkpointRestorationEligible',
1073
+ 'logicalReceipt',
1074
+ ], path);
1075
+ const sequence = effectInteger(value.sequence, `${path}.sequence`, 1);
1076
+ if (sequence !== index + 1) {
1077
+ throw new TypeError('effect ledger logical-operation sequence must be contiguous from one');
1078
+ }
1079
+ const operationId = effectUuid(value.operationId, `${path}.operationId`);
1080
+ const playbookId = requireNonEmptyString(value.playbookId, `${path}.playbookId`);
1081
+ const runtimeSessionId = effectUuid(value.runtimeSessionId, `${path}.runtimeSessionId`);
1082
+ if (!Array.isArray(value.boundaryIds) || value.boundaryIds.length === 0) {
1083
+ throw new TypeError(`${path}.boundaryIds must be a nonempty array`);
1084
+ }
1085
+ const boundaries = value.boundaryIds.map((boundaryId, boundaryIndex) => {
1086
+ const id = effectUuid(boundaryId, `${path}.boundaryIds[${boundaryIndex}]`);
1087
+ const boundary = boundaryById.get(id);
1088
+ if (boundary === undefined) {
1089
+ throw new TypeError(`${path}.boundaryIds[${boundaryIndex}] is dangling`);
1090
+ }
1091
+ if (boundary.playbookId !== playbookId ||
1092
+ boundary.runtimeSessionId !== runtimeSessionId ||
1093
+ boundary.logicalOperationId !== operationId) {
1094
+ throw new TypeError(`${path}.boundaryIds[${boundaryIndex}] does not belong to this logical operation`);
1095
+ }
1096
+ return boundary;
1097
+ });
1098
+ if (new Set(value.boundaryIds).size !== value.boundaryIds.length) {
1099
+ throw new TypeError(`${path}.boundaryIds must not contain duplicates`);
1100
+ }
1101
+ if (boundaries.some((boundary, boundaryIndex) => boundaryIndex > 0 &&
1102
+ boundaries[boundaryIndex - 1].sequence >= boundary.sequence)) {
1103
+ throw new TypeError(`${path}.boundaryIds must follow physical boundary order`);
1104
+ }
1105
+ const originalBaseline = effectObservation(value.originalBaseline, `${path}.originalBaseline`);
1106
+ if (!jsonValuesEqual(originalBaseline, boundaries[0].baseline)) {
1107
+ throw new TypeError(`${path}.originalBaseline must equal its first boundary baseline`);
1108
+ }
1109
+ for (const [boundaryIndex, boundary] of boundaries.entries()) {
1110
+ assertObservationIdentity(boundary.baseline, originalBaseline, `${path}.boundaryIds[${boundaryIndex}] baseline`);
1111
+ if (boundaryIndex === 0)
1112
+ continue;
1113
+ const previous = boundaries[boundaryIndex - 1];
1114
+ if (previous.physicalReceipt === undefined ||
1115
+ previous.after === undefined ||
1116
+ !jsonValuesEqual(boundary.baseline, previous.after)) {
1117
+ throw new TypeError(`${path}.boundaryIds must form one completed checkpoint chain`);
1118
+ }
1119
+ }
1120
+ const checkpoint = own(value, 'checkpoint')
1121
+ ? effectObservation(value.checkpoint, `${path}.checkpoint`)
1122
+ : undefined;
1123
+ const latestAfter = boundaries.at(-1).after;
1124
+ if (checkpoint !== undefined) {
1125
+ assertObservationIdentity(checkpoint, originalBaseline, `${path}.checkpoint`);
1126
+ if (latestAfter === undefined ||
1127
+ !jsonValuesEqual(checkpoint, latestAfter)) {
1128
+ throw new TypeError(`${path}.checkpoint must equal its latest boundary after`);
1129
+ }
1130
+ }
1131
+ const pendingQuestion = own(value, 'pendingQuestion')
1132
+ ? effectPendingQuestion(value.pendingQuestion, `${path}.pendingQuestion`)
1133
+ : undefined;
1134
+ const bindingCount = [
1135
+ own(value, 'checkpoint'),
1136
+ own(value, 'pendingQuestion'),
1137
+ own(value, 'playerContinuation'),
1138
+ ].filter(Boolean).length;
1139
+ if (bindingCount !== 0 && bindingCount !== 3) {
1140
+ throw new TypeError(`${path} checkpoint, pendingQuestion, and playerContinuation must be all present or all absent`);
1141
+ }
1142
+ if (typeof value.checkpointRestorationEligible !== 'boolean') {
1143
+ throw new TypeError(`${path}.checkpointRestorationEligible must be boolean`);
1144
+ }
1145
+ if (value.checkpointRestorationEligible &&
1146
+ (checkpoint === undefined ||
1147
+ pendingQuestion === undefined ||
1148
+ !own(value, 'playerContinuation'))) {
1149
+ throw new TypeError(`${path}.checkpointRestorationEligible requires checkpoint, pendingQuestion, and playerContinuation`);
1150
+ }
1151
+ if (own(value, 'logicalReceipt')) {
1152
+ if (boundaries.some((boundary) => boundary.physicalReceipt === undefined)) {
1153
+ throw new TypeError(`${path}.logicalReceipt requires every physical boundary receipt`);
1154
+ }
1155
+ effectReceipt(value.logicalReceipt, `${path}.logicalReceipt`, originalBaseline, latestAfter, true, boundaries.at(-1).dispositions);
1156
+ }
1157
+ return value;
1158
+ }
1159
+ /** Return the canonical empty host-owned effect-ledger mirror. */
1160
+ export function emptyPlaybookEffectLedger() {
1161
+ return Object.freeze({
1162
+ schemaVersion: 1,
1163
+ revision: 0,
1164
+ boundaries: Object.freeze([]),
1165
+ logicalOperations: Object.freeze([]),
1166
+ });
1167
+ }
1168
+ /** Validate, detach, and recursively freeze one effect-ledger mirror. */
1169
+ export function assertPlaybookEffectLedger(value, path = 'effect ledger') {
1170
+ const detached = snapshotJsonValue(value, path);
1171
+ if (!isRecord(detached))
1172
+ throw new TypeError(`${path} must be an object`);
1173
+ rejectUnknownKeys(detached, ['schemaVersion', 'revision', 'boundaries', 'logicalOperations'], path);
1174
+ if (detached.schemaVersion !== 1) {
1175
+ throw new TypeError(`${path}.schemaVersion must equal 1`);
1176
+ }
1177
+ const revision = effectInteger(detached.revision, `${path}.revision`);
1178
+ if (!Array.isArray(detached.boundaries)) {
1179
+ throw new TypeError(`${path}.boundaries must be an array`);
1180
+ }
1181
+ if (!Array.isArray(detached.logicalOperations)) {
1182
+ throw new TypeError(`${path}.logicalOperations must be an array`);
1183
+ }
1184
+ const isEmpty = detached.boundaries.length === 0 && detached.logicalOperations.length === 0;
1185
+ if ((revision === 0) !== isEmpty) {
1186
+ throw new TypeError(`${path}.revision must be zero if and only if both ordered ledgers are empty`);
1187
+ }
1188
+ const boundaries = detached.boundaries.map(effectBoundary);
1189
+ const boundaryById = new Map(boundaries.map((boundary) => [boundary.boundaryId, boundary]));
1190
+ if (boundaryById.size !== boundaries.length) {
1191
+ throw new TypeError(`${path}.boundaries must not reuse a boundaryId`);
1192
+ }
1193
+ const cohorts = new Map();
1194
+ for (const boundary of boundaries) {
1195
+ if (boundary.cohortId === undefined)
1196
+ continue;
1197
+ const members = cohorts.get(boundary.cohortId) ?? [];
1198
+ members.push(boundary);
1199
+ cohorts.set(boundary.cohortId, members);
1200
+ }
1201
+ for (const [cohortId, members] of cohorts) {
1202
+ const first = members[0];
1203
+ const commonKeys = [
1204
+ 'attemptId',
1205
+ 'attemptNumber',
1206
+ 'playbookId',
1207
+ 'runtimeSessionId',
1208
+ 'turnId',
1209
+ 'canonicalWorktree',
1210
+ 'baseline',
1211
+ ];
1212
+ if (members.length < 2 ||
1213
+ members.some((boundary, index) => boundary.sequence !== first.sequence + index ||
1214
+ !boundary.dispositions.every((disposition) => disposition === 'unchanged') ||
1215
+ !commonKeys.every((key) => jsonValuesEqual(boundary[key], first[key]))) ||
1216
+ new Set(members.map((boundary) => boundary.roleId)).size !==
1217
+ members.length ||
1218
+ new Set(members.map((boundary) => boundary.physicalReceipt === undefined ? 'started' : 'complete')).size !== 1 ||
1219
+ (first.physicalReceipt !== undefined &&
1220
+ members.some((boundary) => !jsonValuesEqual(boundary.physicalReceipt, first.physicalReceipt) ||
1221
+ !jsonValuesEqual(boundary.after, first.after)))) {
1222
+ throw new TypeError(`effect ledger cohort ${JSON.stringify(cohortId)} is not one contiguous all-unchanged boundary group`);
1223
+ }
1224
+ }
1225
+ const logicalOperations = detached.logicalOperations.map((operation, index) => effectLogicalOperation(operation, index, boundaryById));
1226
+ const operationById = new Map(logicalOperations.map((operation) => [operation.operationId, operation]));
1227
+ if (operationById.size !== logicalOperations.length) {
1228
+ throw new TypeError(`${path}.logicalOperations must not reuse an operationId`);
1229
+ }
1230
+ for (const [index, operation] of logicalOperations.entries()) {
1231
+ const firstBoundary = boundaryById.get(operation.boundaryIds[0]);
1232
+ if (index > 0 &&
1233
+ boundaryById.get(logicalOperations[index - 1].boundaryIds[0])
1234
+ .sequence >= firstBoundary.sequence) {
1235
+ throw new TypeError(`${path}.logicalOperations must follow their first physical boundary order`);
1236
+ }
1237
+ }
1238
+ for (const boundary of boundaries) {
1239
+ if (boundary.logicalOperationId !== undefined &&
1240
+ !operationById.has(boundary.logicalOperationId)) {
1241
+ throw new TypeError(`${path}.boundaries[${boundary.sequence - 1}].logicalOperationId is dangling`);
1242
+ }
1243
+ if (boundary.logicalOperationId !== undefined &&
1244
+ !operationById
1245
+ .get(boundary.logicalOperationId)
1246
+ .boundaryIds.includes(boundary.boundaryId)) {
1247
+ throw new TypeError(`${path}.boundaries[${boundary.sequence - 1}].logicalOperationId has no reciprocal operation reference`);
1248
+ }
1249
+ }
1250
+ return detached;
1251
+ }
1252
+ function optionalEvidenceExtends(baseline, current, keys) {
1253
+ return keys.every((key) => !own(baseline, key) ||
1254
+ (own(current, key) &&
1255
+ jsonValuesEqual(baseline[key], current[key])));
1256
+ }
1257
+ /** Whether current preserves every durable fact in baseline and only extends it. */
1258
+ export function isPlaybookEffectLedgerMonotonicExtension(baselineValue, currentValue) {
1259
+ let baseline;
1260
+ let current;
1261
+ try {
1262
+ baseline = assertPlaybookEffectLedger(baselineValue, 'baseline effect ledger');
1263
+ current = assertPlaybookEffectLedger(currentValue, 'current effect ledger');
1264
+ }
1265
+ catch {
1266
+ return false;
1267
+ }
1268
+ if (current.revision < baseline.revision ||
1269
+ current.boundaries.length < baseline.boundaries.length ||
1270
+ current.logicalOperations.length < baseline.logicalOperations.length) {
1271
+ return false;
1272
+ }
1273
+ if (current.revision === baseline.revision &&
1274
+ !jsonValuesEqual(baseline, current)) {
1275
+ return false;
1276
+ }
1277
+ const boundaryStableKeys = [
1278
+ 'sequence',
1279
+ 'boundaryId',
1280
+ 'attemptId',
1281
+ 'attemptNumber',
1282
+ 'playbookId',
1283
+ 'runtimeSessionId',
1284
+ 'turnId',
1285
+ 'callId',
1286
+ 'roleId',
1287
+ 'sourceStateId',
1288
+ 'sourceOutcomeSchema',
1289
+ 'dispositions',
1290
+ 'canonicalWorktree',
1291
+ 'baseline',
1292
+ 'cohortId',
1293
+ ];
1294
+ const boundaryEvidenceKeys = [
1295
+ 'after',
1296
+ 'physicalReceipt',
1297
+ 'finalText',
1298
+ 'initialSemanticCandidate',
1299
+ ];
1300
+ for (const [index, prior] of baseline.boundaries.entries()) {
1301
+ const next = current.boundaries[index];
1302
+ if (!boundaryStableKeys.every((key) => jsonValuesEqual(prior[key], next[key])) ||
1303
+ !optionalEvidenceExtends(prior, next, boundaryEvidenceKeys) ||
1304
+ !semanticCandidateExtends(prior, next) ||
1305
+ (prior.logicalOperationId !== undefined &&
1306
+ prior.logicalOperationId !== next.logicalOperationId) ||
1307
+ (prior.correctionBudget.spent && !next.correctionBudget.spent)) {
1308
+ return false;
1309
+ }
1310
+ }
1311
+ const operationStableKeys = [
1312
+ 'sequence',
1313
+ 'operationId',
1314
+ 'playbookId',
1315
+ 'runtimeSessionId',
1316
+ 'originalBaseline',
1317
+ ];
1318
+ // The current deferred binding is replaceable across authored repeated
1319
+ // questions; only a completed logical receipt becomes immutable evidence.
1320
+ const operationEvidenceKeys = ['logicalReceipt'];
1321
+ for (const [index, prior] of baseline.logicalOperations.entries()) {
1322
+ const next = current.logicalOperations[index];
1323
+ if (!operationStableKeys.every((key) => jsonValuesEqual(prior[key], next[key])) ||
1324
+ next.boundaryIds.length < prior.boundaryIds.length ||
1325
+ !prior.boundaryIds.every((boundaryId, boundaryIndex) => boundaryId === next.boundaryIds[boundaryIndex]) ||
1326
+ !optionalEvidenceExtends(prior, next, operationEvidenceKeys)) {
1327
+ return false;
1328
+ }
1329
+ }
1330
+ return true;
1331
+ }
1332
+ function semanticCandidateExtends(prior, next) {
1333
+ if (prior.semanticCandidate === undefined) {
1334
+ return (!prior.correctionBudget.spent ||
1335
+ next.initialSemanticCandidate === undefined);
1336
+ }
1337
+ if (next.semanticCandidate !== undefined &&
1338
+ jsonValuesEqual(prior.semanticCandidate, next.semanticCandidate)) {
1339
+ return (prior.initialSemanticCandidate !== undefined ||
1340
+ next.initialSemanticCandidate === undefined);
1341
+ }
1342
+ return (prior.initialSemanticCandidate === undefined &&
1343
+ next.correctionBudget.spent &&
1344
+ next.initialSemanticCandidate !== undefined &&
1345
+ jsonValuesEqual(prior.semanticCandidate, next.initialSemanticCandidate));
1346
+ }
553
1347
  function snapshotSuspendedCall(value, path = 'runtime snapshot suspendedCall') {
554
1348
  const captured = snapshotJsonValue(value, path);
555
1349
  if (!isRecord(captured)) {
556
1350
  throw new TypeError(`${path} must be an object`);
557
1351
  }
558
- rejectUnknownKeys(captured, ['callId', 'stateId', 'playbookId', 'text', 'childSessionId', 'turnId'], path);
1352
+ rejectUnknownKeys(captured, [
1353
+ 'callId',
1354
+ 'stateId',
1355
+ 'playbookId',
1356
+ 'text',
1357
+ 'childSessionId',
1358
+ 'turnId',
1359
+ 'effectBoundaryPrefixSequence',
1360
+ ], path);
559
1361
  const call = {
560
1362
  callId: requireNonEmptyString(captured.callId, `${path}.callId`),
561
1363
  stateId: requireNonEmptyString(captured.stateId, `${path}.stateId`),
@@ -570,12 +1372,18 @@ function snapshotSuspendedCall(value, path = 'runtime snapshot suspendedCall') {
570
1372
  }
571
1373
  call.turnId = captured.turnId;
572
1374
  }
1375
+ if (own(captured, 'effectBoundaryPrefixSequence')) {
1376
+ call.effectBoundaryPrefixSequence =
1377
+ captured.effectBoundaryPrefixSequence === null
1378
+ ? null
1379
+ : effectInteger(captured.effectBoundaryPrefixSequence, `${path}.effectBoundaryPrefixSequence`);
1380
+ }
573
1381
  return Object.freeze(call);
574
1382
  }
575
- // DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
576
- // schema-3 runtime snapshot before restore touches any state. A suspended
1383
+ // DR-014 §1 / DR-031 §5 / DR-032 / DR-040: validate and detach a host-supplied
1384
+ // schema-4 runtime snapshot before restore touches any state. A suspended
577
1385
  // call is rejected unless the restore path explicitly promises to seed and
578
- // claim it; older schemas are rejected rather than guessing role identity.
1386
+ // claim it; older schemas are rejected by this public restore boundary.
579
1387
  export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options = {}) {
580
1388
  const snapshot = snapshotJsonValue(value, 'runtime snapshot');
581
1389
  if (!isRecord(snapshot)) {
@@ -591,8 +1399,8 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
591
1399
  throw new TypeError('runtime snapshot validation options.allowSuspendedCall must be boolean');
592
1400
  }
593
1401
  const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
594
- if (snapshot.schemaVersion !== 3) {
595
- throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
1402
+ if (snapshot.schemaVersion !== 4) {
1403
+ throw new TypeError(`runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 4)`);
596
1404
  }
597
1405
  rejectUnknownKeys(snapshot, [
598
1406
  'schemaVersion',
@@ -602,6 +1410,10 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
602
1410
  'sequences',
603
1411
  'state',
604
1412
  'pendingBossQuestions',
1413
+ 'effectLedger',
1414
+ 'retainedEffectSourceSessionId',
1415
+ 'retainedEffectReconciliation',
1416
+ 'failedEffectAttempt',
605
1417
  'suspendedCall',
606
1418
  ], 'runtime snapshot');
607
1419
  let suspendedCall;
@@ -706,6 +1518,66 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
706
1518
  };
707
1519
  return Object.freeze(question);
708
1520
  });
1521
+ const effectLedger = assertPlaybookEffectLedger(snapshot.effectLedger, 'runtime snapshot effectLedger');
1522
+ const retainedEffectSourceSessionId = own(snapshot, 'retainedEffectSourceSessionId')
1523
+ ? effectUuid(snapshot.retainedEffectSourceSessionId, 'runtime snapshot retainedEffectSourceSessionId')
1524
+ : undefined;
1525
+ let retainedEffectReconciliation;
1526
+ if (own(snapshot, 'retainedEffectReconciliation')) {
1527
+ if (!isRecord(snapshot.retainedEffectReconciliation)) {
1528
+ throw new TypeError('runtime snapshot retainedEffectReconciliation must be an object');
1529
+ }
1530
+ rejectUnknownKeys(snapshot.retainedEffectReconciliation, ['sourceSessionId', 'checkpoint'], 'runtime snapshot retainedEffectReconciliation');
1531
+ const sourceSessionId = effectUuid(snapshot.retainedEffectReconciliation.sourceSessionId, 'runtime snapshot retainedEffectReconciliation.sourceSessionId');
1532
+ const checkpoint = assertPlaybookEffectLedger(snapshot.retainedEffectReconciliation.checkpoint, 'runtime snapshot retainedEffectReconciliation.checkpoint');
1533
+ if (checkpoint.boundaries.some(({ physicalReceipt }) => physicalReceipt === undefined)) {
1534
+ throw new TypeError('runtime snapshot retainedEffectReconciliation.checkpoint contains an incomplete physical boundary');
1535
+ }
1536
+ if (!isPlaybookEffectLedgerMonotonicExtension(checkpoint, effectLedger)) {
1537
+ throw new TypeError('runtime snapshot retainedEffectReconciliation.checkpoint is not a monotonic prefix of effectLedger');
1538
+ }
1539
+ if (retainedEffectSourceSessionId === undefined ||
1540
+ retainedEffectSourceSessionId !== sourceSessionId) {
1541
+ throw new TypeError('runtime snapshot retainedEffectReconciliation.sourceSessionId must equal retainedEffectSourceSessionId');
1542
+ }
1543
+ retainedEffectReconciliation = Object.freeze({
1544
+ sourceSessionId,
1545
+ checkpoint,
1546
+ });
1547
+ }
1548
+ if (typeof suspendedCall?.effectBoundaryPrefixSequence === 'number' &&
1549
+ suspendedCall.effectBoundaryPrefixSequence >
1550
+ (effectLedger.boundaries.at(-1)?.sequence ?? 0)) {
1551
+ throw new TypeError('runtime snapshot suspendedCall.effectBoundaryPrefixSequence exceeds the effect ledger');
1552
+ }
1553
+ let failedEffectAttempt;
1554
+ if (own(snapshot, 'failedEffectAttempt')) {
1555
+ if (state.stateId !== 'failed') {
1556
+ throw new TypeError('runtime snapshot failedEffectAttempt requires the failed state');
1557
+ }
1558
+ if (!isRecord(snapshot.failedEffectAttempt)) {
1559
+ throw new TypeError('runtime snapshot failedEffectAttempt must be an object');
1560
+ }
1561
+ rejectUnknownKeys(snapshot.failedEffectAttempt, ['boundaryPrefix', 'attemptId'], 'runtime snapshot failedEffectAttempt');
1562
+ const boundaryPrefix = effectInteger(snapshot.failedEffectAttempt.boundaryPrefix, 'runtime snapshot failedEffectAttempt.boundaryPrefix');
1563
+ const lastBoundarySequence = effectLedger.boundaries.at(-1)?.sequence ?? 0;
1564
+ if (boundaryPrefix > lastBoundarySequence) {
1565
+ throw new TypeError('runtime snapshot failedEffectAttempt.boundaryPrefix exceeds the effect ledger');
1566
+ }
1567
+ const attemptId = snapshot.failedEffectAttempt.attemptId === null
1568
+ ? null
1569
+ : effectUuid(snapshot.failedEffectAttempt.attemptId, 'runtime snapshot failedEffectAttempt.attemptId');
1570
+ const causalBoundaries = effectLedger.boundaries.filter(({ sequence }) => sequence > boundaryPrefix);
1571
+ if (attemptId === null && causalBoundaries.length !== 0) {
1572
+ throw new TypeError('runtime snapshot failedEffectAttempt null attemptId requires an empty causal suffix');
1573
+ }
1574
+ if (attemptId !== null &&
1575
+ (causalBoundaries.length === 0 ||
1576
+ causalBoundaries.some(({ attemptId: boundaryAttemptId }) => boundaryAttemptId !== attemptId))) {
1577
+ throw new TypeError('runtime snapshot failedEffectAttempt does not match its causal ledger suffix');
1578
+ }
1579
+ failedEffectAttempt = Object.freeze({ boundaryPrefix, attemptId });
1580
+ }
709
1581
  const fields = {
710
1582
  playbookId,
711
1583
  machine,
@@ -713,9 +1585,19 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
713
1585
  sequences: Object.freeze(sequences),
714
1586
  state,
715
1587
  pendingBossQuestions: Object.freeze(pendingBossQuestions),
1588
+ effectLedger,
1589
+ ...(retainedEffectSourceSessionId === undefined
1590
+ ? {}
1591
+ : { retainedEffectSourceSessionId }),
1592
+ ...(retainedEffectReconciliation === undefined
1593
+ ? {}
1594
+ : { retainedEffectReconciliation }),
1595
+ ...(failedEffectAttempt === undefined
1596
+ ? {}
1597
+ : { failedEffectAttempt }),
716
1598
  };
717
1599
  return Object.freeze({
718
- schemaVersion: 3,
1600
+ schemaVersion: 4,
719
1601
  ...fields,
720
1602
  ...(suspendedCall === undefined ? {} : { suspendedCall }),
721
1603
  });
@@ -948,22 +1830,26 @@ export function createNestedPlaybookBridge(options) {
948
1830
  let disposed = false;
949
1831
  const usedCallIds = new Set();
950
1832
  const pendingListeners = new Set();
951
- const reportBackgroundError = (error) => {
1833
+ const reportBackgroundError = (error, aborts) => {
1834
+ if (aborts?.isAbortReason(error))
1835
+ return;
952
1836
  try {
953
- options.onBackgroundError?.(error);
1837
+ options.onBackgroundError?.(error, aborts);
954
1838
  }
955
1839
  catch {
956
1840
  // Background observers are a terminal sink and cannot own cleanup.
957
1841
  }
958
1842
  };
959
- const reportControlPlaneError = (error) => {
1843
+ const reportControlPlaneError = (error, aborts) => {
1844
+ if (aborts?.isAbortReason(error))
1845
+ return;
960
1846
  try {
961
- options.onControlPlaneError?.(error);
1847
+ options.onControlPlaneError?.(error, aborts);
962
1848
  }
963
1849
  catch (callbackError) {
964
1850
  // Observability callbacks must never prevent terminal cleanup of the
965
1851
  // invocation they are observing.
966
- reportBackgroundError(callbackError);
1852
+ reportBackgroundError(callbackError, aborts);
967
1853
  }
968
1854
  };
969
1855
  const rejectControlPlane = (error) => {
@@ -1003,27 +1889,37 @@ export function createNestedPlaybookBridge(options) {
1003
1889
  if (current === active)
1004
1890
  current = undefined;
1005
1891
  };
1006
- const emitFinish = async (active, result) => {
1892
+ // A failure causally identical to an applicable abort reason is the
1893
+ // cancellation's own evidence, never a control-plane error.
1894
+ const reportNonAbortControlError = (error, aborts) => {
1895
+ reportControlPlaneError(error, aborts);
1896
+ };
1897
+ const emitFinish = async (active, result, aborts) => {
1007
1898
  await options.emitFinished({
1008
1899
  callId: active.callId,
1009
1900
  stateId: active.input.stateId,
1010
1901
  playbookId: active.input.playbookId,
1011
1902
  text: active.input.text,
1012
1903
  result,
1013
- });
1014
- await options.drain();
1904
+ }, aborts);
1905
+ await options.drain(aborts);
1015
1906
  };
1016
1907
  const finishImmediate = async (active, result, controlError, resultAfterAbortCleanup) => {
1908
+ const aborts = active.aborts;
1017
1909
  let effectiveResult = result;
1018
1910
  let cleanupControlError;
1019
1911
  if (result.status === 'aborted' || active.signal.aborted) {
1020
1912
  try {
1021
- await drainPlaybookAbortCleanups(active.signal);
1913
+ await drainPlaybookAbortCleanups(active.signal, aborts);
1022
1914
  }
1023
1915
  catch (error) {
1024
- cleanupControlError = error;
1025
- reportControlPlaneError(error);
1026
- effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, error, false);
1916
+ // A cleanup rejection identical to an applicable abort reason is
1917
+ // the cancellation's own evidence — no latch, no result override.
1918
+ if (!aborts.isAbortReason(error)) {
1919
+ cleanupControlError = error;
1920
+ reportControlPlaneError(error, aborts);
1921
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, error, false);
1922
+ }
1027
1923
  }
1028
1924
  if (cleanupControlError === undefined && resultAfterAbortCleanup) {
1029
1925
  effectiveResult = resultAfterAbortCleanup();
@@ -1031,10 +1927,10 @@ export function createNestedPlaybookBridge(options) {
1031
1927
  }
1032
1928
  let finishControlError;
1033
1929
  try {
1034
- await emitFinish(active, effectiveResult);
1930
+ await emitFinish(active, effectiveResult, aborts);
1035
1931
  }
1036
1932
  catch (error) {
1037
- reportControlPlaneError(error);
1933
+ reportNonAbortControlError(error, aborts);
1038
1934
  finishControlError = error;
1039
1935
  }
1040
1936
  finally {
@@ -1042,6 +1938,7 @@ export function createNestedPlaybookBridge(options) {
1042
1938
  // emission fails, do not leave a permanently unresumable call in the
1043
1939
  // bridge and prevent disposal or a later invocation.
1044
1940
  clear(active);
1941
+ options.bindActorSettlement?.(aborts);
1045
1942
  }
1046
1943
  if (controlError !== undefined)
1047
1944
  throw controlError;
@@ -1051,7 +1948,7 @@ export function createNestedPlaybookBridge(options) {
1051
1948
  throw finishControlError;
1052
1949
  return outputOrThrow(effectiveResult);
1053
1950
  };
1054
- const settlePending = async (active, result, controlError) => {
1951
+ const settlePending = async (active, result, controlError, aborts = active.aborts) => {
1055
1952
  if (active.phase === 'settling' && active.settlement) {
1056
1953
  await active.settlement;
1057
1954
  return;
@@ -1065,32 +1962,40 @@ export function createNestedPlaybookBridge(options) {
1065
1962
  let cleanupControlError;
1066
1963
  if (result.status === 'aborted' || active.signal.aborted) {
1067
1964
  if (result.status !== 'aborted' && active.signal.aborted) {
1068
- effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ??
1069
- new Error('Nested playbook invocation aborted'), true);
1965
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason, true);
1070
1966
  }
1071
1967
  try {
1072
- await drainPlaybookAbortCleanups(active.signal);
1968
+ await drainPlaybookAbortCleanups(active.signal, aborts);
1073
1969
  }
1074
1970
  catch (cleanupError) {
1075
- cleanupControlError = cleanupError;
1076
- reportControlPlaneError(cleanupError);
1077
- effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, cleanupError, false);
1971
+ // A cleanup rejection identical to an applicable abort reason is
1972
+ // the cancellation's own evidence — no latch, no result override.
1973
+ if (!aborts.isAbortReason(cleanupError)) {
1974
+ cleanupControlError = cleanupError;
1975
+ reportControlPlaneError(cleanupError, aborts);
1976
+ effectiveResult = resultFromThrown(active.input.playbookId, active.childSessionId, cleanupError, false);
1977
+ }
1078
1978
  }
1079
1979
  }
1080
1980
  try {
1081
- await emitFinish(active, effectiveResult);
1981
+ await emitFinish(active, effectiveResult, aborts);
1082
1982
  }
1083
1983
  catch (error) {
1084
1984
  // A finish event is the durable return boundary. If it cannot be
1085
1985
  // emitted and drained, the child result must not remain retryable:
1086
1986
  // clear the identity and fail the promise actor so its parent takes
1087
- // onError instead of observing a phantom suspended child.
1088
- reportControlPlaneError(error);
1987
+ // onError instead of observing a phantom suspended child. A finish
1988
+ // rejection that is an applicable abort reason — the invocation's
1989
+ // or the settling resume's — evidences cancellation, not a
1990
+ // control-plane failure (slc/link.md §Abort).
1991
+ reportControlPlaneError(error, aborts);
1089
1992
  clear(active);
1993
+ options.bindActorSettlement?.(aborts);
1090
1994
  active.deferred.reject(error);
1091
1995
  throw error;
1092
1996
  }
1093
1997
  clear(active);
1998
+ options.bindActorSettlement?.(aborts);
1094
1999
  if (controlError !== undefined) {
1095
2000
  active.deferred.reject(controlError);
1096
2001
  }
@@ -1126,6 +2031,7 @@ export function createNestedPlaybookBridge(options) {
1126
2031
  active.restoreRolledBack = true;
1127
2032
  clear(active);
1128
2033
  usedCallIds.delete(active.callId);
2034
+ options.bindActorSettlement?.(active.aborts);
1129
2035
  active.deferred.reject(error);
1130
2036
  return active;
1131
2037
  };
@@ -1136,9 +2042,9 @@ export function createNestedPlaybookBridge(options) {
1136
2042
  const abortListener = () => {
1137
2043
  if (active.phase !== 'suspended')
1138
2044
  return;
1139
- const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason ?? new Error('Nested playbook invocation aborted'), true);
2045
+ const result = resultFromThrown(active.input.playbookId, active.childSessionId, active.signal.reason, true);
1140
2046
  void settlePending(active, result).catch((error) => {
1141
- reportBackgroundError(error);
2047
+ reportBackgroundError(error, active.aborts);
1142
2048
  });
1143
2049
  };
1144
2050
  active.abortListener = abortListener;
@@ -1152,7 +2058,7 @@ export function createNestedPlaybookBridge(options) {
1152
2058
  listener(pendingCall);
1153
2059
  }
1154
2060
  catch (error) {
1155
- reportBackgroundError(error);
2061
+ reportBackgroundError(error, active.aborts);
1156
2062
  }
1157
2063
  }
1158
2064
  if (active.signal.aborted)
@@ -1222,8 +2128,11 @@ export function createNestedPlaybookBridge(options) {
1222
2128
  }
1223
2129
  const controller = new AbortController();
1224
2130
  let callSignal;
2131
+ let callAborts;
1225
2132
  try {
1226
- callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
2133
+ const boundarySignal = options.getBoundarySignal?.();
2134
+ callSignal = combineAbortSignals(invocationSignal, boundarySignal, controller.signal);
2135
+ callAborts = createAbortReasonClassifier(invocationSignal, boundarySignal, controller.signal);
1227
2136
  }
1228
2137
  catch (error) {
1229
2138
  failRestoreMode(mode, error);
@@ -1239,6 +2148,7 @@ export function createNestedPlaybookBridge(options) {
1239
2148
  finished: deferred(),
1240
2149
  controller,
1241
2150
  signal: callSignal,
2151
+ aborts: callAborts,
1242
2152
  phase: 'restoring',
1243
2153
  childSessionId: seed.childSessionId,
1244
2154
  };
@@ -1253,8 +2163,7 @@ export function createNestedPlaybookBridge(options) {
1253
2163
  active.phase !== 'restoring') {
1254
2164
  return;
1255
2165
  }
1256
- rollbackRestoredCall(mode, active.signal.reason ??
1257
- new Error('Restored nested playbook invocation aborted'));
2166
+ rollbackRestoredCall(mode, active.signal.reason);
1258
2167
  };
1259
2168
  active.abortListener = restoreAbortListener;
1260
2169
  active.signal.addEventListener('abort', restoreAbortListener, {
@@ -1291,8 +2200,11 @@ export function createNestedPlaybookBridge(options) {
1291
2200
  usedCallIds.add(callId);
1292
2201
  const controller = new AbortController();
1293
2202
  let callSignal;
2203
+ let callAborts;
1294
2204
  try {
1295
- callSignal = combineAbortSignals(invocationSignal, options.getBoundarySignal?.(), controller.signal);
2205
+ const boundarySignal = options.getBoundarySignal?.();
2206
+ callSignal = combineAbortSignals(invocationSignal, boundarySignal, controller.signal);
2207
+ callAborts = createAbortReasonClassifier(invocationSignal, boundarySignal, controller.signal);
1296
2208
  }
1297
2209
  catch (error) {
1298
2210
  return rejectControlPlane(error);
@@ -1304,6 +2216,7 @@ export function createNestedPlaybookBridge(options) {
1304
2216
  finished: deferred(),
1305
2217
  controller,
1306
2218
  signal: callSignal,
2219
+ aborts: callAborts,
1307
2220
  phase: 'starting',
1308
2221
  };
1309
2222
  current = active;
@@ -1312,26 +2225,39 @@ export function createNestedPlaybookBridge(options) {
1312
2225
  // for their entering state. Yield through the runtime's global queue so
1313
2226
  // that transition/status telemetry is enqueued before call.started.
1314
2227
  try {
1315
- await options.drain();
2228
+ await options.drain(active.aborts);
1316
2229
  }
1317
2230
  catch (error) {
1318
- reportControlPlaneError(error);
2231
+ reportNonAbortControlError(error, active.aborts);
1319
2232
  clear(active);
1320
2233
  throw error;
1321
2234
  }
1322
2235
  try {
1323
- await options.emitStarted({ callId, ...normalizedInput });
2236
+ await options.emitStarted({ callId, ...normalizedInput }, active.aborts);
1324
2237
  }
1325
2238
  catch (error) {
1326
- reportControlPlaneError(error);
1327
- return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, false), error);
2239
+ // A start-sink rejection identical to the applicable abort
2240
+ // reason is the cancellation itself: the pair finishes
2241
+ // `aborted` and nothing is reported (slc/link.md §Abort).
2242
+ const controlError = active.aborts.isAbortReason(error)
2243
+ ? undefined
2244
+ : error;
2245
+ if (controlError !== undefined) {
2246
+ reportControlPlaneError(controlError, active.aborts);
2247
+ }
2248
+ return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined), controlError);
1328
2249
  }
1329
2250
  try {
1330
- await options.drain();
2251
+ await options.drain(active.aborts);
1331
2252
  }
1332
2253
  catch (error) {
1333
- reportControlPlaneError(error);
1334
- return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, false), error);
2254
+ const controlError = active.aborts.isAbortReason(error)
2255
+ ? undefined
2256
+ : error;
2257
+ if (controlError !== undefined) {
2258
+ reportControlPlaneError(controlError, active.aborts);
2259
+ }
2260
+ return await finishImmediate(active, resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined), controlError);
1335
2261
  }
1336
2262
  const request = {
1337
2263
  callId,
@@ -1354,7 +2280,7 @@ export function createNestedPlaybookBridge(options) {
1354
2280
  throw error;
1355
2281
  });
1356
2282
  const openingCleanup = starting.then(() => undefined, (error) => {
1357
- if (isAbortReason(error, active.signal))
2283
+ if (active.aborts.isAbortReason(error))
1358
2284
  return;
1359
2285
  throw error;
1360
2286
  });
@@ -1371,11 +2297,14 @@ export function createNestedPlaybookBridge(options) {
1371
2297
  rawStart = await withAbort(starting, active.signal);
1372
2298
  }
1373
2299
  catch (error) {
1374
- const controlError = active.signal.aborted ? undefined : error;
1375
- if (controlError !== undefined)
1376
- reportControlPlaneError(controlError);
1377
- const result = resultFromThrown(normalizedInput.playbookId, undefined, error, active.signal.aborted);
1378
- return await finishImmediate(active, result, controlError, active.signal.aborted
2300
+ const controlError = active.aborts.isAbortReason(error)
2301
+ ? undefined
2302
+ : error;
2303
+ if (controlError !== undefined) {
2304
+ reportControlPlaneError(controlError, active.aborts);
2305
+ }
2306
+ const result = resultFromThrown(normalizedInput.playbookId, undefined, error, controlError === undefined && active.signal.aborted);
2307
+ return await finishImmediate(active, result, controlError, controlError === undefined && active.signal.aborted
1379
2308
  ? () => resultFromThrown(normalizedInput.playbookId, startSettled
1380
2309
  ? assignedChildSessionId(observedStart)
1381
2310
  : undefined, error, true)
@@ -1503,8 +2432,7 @@ export function createNestedPlaybookBridge(options) {
1503
2432
  }
1504
2433
  const active = mode.active;
1505
2434
  if (active.signal.aborted) {
1506
- const error = active.signal.reason ??
1507
- new Error('Restored nested playbook invocation aborted');
2435
+ const error = active.signal.reason;
1508
2436
  rollbackRestoredCall(mode, error);
1509
2437
  restoreMode = undefined;
1510
2438
  throw error;
@@ -1532,7 +2460,7 @@ export function createNestedPlaybookBridge(options) {
1532
2460
  listener(pendingCall);
1533
2461
  }
1534
2462
  catch (error) {
1535
- reportBackgroundError(error);
2463
+ reportBackgroundError(error, current?.aborts);
1536
2464
  }
1537
2465
  }
1538
2466
  return () => pendingListeners.delete(listener);
@@ -1560,8 +2488,17 @@ export function createNestedPlaybookBridge(options) {
1560
2488
  }
1561
2489
  throw error;
1562
2490
  }
1563
- options.bindResumeSignal?.(signal);
1564
- await settlePending(active, validatedResult);
2491
+ // A resume whose signal is already aborted delivers nothing: the
2492
+ // validated child result is not consumed, no finish is emitted, and
2493
+ // the pending call survives for a later resume with a fresh signal
2494
+ // (slc/link.md §Nested playbook bridge). Identity and validation
2495
+ // control errors above still win — they are the caller's defects.
2496
+ if (signal.aborted) {
2497
+ throw signal.reason;
2498
+ }
2499
+ const resumeAborts = createAbortReasonClassifier(active.aborts, signal);
2500
+ options.bindResumeSignal?.(signal, resumeAborts);
2501
+ await settlePending(active, validatedResult, undefined, resumeAborts);
1565
2502
  },
1566
2503
  abortPending,
1567
2504
  async dispose() {